From e6ad078d7a8dfebe2f8d73ec94d165be0dc80a7b Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Fri, 15 May 2026 12:00:56 +0530 Subject: [PATCH 01/26] fix(web): kill RSC prefetch storm + dedupe in-flight fetches + retry on transient timeout (closes #1855) (#1856) * fix: reduce session page fetch starvation * fix: address fetch dedupe review feedback * fix: preserve body-read timeouts in client fetch --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> --- .../web/src/app/sessions/[id]/page.test.tsx | 54 +++- packages/web/src/app/sessions/[id]/page.tsx | 79 +++++- .../web/src/components/ProjectSidebar.tsx | 2 + .../__tests__/UpdateBanner.test.tsx | 22 ++ .../__tests__/useSessionEvents.mux.test.ts | 2 +- .../src/lib/__tests__/client-fetch.test.ts | 107 +++++++ packages/web/src/lib/client-fetch.ts | 266 +++++++++++++++++- 7 files changed, 511 insertions(+), 21 deletions(-) create mode 100644 packages/web/src/lib/__tests__/client-fetch.test.ts diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index b6f405c02..1f3158e8d 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -149,20 +149,20 @@ describe("SessionPage project polling", () => { expect(fetch).toHaveBeenCalledWith( "/api/projects", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions/worker-1", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions?fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect( @@ -258,7 +258,7 @@ describe("SessionPage project polling", () => { expect(fetch).toHaveBeenCalledWith( "/api/sessions?project=my-app&fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); }); @@ -336,7 +336,7 @@ describe("SessionPage project polling", () => { expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument(); }); - it("times out a stuck session fetch and replaces the infinite loader with an error state", async () => { + it("keeps retrying after a stuck session fetch times out before showing an error state", async () => { global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url === "/api/projects") { @@ -377,12 +377,30 @@ describe("SessionPage project polling", () => { }); await flushAsyncWork(); + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + await flushAsyncWork(); + + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(12_000); + }); + await flushAsyncWork(); + expect(screen.getByText("Failed to load session")).toBeInTheDocument(); expect(screen.getByText(/taking too long/i)).toBeInTheDocument(); expect(screen.queryByText("Loading session…")).not.toBeInTheDocument(); }); - it("shows a recoverable unavailable state when the first session request aborts", async () => { + it("does not show the route error after the first aborted session request", async () => { + let sessionFetches = 0; + global.fetch = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url === "/api/projects") { @@ -396,6 +414,7 @@ describe("SessionPage project polling", () => { } if (url === "/api/sessions/worker-1") { + sessionFetches += 1; return Promise.reject(new DOMException("Aborted", "AbortError")); } @@ -415,9 +434,24 @@ describe("SessionPage project polling", () => { render(); await flushAsyncWork(); - expect(screen.getByText("Session unavailable")).toBeInTheDocument(); - expect(screen.getByText(/backend has not returned this session yet/i)).toBeInTheDocument(); - expect(screen.queryByText("Loading session…")).not.toBeInTheDocument(); + expect(sessionFetches).toBe(1); + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000); + }); + await flushAsyncWork(); + + expect(sessionFetches).toBeGreaterThanOrEqual(3); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4_000); + }); + await flushAsyncWork(); + + expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0); }); it("marks sidebar data as loading until the sessions list resolves", async () => { diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index cbd7efb63..f16c5c365 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -84,6 +84,9 @@ let cachedProjects: ProjectInfo[] | null = null; let cachedSidebarSessions: DashboardSession[] | null = null; const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000; const SESSION_FETCH_TIMEOUT_MS = 8000; +const SESSION_LOAD_MAX_CONSECUTIVE_FAILURES = 4; +const SESSION_LOAD_MAX_RETRY_ELAPSED_MS = 30_000; +const SESSION_LOAD_RETRY_BACKOFF_MS = [1_000, 2_000, 4_000] as const; const PROJECT_SIDEBAR_FETCH_TIMEOUT_MS = 5000; const PROJECTS_FETCH_TIMEOUT_MS = 5000; const validSessionStatuses = new Set(Object.values(SESSION_STATUS)); @@ -136,6 +139,19 @@ function isAbortLikeError(error: unknown): boolean { return false; } +function isTransientSessionLoadError(error: unknown): boolean { + if (isAbortLikeError(error)) return true; + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes("timed out") || + message.includes("network") || + message.includes("failed to fetch") + ); + } + return false; +} + function getSessionLoadErrorMessage(error: Error): string { const normalized = error.message.toLowerCase(); if (normalized.includes("timed out")) { @@ -418,6 +434,22 @@ export default function SessionPage() { const projectSessionsFetchControllerRef = useRef(null); const sidebarFetchControllerRef = useRef(null); const pageUnloadingRef = useRef(false); + const sessionLoadFailureCountRef = useRef(0); + const sessionLoadFirstFailureAtRef = useRef(null); + const sessionLoadRetryTimerRef = useRef | null>(null); + + const clearSessionLoadRetry = useCallback(() => { + if (sessionLoadRetryTimerRef.current) { + clearTimeout(sessionLoadRetryTimerRef.current); + sessionLoadRetryTimerRef.current = null; + } + }, []); + + const resetSessionLoadFailures = useCallback(() => { + sessionLoadFailureCountRef.current = 0; + sessionLoadFirstFailureAtRef.current = null; + clearSessionLoadRetry(); + }, [clearSessionLoadRetry]); // Keep prefixByProjectRef in sync so fetchProjectSessions (stable [] dep) reads latest map useEffect(() => { @@ -497,6 +529,7 @@ export default function SessionPage() { fetchingSessionRef.current = true; const controller = new AbortController(); sessionFetchControllerRef.current = controller; + let keepLoadingForRetry = false; try { const data = await fetchJsonWithTimeout( `/api/sessions/${encodeURIComponent(id)}`, @@ -510,8 +543,9 @@ export default function SessionPage() { setRouteError(null); setSessionMissing(false); hasLoadedSessionRef.current = true; + resetSessionLoadFailures(); } catch (err) { - if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) { + if (pageUnloadingRef.current || controller.signal.aborted) { return; } const message = err instanceof Error ? err.message : "Failed to load session"; @@ -524,20 +558,55 @@ export default function SessionPage() { setSessionMissing(true); } setLoading(false); + resetSessionLoadFailures(); return; } + + if (!hasLoadedSessionRef.current && isTransientSessionLoadError(err)) { + const failureCount = sessionLoadFailureCountRef.current + 1; + sessionLoadFailureCountRef.current = failureCount; + sessionLoadFirstFailureAtRef.current ??= Date.now(); + const elapsedMs = Date.now() - sessionLoadFirstFailureAtRef.current; + const shouldKeepRetrying = + failureCount < SESSION_LOAD_MAX_CONSECUTIVE_FAILURES && + elapsedMs < SESSION_LOAD_MAX_RETRY_ELAPSED_MS; + + if (shouldKeepRetrying) { + const delay = + SESSION_LOAD_RETRY_BACKOFF_MS[ + Math.min(failureCount - 1, SESSION_LOAD_RETRY_BACKOFF_MS.length - 1) + ]; + keepLoadingForRetry = true; + setLoading(true); + console.warn("Session fetch failed transiently; retrying", { + sessionId: id, + failureCount, + retryInMs: delay, + error: err, + }); + clearSessionLoadRetry(); + sessionLoadRetryTimerRef.current = setTimeout(() => { + sessionLoadRetryTimerRef.current = null; + void fetchSession(); + }, delay); + return; + } + } + console.error("Failed to fetch session:", err); if (!hasLoadedSessionRef.current) { setRouteError(err instanceof Error ? err : new Error("Failed to load session")); } } finally { - setLoading(false); + if (!keepLoadingForRetry) { + setLoading(false); + } fetchingSessionRef.current = false; if (sessionFetchControllerRef.current === controller) { sessionFetchControllerRef.current = null; } } - }, [id]); + }, [clearSessionLoadRetry, id, resetSessionLoadFailures]); const fetchProjectSessions = useCallback(async () => { if (fetchingProjectSessionsRef.current) return; @@ -727,11 +796,12 @@ export default function SessionPage() { useEffect(() => { return () => { + clearSessionLoadRetry(); sessionFetchControllerRef.current?.abort(); projectSessionsFetchControllerRef.current?.abort(); sidebarFetchControllerRef.current?.abort(); }; - }, []); + }, [clearSessionLoadRetry]); if (loading) { return ( @@ -816,6 +886,7 @@ export default function SessionPage() { setRouteError(null); setSessionMissing(false); setLoading(true); + resetSessionLoadFailures(); void Promise.all([fetchProjects(), fetchSession(), fetchSidebarSessions()]); }, }} diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index cc8bb51f2..2b6b78e1c 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -634,6 +634,7 @@ function ProjectSidebarInner({ {!isDegraded ? ( { e.stopPropagation(); onMobileClose?.(); @@ -659,6 +660,7 @@ function ProjectSidebarInner({ {!isDegraded && orchestratorLink && ( { e.stopPropagation(); onMobileClose?.(); diff --git a/packages/web/src/components/__tests__/UpdateBanner.test.tsx b/packages/web/src/components/__tests__/UpdateBanner.test.tsx index 79b97b05c..f744a680b 100644 --- a/packages/web/src/components/__tests__/UpdateBanner.test.tsx +++ b/packages/web/src/components/__tests__/UpdateBanner.test.tsx @@ -20,8 +20,30 @@ function mockVersionResponse(body: { describe("UpdateBanner", () => { let fetchMock: ReturnType; + let localStorageStore: Map; beforeEach(() => { + localStorageStore = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + writable: true, + value: { + getItem: (key: string) => localStorageStore.get(key) ?? null, + setItem: (key: string, value: string) => { + localStorageStore.set(key, value); + }, + removeItem: (key: string) => { + localStorageStore.delete(key); + }, + clear: () => { + localStorageStore.clear(); + }, + get length() { + return localStorageStore.size; + }, + key: (index: number) => [...localStorageStore.keys()][index] ?? null, + }, + }); window.localStorage.clear(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts b/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts index 19e5e4115..cfbf1a66d 100644 --- a/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts +++ b/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts @@ -50,7 +50,7 @@ describe("useSessionEvents - mux", () => { await waitFor(() => { expect(fetch).toHaveBeenCalledWith( "/api/sessions?project=proj", - expect.objectContaining({ signal: expect.any(AbortSignal), cache: "no-store" }), + expect.objectContaining({ cache: "no-store" }), ); }); }); diff --git a/packages/web/src/lib/__tests__/client-fetch.test.ts b/packages/web/src/lib/__tests__/client-fetch.test.ts new file mode 100644 index 000000000..0625478a9 --- /dev/null +++ b/packages/web/src/lib/__tests__/client-fetch.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { __clearInflightFetchesForTest, dedupFetch, fetchJsonWithTimeout } from "../client-fetch"; + +describe("dedupFetch", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + __clearInflightFetchesForTest(); + }); + + it("shares one underlying request for concurrent requests with the same key", async () => { + let resolveFetch: ((response: Response) => void) | undefined; + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const first = dedupFetch("/api/sessions/ao-187"); + const second = dedupFetch("/api/sessions/ao-187"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + const response = new Response(JSON.stringify({ ok: true }), { status: 200 }); + resolveFetch?.(response); + + await expect(first.then((res) => res.json())).resolves.toEqual({ ok: true }); + await expect(second.then((res) => res.json())).resolves.toEqual({ ok: true }); + }); + + it("starts a new request after the in-flight request settles", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("{}", { status: 200 })) + .mockResolvedValueOnce(new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await dedupFetch("/api/sessions/ao-187"); + await dedupFetch("/api/sessions/ao-187"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not coalesce requests with different headers", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("{}", { status: 200 })) + .mockResolvedValueOnce(new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await Promise.all([ + dedupFetch("/api/sessions/ao-187", { headers: { Accept: "application/json" } }), + dedupFetch("/api/sessions/ao-187", { headers: { Accept: "text/plain" } }), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("aborts the underlying fetch when all waiters abort", async () => { + const controller = new AbortController(); + let underlyingSignal: AbortSignal | undefined; + const fetchMock = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + underlyingSignal = init?.signal ?? undefined; + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const request = dedupFetch("/api/sessions/ao-187", { signal: controller.signal }); + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect(underlyingSignal?.aborted).toBe(true); + }); + + it("times out when response headers arrive but the JSON body stalls", async () => { + vi.useFakeTimers(); + const cancelBody = vi.fn(); + const body = new ReadableStream({ + cancel: cancelBody, + }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(body, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const request = fetchJsonWithTimeout("/api/sessions/ao-187", { + timeoutMs: 100, + timeoutMessage: "session timed out", + }); + const expectation = expect(request).rejects.toThrow("session timed out"); + + await vi.advanceTimersByTimeAsync(100); + + await expectation; + expect(cancelBody).toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/lib/client-fetch.ts b/packages/web/src/lib/client-fetch.ts index aede5f60d..03421d685 100644 --- a/packages/web/src/lib/client-fetch.ts +++ b/packages/web/src/lib/client-fetch.ts @@ -5,6 +5,215 @@ interface FetchJsonOptions extends RequestInit { timeoutMessage?: string; } +interface InflightFetch { + controller: AbortController; + consumers: number; + maxConsumers: number; + promise: Promise; + settled: boolean; +} + +const inflightFetches = new Map(); + +function getFetchUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function getFetchMethod(input: RequestInfo | URL, init: RequestInit | undefined): string { + return (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); +} + +function hashBody(body: BodyInit | null | undefined): string { + if (body === null || body === undefined) return ""; + if (typeof body === "string") return body; + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof Blob) return `blob:${body.type}:${body.size}`; + if (body instanceof ArrayBuffer) return `array-buffer:${body.byteLength}`; + if (ArrayBuffer.isView(body)) { + return `${body.constructor.name}:${body.byteOffset}:${body.byteLength}`; + } + if (body instanceof FormData) { + return [...body.entries()] + .map(([key, value]) => + typeof File !== "undefined" && value instanceof File + ? `${key}=file:${value.name}:${value.type}:${value.size}` + : `${key}=${value}`, + ) + .join("&"); + } + return body.constructor.name; +} + +function hashHeaders(input: RequestInfo | URL, init: RequestInit | undefined): string { + const headers = new Headers(input instanceof Request ? input.headers : undefined); + if (init?.headers) { + new Headers(init.headers).forEach((value, key) => { + headers.set(key, value); + }); + } + return [...headers.entries()] + .map(([key, value]) => `${key.toLowerCase()}:${value}`) + .sort() + .join("\n"); +} + +function getFetchKey(input: RequestInfo | URL, init: RequestInit | undefined): string { + return `${getFetchUrl(input)}|${getFetchMethod(input, init)}|${hashHeaders(input, init)}|${hashBody(init?.body)}`; +} + +function cloneResponse(response: Response): Response { + if (typeof response.clone !== "function") { + throw new TypeError("Cannot clone deduplicated fetch response"); + } + return response.clone(); +} + +function abortError(): DOMException { + return new DOMException("The operation was aborted.", "AbortError"); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw abortError(); + } +} + +async function readResponseText(response: Response, signal: AbortSignal | undefined): Promise { + throwIfAborted(signal); + + if (!response.body) { + return typeof response.text === "function" ? await response.text() : ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + + try { + while (true) { + throwIfAborted(signal); + let removeAbortListener: () => void = () => {}; + const abortPromise = + signal === undefined + ? null + : new Promise((_, reject) => { + const abort = () => { + void reader.cancel().catch(() => {}); + reject(abortError()); + }; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener("abort", abort, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", abort); + }); + + try { + const result = await Promise.race([ + reader.read(), + ...(abortPromise ? [abortPromise] : []), + ]); + if (result.done) break; + chunks.push(decoder.decode(result.value, { stream: true })); + } finally { + removeAbortListener(); + } + } + } finally { + reader.releaseLock(); + } + + chunks.push(decoder.decode()); + return chunks.join(""); +} + +async function readResponseJson( + response: Response, + signal: AbortSignal | undefined, +): Promise { + throwIfAborted(signal); + if (!response.body && typeof response.json === "function") { + return (await response.json()) as T; + } + return JSON.parse(await readResponseText(response, signal)) as T; +} + +export function dedupFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const key = getFetchKey(input, init); + let entry = inflightFetches.get(key); + if (!entry) { + const controller = new AbortController(); + const { signal: _signal, ...sharedInit } = init ?? {}; + const request = fetch(input, { ...sharedInit, signal: controller.signal }); + const newEntry: InflightFetch = { + controller, + consumers: 0, + maxConsumers: 0, + promise: request, + settled: false, + }; + request.finally(() => { + newEntry.settled = true; + inflightFetches.delete(key); + }).catch(() => { + // The original request promise is returned to callers; this side-effect + // chain only prevents an unhandled rejection from the cleanup branch. + }); + entry = newEntry; + inflightFetches.set(key, entry); + } + + entry.consumers += 1; + entry.maxConsumers = Math.max(entry.maxConsumers, entry.consumers); + + const callerSignal = init?.signal ?? undefined; + let removeAbortListener: () => void = () => {}; + let released = false; + const release = () => { + if (released) return; + released = true; + removeAbortListener(); + entry.consumers -= 1; + if (entry.consumers === 0 && !entry.settled) { + entry.controller.abort(); + } + }; + + const responsePromise = entry.promise.then((response) => + entry.maxConsumers > 1 ? cloneResponse(response) : response, + ); + + const abortPromise = + callerSignal === undefined + ? null + : new Promise((_, reject) => { + const abort = () => { + release(); + reject(abortError()); + }; + if (callerSignal.aborted) { + abort(); + return; + } + callerSignal.addEventListener("abort", abort, { once: true }); + removeAbortListener = () => callerSignal.removeEventListener("abort", abort); + }); + + return Promise.race([responsePromise, ...(abortPromise ? [abortPromise] : [])]).finally( + release, + ); +} + +export function __clearInflightFetchesForTest(): void { + for (const entry of inflightFetches.values()) { + entry.controller.abort(); + } + inflightFetches.clear(); +} + function mergeAbortSignals( signals: Array, ): AbortSignal | undefined { @@ -26,14 +235,23 @@ function mergeAbortSignals( return controller.signal; } -async function readErrorMessage(response: Response): Promise { +async function readErrorMessage( + response: Response, + signal: AbortSignal | undefined, +): Promise { try { - const payload = (await response.json()) as { error?: string; message?: string } | null; + const payload = await readResponseJson<{ error?: string; message?: string } | null>( + response, + signal, + ); const message = payload?.error ?? payload?.message; if (typeof message === "string" && message.trim().length > 0) { return message.trim(); } - } catch { + } catch (error) { + if (signal?.aborted || (error instanceof DOMException && error.name === "AbortError")) { + throw error; + } // Ignore parse failures and fall back to status text. } @@ -52,6 +270,7 @@ export async function fetchJsonWithTimeout( const { timeoutMs = 8_000, timeoutMessage, signal, ...init } = options; const timeoutController = new AbortController(); let timer: ReturnType | null = null; + let removeAbortListener: () => void = () => {}; let timedOut = false; try { @@ -68,9 +287,28 @@ export async function fetchJsonWithTimeout( reject(new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`)); }, timeoutMs); }); + void timeoutPromise.catch(() => {}); + + const abortPromise = + mergedSignal === undefined + ? null + : new Promise((_, reject) => { + const abort = () => { + if (!timedOut) { + reject(abortError()); + } + }; + if (mergedSignal.aborted) { + abort(); + return; + } + mergedSignal.addEventListener("abort", abort, { once: true }); + removeAbortListener = () => mergedSignal.removeEventListener("abort", abort); + }); + void abortPromise?.catch(() => {}); const response = await Promise.race([ - fetch(input, requestInit).catch((error: unknown) => { + dedupFetch(input, requestInit).catch((error: unknown) => { if (timedOut) { throw new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`, { cause: error, @@ -79,16 +317,32 @@ export async function fetchJsonWithTimeout( throw error; }), timeoutPromise, + ...(abortPromise ? [abortPromise] : []), ]); + const readWithTimeout = (read: Promise): Promise => + Promise.race([ + read.catch((error: unknown) => { + if (timedOut) { + throw new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`, { + cause: error, + }); + } + throw error; + }), + timeoutPromise, + ...(abortPromise ? [abortPromise] : []), + ]); + if (!response.ok) { - throw new Error(await readErrorMessage(response)); + throw new Error(await readWithTimeout(readErrorMessage(response, mergedSignal))); } - return (await response.json()) as T; + return await readWithTimeout(readResponseJson(response, mergedSignal)); } finally { if (timer) { clearTimeout(timer); } + removeAbortListener(); } } From 667d1dedfc31073260fe0f7935a182ae380c5fac Mon Sep 17 00:00:00 2001 From: Adil Shaikh <106678504+whoisasx@users.noreply.github.com> Date: Sat, 16 May 2026 19:05:49 +0530 Subject: [PATCH 02/26] fix(core): sm.list() no longer writes terminated state to disk (#1737) * fix(core): sm.list() no longer writes terminated state to disk (#1735) sm.list() was bypassing the lifecycle manager's probe decision matrix by persisting terminated state immediately on a single isAlive() failure. The dashboard's 3s poll via /api/sessions/patches called sm.list() ~10x more often than the lifecycle manager, so a transient runtime failure would permanently kill the session before the lifecycle manager could evaluate all three probes (runtime, process, activity). Changes: - sm.list() now persists "detecting" instead of "terminated" when it detects a dead runtime, so the lifecycle manager's resolveProbeDecision pipeline remains the single authority on terminal decisions. - /api/sessions/patches now calls listCached() instead of list(), preventing the dashboard's 3s poll from probing runtimes directly. The cache TTL (35s) aligns with the lifecycle manager's 30s poll. - Updated CLAUDE.md invariants to reflect the new behavior. * fix(core): skip re-persisting detecting state on subsequent list() calls Check the on-disk lifecycle state (raw metadata) instead of the in-memory state when deciding whether to persist. Enrichment already sets detecting in-memory, so the previous guard always skipped the persist block. Using the on-disk state ensures: - First detection: persists detecting + lastTransitionAt - Subsequent calls: skips re-write, preserving the original timestamp --- CLAUDE.md | 4 +-- .../__tests__/session-manager/query.test.ts | 7 ++++-- packages/core/src/session-manager.ts | 25 ++++++++++++------- .../web/src/app/api/sessions/patches/route.ts | 2 +- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 42c583e49..103911ac3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,7 @@ spawning -> working -> pr_open -> ci_failed / review_pending +-> mergeable -> merged -> cleanup -> done ``` -**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `runtime_lost` reason to disk. This maps to legacy status `killed`. Without this, sessions with dead runtimes would show stale "active" status indefinitely. +**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `detecting` state with `runtime_lost` reason to disk. The lifecycle manager's `resolveProbeDecision` pipeline is the single authority on terminal decisions — `sm.list()` never writes `terminated` directly (#1735). ### Data Flow @@ -224,7 +224,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - Kanban board filters client-side via `projectSessions` memo ### Key invariants -- `sm.list()` persists `runtime_lost` lifecycle to disk when enrichment detects dead runtimes — this is the only place stale runtime state gets reconciled +- `sm.list()` persists `detecting` state (not `terminated`) to disk when enrichment detects dead runtimes — terminal decisions are made only by the lifecycle manager's probe pipeline (#1735) - `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here - Tab completions merge local config + global config to show all projects diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index e8acb988d..cf7c040bf 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -220,7 +220,9 @@ describe("list", () => { const sm = createSessionManager({ config, registry: registryWithDead }); const sessions = await sm.list(); - expect(sessions[0].status).toBe("killed"); + // sm.list() persists "detecting" (not "terminated") so the lifecycle + // manager's probe pipeline makes the final terminal decision (#1735). + expect(sessions[0].status).toBe("detecting"); expect(sessions[0].activity).toBe("exited"); }); @@ -336,7 +338,8 @@ describe("list", () => { expect(sessions).toHaveLength(1); expect(sessions[0].runtimeHandle?.id).toBe(expectedTmuxName); - expect(sessions[0].status).toBe("killed"); + // sm.list() persists "detecting" so the lifecycle manager decides (#1735). + expect(sessions[0].status).toBe("detecting"); expect(sessions[0].activity).toBe("exited"); expect(agentWithSpy.getActivityState).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index be8238e10..b4879b8ee 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1941,23 +1941,30 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } } - // Persist lifecycle to disk when enrichment detected a dead runtime. - // enrichSessionWithRuntimeState updates the in-memory lifecycle but - // doesn't write to disk — without this, the stale "alive" state persists - // and the dashboard shows terminated sessions on the active sidebar. + // Persist runtime probe result to disk so the lifecycle manager sees it + // on next poll. We only persist the runtime signal and detecting state — + // the lifecycle manager's resolveProbeDecision pipeline is the single + // authority on terminal decisions (terminated/done). See #1735. + // Check the on-disk state (raw) to avoid re-writing when already + // detecting — enrichment sets detecting in-memory, but we only need + // to persist the transition once to avoid resetting lastTransitionAt. + const onDiskLifecycle = parseCanonicalLifecycle(raw, { + sessionId: sessionName, + status: validateStatus(raw["status"]), + }); if ( session.lifecycle && (session.lifecycle.runtime.state === "missing" || session.lifecycle.runtime.state === "exited") && - session.lifecycle.session.state !== "terminated" && - session.lifecycle.session.state !== "done" + onDiskLifecycle.session.state !== "terminated" && + onDiskLifecycle.session.state !== "done" && + onDiskLifecycle.session.state !== "detecting" ) { try { const persisted = buildUpdatedLifecycle(sessionName, raw, (next) => { - next.session.state = "terminated"; + next.session.state = "detecting"; next.session.reason = "runtime_lost"; - next.session.terminatedAt = new Date().toISOString(); - next.session.lastTransitionAt = next.session.terminatedAt; + next.session.lastTransitionAt = new Date().toISOString(); next.runtime.state = session.lifecycle!.runtime.state; next.runtime.reason = session.lifecycle!.runtime.reason; next.runtime.lastObservedAt = new Date().toISOString(); diff --git a/packages/web/src/app/api/sessions/patches/route.ts b/packages/web/src/app/api/sessions/patches/route.ts index 1ba9fa9de..0aa9df61b 100644 --- a/packages/web/src/app/api/sessions/patches/route.ts +++ b/packages/web/src/app/api/sessions/patches/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { ? projectFilter : undefined; - const coreSessions = await sessionManager.list(requestedProjectId); + const coreSessions = await sessionManager.listCached(requestedProjectId); const visibleSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects); // Convert to dashboard format From cb7b83d2238c02ea5e28e2d59e6443c32a6c6c70 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sun, 17 May 2026 20:03:07 +0530 Subject: [PATCH 03/26] fix(agent-claude-code): add AO-JSONL safety net cascade (closes #1897) (#1903) * fix(agent-claude-code): add AO-JSONL safety net cascade claude-code now has the same AO-JSONL fallback cascade as codex; protects against ~/.claude/projects slug drift (cf. #1611, commit 582c5373). Closes #1897. References #1899. * fix(agent-claude-code): let stale native activity use AO fallback When Claude's latest native JSONL entry predates the AO session, fall through to the AO activity JSONL cascade before preserving the prior idle default. This keeps terminal-derived waiting_input from being hidden by an old native file. Addresses review on #1903. References #1899 and closes #1897. --- .../src/__tests__/activity-detection.test.ts | 95 +++++++++++++--- .../plugins/agent-claude-code/src/index.ts | 105 +++++++++++------- 2 files changed, 147 insertions(+), 53 deletions(-) diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index ff9a78bcb..6199b3b19 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -1,9 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { toClaudeProjectPath, create } from "../index.js"; -import { createActivitySignal, type Session, type RuntimeHandle } from "@aoagents/ao-core"; +import { + createActivitySignal, + readLastActivityEntry, + type ActivityState, + type Session, + type RuntimeHandle, +} from "@aoagents/ao-core"; // Mock homedir() so getActivityState looks in our temp dir vi.mock("node:os", async (importOriginal) => { @@ -57,6 +63,16 @@ function writeJsonl( } } +function writeActivityLog(state: ActivityState, ageMs = 0): void { + const ts = new Date(Date.now() - ageMs).toISOString(); + const aoDir = join(workspacePath, ".ao"); + mkdirSync(aoDir, { recursive: true }); + writeFileSync( + join(aoDir, "activity.jsonl"), + JSON.stringify({ ts, state, source: "terminal" }) + "\n", + ); +} + // ============================================================================= // toClaudeProjectPath // ============================================================================= @@ -141,23 +157,74 @@ describe("Claude Code Activity Detection", () => { // Fallback cases (no JSONL data available) // ----------------------------------------------------------------------- - it("returns 'idle' when no session file exists yet", async () => { - // projectDir exists but is empty — no .jsonl files yet (freshly spawned session) - const session = makeSession(); - const result = await agent.getActivityState(session); - expect(result?.state).toBe("idle"); - // timestamp must be session.createdAt so stuck-detection can fire eventually - expect(result?.timestamp).toBe(session.createdAt); + it("returns null when no session file or AO activity entry exists yet", async () => { + // projectDir exists but is empty, and the AO safety-net log is absent. + expect(await agent.getActivityState(makeSession())).toBeNull(); }); it("returns null when no workspacePath", async () => { expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull(); }); - it("returns 'idle' when project directory does not exist", async () => { - // Process is running but no Claude project dir yet — treat as idle + it("returns null when project directory does not exist and AO activity is unavailable", async () => { const badPath = join(fakeHome, "nonexistent-workspace"); - expect((await agent.getActivityState(makeSession({ workspacePath: badPath })))?.state).toBe("idle"); + expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull(); + }); + + it("recordActivity writes to .ao/activity.jsonl when workspacePath is set", async () => { + await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o"); + + const result = await readLastActivityEntry(workspacePath); + expect(result?.entry.state).toBe("waiting_input"); + expect(result?.entry.source).toBe("terminal"); + expect(result?.entry.trigger).toContain("Do you want to proceed?"); + }); + + it("recordActivity is a no-op when workspacePath is null", async () => { + await agent.recordActivity?.( + makeSession({ workspacePath: null }), + "Do you want to proceed?\n(Y)es / (N)o", + ); + + expect(existsSync(join(workspacePath, ".ao", "activity.jsonl"))).toBe(false); + }); + + it("keeps native JSONL as primary when AO activity JSONL also exists", async () => { + writeJsonl([{ type: "assistant", message: { content: "Done!" } }]); + writeActivityLog("waiting_input"); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + }); + + it("falls back to AO JSONL waiting_input when native session lookup is unavailable", async () => { + await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o"); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); + }); + + it("falls back to AO JSONL waiting_input when native session entry predates this session", async () => { + writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000); + const session = makeSession({ createdAt: new Date() }); + + await agent.recordActivity?.(session, "Do you want to proceed?\n(Y)es / (N)o"); + + expect((await agent.getActivityState(session))?.state).toBe("waiting_input"); + }); + + it("returns idle for stale native session entry when AO JSONL is unavailable", async () => { + writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000); + const session = makeSession({ createdAt: new Date() }); + + const result = await agent.getActivityState(session); + + expect(result?.state).toBe("idle"); + expect(result?.timestamp).toBe(session.createdAt); + }); + + it("falls back to AO JSONL age-decay when native session lookup is unavailable", async () => { + writeActivityLog("active", 400_000); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); // ----------------------------------------------------------------------- @@ -306,8 +373,8 @@ describe("Claude Code Activity Detection", () => { it("ignores agent- prefixed JSONL files", async () => { writeJsonl([{ type: "user" }], 0, "agent-toolkit.jsonl"); - // No real session file → process is running, treat as idle - expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + // No real session file and no AO activity fallback. + expect(await agent.getActivityState(makeSession())).toBeNull(); }); it("reads last entry from multi-entry JSONL (not first)", async () => { diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 1e5a933ac..7235b05b4 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -6,6 +6,10 @@ import { PROCESS_PROBE_INDETERMINATE, DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, + readLastActivityEntry, + checkActivityLogState, + getActivityFallbackState, + recordTerminalActivity, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -947,6 +951,13 @@ function createClaudeCodeAgent(): Agent { return classifyTerminalOutput(terminalOutput); }, + async recordActivity(session: Session, terminalOutput: string): Promise { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => + this.detectActivity(output), + ); + }, + async isProcessRunning(handle: RuntimeHandle): Promise { const pid = await findClaudeProcess(handle); if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; @@ -976,51 +987,67 @@ function createClaudeCodeAgent(): Agent { const projectDir = join(homedir(), ".claude", "projects", projectPath); const sessionFile = await findLatestSessionFile(projectDir); - if (!sessionFile) { - // No session file yet — process is running but no conversation started. - // Treat as idle (waiting for first task). - return { state: "idle", timestamp: session.createdAt }; + let staleNativeState: ActivityDetection | null = null; + if (sessionFile) { + const entry = await readLastJsonlEntry(sessionFile); + if (entry) { + // If the JSONL entry predates this session, it's from a previous session + // in the same worktree. Fall through to the AO safety net first: the + // terminal may have already surfaced waiting_input/blocked before + // Claude writes this session's first native JSONL entry. + if (session.createdAt && entry.modifiedAt < session.createdAt) { + staleNativeState = { state: "idle", timestamp: session.createdAt }; + } else { + const ageMs = Date.now() - entry.modifiedAt.getTime(); + const timestamp = entry.modifiedAt; + + const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); + switch (entry.lastType) { + case "user": + case "tool_use": + case "progress": + if (ageMs <= activeWindowMs) return { state: "active", timestamp }; + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + case "assistant": + case "system": + case "summary": + case "result": + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + case "permission_request": + return { state: "waiting_input", timestamp }; + + case "error": + return { state: "blocked", timestamp }; + + default: + if (ageMs <= activeWindowMs) return { state: "active", timestamp }; + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + } + } + } + + // Session file exists but no parseable entry — fall through to AO JSONL + // checks below instead of returning early, so terminal-derived + // waiting_input/blocked can still be detected. } - const entry = await readLastJsonlEntry(sessionFile); - if (!entry) { - // Empty file or read error — cannot determine activity - return null; - } - - // If the JSONL entry predates this session, it's from a previous session - // in the same worktree. Treat as no data (agent hasn't written yet). - if (session.createdAt && entry.modifiedAt < session.createdAt) { - return { state: "idle", timestamp: session.createdAt }; - } - - const ageMs = Date.now() - entry.modifiedAt.getTime(); - const timestamp = entry.modifiedAt; + // Fallback: check AO activity JSONL (terminal-derived) for + // waiting_input/blocked when Claude's native JSONL is unavailable. + const activityResult = await readLastActivityEntry(session.workspacePath); + const activityState = checkActivityLogState(activityResult); + if (activityState) return activityState; + // Last fallback: use the AO entry with age-based decay when native + // session lookup is missing or unparseable (e.g. Claude project slug drift). const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); - switch (entry.lastType) { - case "user": - case "tool_use": - case "progress": - if (ageMs <= activeWindowMs) return { state: "active", timestamp }; - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold); + if (fallback) return fallback; - case "assistant": - case "system": - case "summary": - case "result": - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + if (staleNativeState) return staleNativeState; - case "permission_request": - return { state: "waiting_input", timestamp }; - - case "error": - return { state: "blocked", timestamp }; - - default: - if (ageMs <= activeWindowMs) return { state: "active", timestamp }; - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - } + return null; }, async getSessionInfo(session: Session): Promise { From ce6b47adf87d87f8e1a44a6515bfa718c1b79863 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sun, 17 May 2026 20:03:56 +0530 Subject: [PATCH 04/26] fix(core): keep actionable activity sticky (#1902) `waiting_input`/`blocked` no longer decay on wallclock; clears only on process death or newer entry. Close #1894. Reference #1899. --- CLAUDE.md | 6 +- .../core/src/__tests__/activity-log.test.ts | 126 +++++++++++++++++- packages/core/src/activity-log.ts | 28 +--- website/content/docs/architecture.mdx | 2 +- website/content/docs/plugins/authoring.mdx | 2 +- 5 files changed, 131 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 103911ac3..731ee041f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -450,8 +450,8 @@ import { validateUrl, // Webhook URL validation readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL) readLastActivityEntry, // Read last AO activity JSONL entry - checkActivityLogState, // Extract waiting_input/blocked from AO JSONL (with staleness cap) - getActivityFallbackState, // Last-resort fallback: entry state + age-based decay + checkActivityLogState, // Extract sticky waiting_input/blocked from AO JSONL + getActivityFallbackState, // Last-resort fallback: actionable states + liveness age decay recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append) classifyTerminalActivity, // Classify terminal output via detectActivity appendActivityEntry, // Low-level JSONL append @@ -460,7 +460,7 @@ import { normalizeAgentPermissionMode, // Normalize permission mode strings DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window - ACTIVITY_INPUT_STALENESS_MS, // 5 min — waiting_input/blocked expiry + ACTIVITY_INPUT_STALENESS_MS, // Deprecated compatibility export; actionable states no longer expire by wallclock PREFERRED_GH_PATH, // /usr/local/bin/gh CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants type Session, type ProjectConfig, type RuntimeHandle, diff --git a/packages/core/src/__tests__/activity-log.test.ts b/packages/core/src/__tests__/activity-log.test.ts index 858cfea74..6d8288b8d 100644 --- a/packages/core/src/__tests__/activity-log.test.ts +++ b/packages/core/src/__tests__/activity-log.test.ts @@ -9,9 +9,26 @@ import { appendActivityEntry, recordTerminalActivity, getActivityLogPath, - ACTIVITY_INPUT_STALENESS_MS, + getActivityFallbackState, } from "../activity-log.js"; -import type { ActivityState } from "../types.js"; +import type { ActivityDetection, ActivityLogEntry, ActivityState } from "../types.js"; + +const minutesAgo = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString(); + +const toActivityResult = ( + entry: ActivityLogEntry, +): { entry: ActivityLogEntry; modifiedAt: Date } => ({ + entry, + modifiedAt: new Date(entry.ts), +}); + +const detectWithProcessCheck = ( + isProcessRunning: boolean, + activityResult: { entry: ActivityLogEntry; modifiedAt: Date } | null, +): ActivityDetection | null => { + if (!isProcessRunning) return { state: "exited", timestamp: new Date() }; + return checkActivityLogState(activityResult) ?? getActivityFallbackState(activityResult, 30_000, 5 * 60_000); +}; describe("classifyTerminalActivity", () => { it("returns active state with no trigger", () => { @@ -56,13 +73,20 @@ describe("checkActivityLogState", () => { expect(result?.state).toBe("blocked"); }); - it("returns null for stale waiting_input entry", () => { - const staleTs = new Date(Date.now() - ACTIVITY_INPUT_STALENESS_MS - 1000).toISOString(); + it("returns waiting_input even when older than the former wallclock cap", () => { const result = checkActivityLogState({ - entry: { ts: staleTs, state: "waiting_input", source: "terminal" }, + entry: { ts: minutesAgo(10), state: "waiting_input", source: "terminal" }, modifiedAt: new Date(), }); - expect(result).toBeNull(); + expect(result?.state).toBe("waiting_input"); + }); + + it("returns blocked even when older than the former wallclock cap", () => { + const result = checkActivityLogState({ + entry: { ts: minutesAgo(6), state: "blocked", source: "terminal" }, + modifiedAt: new Date(), + }); + expect(result?.state).toBe("blocked"); }); it("returns null for non-critical states", () => { @@ -82,6 +106,77 @@ describe("checkActivityLogState", () => { }); }); +describe("getActivityFallbackState", () => { + it("returns waiting_input for a 10-minute-old entry instead of decaying to idle", () => { + const result = getActivityFallbackState( + toActivityResult({ ts: minutesAgo(10), state: "waiting_input", source: "terminal" }), + 30_000, + 5 * 60_000, + ); + + expect(result?.state).toBe("waiting_input"); + }); + + it("returns blocked for a 6-minute-old entry instead of decaying to idle", () => { + const result = getActivityFallbackState( + toActivityResult({ ts: minutesAgo(6), state: "blocked", source: "terminal" }), + 30_000, + 5 * 60_000, + ); + + expect(result?.state).toBe("blocked"); + }); + + it("returns blocked for a 1-minute-old entry with unchanged behavior", () => { + const result = getActivityFallbackState( + toActivityResult({ ts: minutesAgo(1), state: "blocked", source: "terminal" }), + 30_000, + 5 * 60_000, + ); + + expect(result?.state).toBe("blocked"); + }); + + it("lets a newer active entry override an older waiting_input entry", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "ao-test-")); + try { + await mkdir(join(tmpDir, ".ao"), { recursive: true }); + const waitingEntry: ActivityLogEntry = { + ts: minutesAgo(6), + state: "waiting_input", + source: "terminal", + }; + const activeEntry: ActivityLogEntry = { + ts: new Date(Date.now() - 1000).toISOString(), + state: "active", + source: "terminal", + }; + await writeFile( + getActivityLogPath(tmpDir), + `${JSON.stringify(waitingEntry)}\n${JSON.stringify(activeEntry)}\n`, + "utf-8", + ); + + const activityResult = await readLastActivityEntry(tmpDir); + const result = getActivityFallbackState(activityResult, 30_000, 5 * 60_000); + + expect(activityResult?.entry.state).toBe("active"); + expect(result?.state).toBe("active"); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns exited when the process check fails before a stale waiting_input can fall through", () => { + const result = detectWithProcessCheck( + false, + toActivityResult({ ts: minutesAgo(6), state: "waiting_input", source: "terminal" }), + ); + + expect(result?.state).toBe("exited"); + }); +}); + describe("readLastActivityEntry", () => { let tmpDir: string; @@ -144,6 +239,25 @@ describe("readLastActivityEntry", () => { const result = await readLastActivityEntry(tmpDir); expect(result).toBeNull(); }); + + it("falls back to the previous complete line when a read races a truncated tail", async () => { + await mkdir(join(tmpDir, ".ao"), { recursive: true }); + const completeEntry: ActivityLogEntry = { + ts: minutesAgo(10), + state: "waiting_input", + source: "terminal", + trigger: "approve?", + }; + await writeFile( + getActivityLogPath(tmpDir), + `${JSON.stringify(completeEntry)}\n{"ts":"${new Date().toISOString()}","state":`, + "utf-8", + ); + + const result = await readLastActivityEntry(tmpDir); + + expect(result?.entry).toEqual(completeEntry); + }); }); describe("recordTerminalActivity", () => { diff --git a/packages/core/src/activity-log.ts b/packages/core/src/activity-log.ts index 1334bfb2e..56a1e02c3 100644 --- a/packages/core/src/activity-log.ts +++ b/packages/core/src/activity-log.ts @@ -15,10 +15,8 @@ import { join, dirname } from "node:path"; import type { ActivityState, ActivityLogEntry, ActivityDetection } from "./types.js"; /** - * Maximum age (ms) for `waiting_input`/`blocked` entries before they're - * considered stale. If no new terminal output overwrites the entry within - * this window, the state falls through to downstream fallbacks instead of - * keeping the session stuck in `needs_input` on the dashboard forever. + * @deprecated Actionable states no longer decay on wallclock. Retained until + * the activity-reducer cleanup removes the old activity-log module. */ export const ACTIVITY_INPUT_STALENESS_MS = 5 * 60 * 1000; // 5 minutes @@ -129,7 +127,7 @@ export async function readLastActivityEntry( /** * Check the AO activity JSONL for actionable states only. * - * Only returns `waiting_input`/`blocked` (with a staleness cap). + * Only returns `waiting_input`/`blocked`. * Non-critical states (`active`, `ready`, `idle`) always return `null` so * callers fall through to their native signals (git commits, chat history, * API queries, native JSONL). This prevents the lifecycle manager's @@ -144,18 +142,12 @@ export function checkActivityLogState( const { entry } = activityResult; if (entry.state === "waiting_input" || entry.state === "blocked") { - // Use the entry's own timestamp for staleness — not file mtime, which - // gets refreshed every poll cycle by recordActivity and would prevent - // stale entries from ever being detected. const entryTs = new Date(entry.ts); if (Number.isNaN(entryTs.getTime())) return null; - const ageMs = Date.now() - entryTs.getTime(); - if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) { - return { state: entry.state, timestamp: entryTs }; - } + return { state: entry.state, timestamp: entryTs }; } - // Non-critical states and stale entries — fall through to native signals + // Non-critical states fall through to native signals return null; } @@ -178,16 +170,8 @@ export function getActivityFallbackState( const entryTs = new Date(entry.ts); if (Number.isNaN(entryTs.getTime())) return null; - // Actionable states use the same staleness cap as checkActivityLogState. - // If the entry is stale, fall through to age-based decay instead of - // re-surfacing a waiting_input/blocked that checkActivityLogState already filtered. if (entry.state === "waiting_input" || entry.state === "blocked") { - const ageMs = Date.now() - entryTs.getTime(); - if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) { - return { state: entry.state, timestamp: entryTs }; - } - // Stale actionable entry — treat as idle - return { state: "idle", timestamp: entryTs }; + return { state: entry.state, timestamp: entryTs }; } // Age-based decay: active→ready→idle, but never promote past the diff --git a/website/content/docs/architecture.mdx b/website/content/docs/architecture.mdx index 421d7d935..dacbe9b96 100644 --- a/website/content/docs/architecture.mdx +++ b/website/content/docs/architecture.mdx @@ -253,7 +253,7 @@ Step 4 (the JSONL entry fallback) is mandatory. Skipping it means `getActivitySt |---|---|---| | `DEFAULT_ACTIVE_WINDOW_MS` | 30 seconds | Activity newer than this is `active`; older is `ready` | | `DEFAULT_READY_THRESHOLD_MS` | 5 minutes | `ready` sessions older than this become `idle` | -| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | `waiting_input` / `blocked` JSONL entries expire after this duration | +| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | Deprecated compatibility export. `waiting_input` / `blocked` entries no longer expire by wallclock; they persist until process death or a newer entry overrides them. | --- diff --git a/website/content/docs/plugins/authoring.mdx b/website/content/docs/plugins/authoring.mdx index 57a10415e..cd8798eb7 100644 --- a/website/content/docs/plugins/authoring.mdx +++ b/website/content/docs/plugins/authoring.mdx @@ -468,7 +468,7 @@ All utilities are exported from `@aoagents/ao-core`. The source lives in `packag |--------|-------| | `DEFAULT_READY_THRESHOLD_MS` | `300_000` (5 min) — ready → idle threshold | | `DEFAULT_ACTIVE_WINDOW_MS` | `30_000` (30 s) — active → ready window | -| `ACTIVITY_INPUT_STALENESS_MS` | `300_000` (5 min) — waiting_input/blocked expiry | +| `ACTIVITY_INPUT_STALENESS_MS` | Deprecated compatibility export (`300_000`); waiting_input/blocked no longer expire by wallclock | | `PREFERRED_GH_PATH` | `/usr/local/bin/gh` | | `CI_STATUS` | `{ PENDING, PASSING, FAILING, NONE }` | | `ACTIVITY_STATE` | `{ ACTIVE, READY, IDLE, WAITING_INPUT, BLOCKED, EXITED }` | From 94981dc0fdb186bc65bd769a97156451337dc51e Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sun, 17 May 2026 21:16:25 +0530 Subject: [PATCH 05/26] feat(web,core): "Launch Orchestrator (clean context)" button (#1904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web,core): "Launch Orchestrator (clean context)" button Adds a dashboard action that replaces the project's canonical orchestrator with a fresh one — killing any existing orchestrator, deleting its metadata, and spawning a new session with no carryover. Why: users had no way to start an orchestrator with a clean slate from the dashboard. Previous orchestrator context (conversation history, stale state) silently carried over via the existing "Open Orchestrator" flow, which only worked for first-time spawn anyway. - core: new SessionManager.relaunchOrchestrator(config) that kills + deletes existing metadata then calls spawnOrchestrator. Ignores project.orchestratorSessionStrategy — replacement is the whole point. Coalesces concurrent calls via a dedicated relaunchOrchestratorPromises map (separate from ensureOrchestratorPromises since the semantics differ — a relaunch behind an ensure must not return the existing session). - web: POST /api/orchestrators accepts { clean: true } to route to the new method. OrchestratorSelector renders a "Launch Orchestrator (clean context)" button that uses window.confirm() before discarding an existing orchestrator; no confirm when none exists. Closes #1900, closes #1080. * fix(core,web): address PR #1904 review - core: cross-map race between ensureOrchestrator and relaunchOrchestrator. Each now awaits the other's in-flight promise (keyed by sessionId) before proceeding. Prevents (a) relaunch skipping the kill while ensure's spawnOrchestrator is mid-reservation, and (b) ensure returning a session that relaunch is about to kill. Adds two race regression tests. - web: align handleSpawnNew with handleRelaunchClean via the void expression form; add "Launching..." in-progress label to the clean-context button and a test that asserts it renders during POST. * refactor(web): rip out Orchestrator Selector page; relocate clean-launch action There is only ever one orchestrator per project, so the /orchestrators selector page is meaningless. Delete it along with its component, tests, and the unused mapSessionsToOrchestrators util. Drop GET /api/orchestrators (only consumer was the deleted page). Remove /orchestrators from project revalidate lists. The "Launch Orchestrator (clean context)" action that previously lived on the deleted page now appears in two places: - Dashboard header: a "Relaunch (clean)" button renders alongside the Orchestrator link whenever a project orchestrator exists. Uses window.confirm before discarding state. - Orchestrator session page: a "Relaunch (clean)" button in the SessionDetailHeader for live orchestrator sessions, calling POST /api/orchestrators with clean:true and reloading the session view. * refactor(web): remove Relaunch (clean) action from the Dashboard Keep the clean-launch action only on the orchestrator session page — that's where the user has the context to decide on a destructive restart. The Dashboard header just links to the orchestrator (or shows the existing Spawn Orchestrator button when none exists). * fix(web): surface relaunch failures with an inline error banner After confirm + POST /api/orchestrators with clean:true, the previous implementation only logged failures to console.error — leaving the user on a stale page with no signal that the destructive action partially executed. relaunchOrchestrator kills before respawning, so a failed respawn means the server has no orchestrator while the client still renders the old session view. Add local relaunchError state, set it on catch (parsed from the JSON error response when available), and render a dismissible error banner above the terminal area. The banner explicitly warns the user that the previous orchestrator may already be terminated and points them at the project dashboard to retry. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web,core): address PR #1904 review from @i-trytoohard - web: navigate to the new orchestrator's session path (from POST response) instead of window.location.reload(). Orchestrator session IDs are fixed per project so the path is the same in practice, but reading from the response is the right contract and a hard nav forces the terminal WebSocket to reconnect cleanly against the new tmux. - web: remove the `!terminalEnded` gate on the Relaunch (clean) button in SessionDetailHeader. Terminated orchestrators are exactly when the user wants to relaunch — hiding the button there was wrong. - core: log a warning instead of silently swallowing when an in-flight cross-map promise (ensure waiting on relaunch, or relaunch waiting on ensure) rejects before its caller proceeds. The catch-and-continue semantics are correct (the caller will re-check state anyway) but invisible failures were a debugging hazard. Adds a regression test that the button stays visible on terminated orchestrator sessions and that successful relaunch navigates via window.location.href. --- .changeset/launch-orchestrator-clean.md | 8 + .../__tests__/session-manager/spawn.test.ts | 181 +++++++++++- packages/core/src/__tests__/test-utils.ts | 5 + packages/core/src/session-manager.ts | 71 +++++ packages/core/src/types.ts | 7 + packages/web/src/__tests__/api-routes.test.ts | 79 +++-- .../web/src/__tests__/orchestrators.test.tsx | 103 ------- .../web/src/app/api/orchestrators/route.ts | 49 +--- .../web/src/app/api/projects/[id]/route.ts | 2 +- packages/web/src/app/api/projects/route.ts | 2 +- packages/web/src/app/orchestrators/page.tsx | 83 ------ .../src/components/OrchestratorSelector.tsx | 273 ------------------ packages/web/src/components/SessionDetail.tsx | 68 +++++ .../src/components/SessionDetailHeader.tsx | 26 ++ .../__tests__/OrchestratorSelector.test.tsx | 241 ---------------- .../__tests__/SessionDetail.desktop.test.tsx | 155 ++++++++++ packages/web/src/lib/orchestrator-utils.ts | 39 --- 17 files changed, 566 insertions(+), 826 deletions(-) create mode 100644 .changeset/launch-orchestrator-clean.md delete mode 100644 packages/web/src/__tests__/orchestrators.test.tsx delete mode 100644 packages/web/src/app/orchestrators/page.tsx delete mode 100644 packages/web/src/components/OrchestratorSelector.tsx delete mode 100644 packages/web/src/components/__tests__/OrchestratorSelector.test.tsx delete mode 100644 packages/web/src/lib/orchestrator-utils.ts diff --git a/.changeset/launch-orchestrator-clean.md b/.changeset/launch-orchestrator-clean.md new file mode 100644 index 000000000..a13c2f708 --- /dev/null +++ b/.changeset/launch-orchestrator-clean.md @@ -0,0 +1,8 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-web": minor +--- + +feat: "Launch Orchestrator (clean context)" action on the orchestrator session page + +Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080. diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 8f2057e27..d92c1a4f1 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -13,7 +13,7 @@ import { readMetadata, readMetadataRaw, } from "../../metadata.js"; -import { getProjectWorktreesDir } from "../../paths.js"; +import { getProjectDir, getProjectWorktreesDir } from "../../paths.js"; import type { OrchestratorConfig, PluginRegistry, @@ -2489,4 +2489,183 @@ describe("spawn", () => { }); }); + + describe("relaunchOrchestrator", () => { + it("spawns a fresh orchestrator when none exists", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(session.status).toBe("working"); + expect(mockWorkspace.create).toHaveBeenCalledTimes(1); + expect(mockRuntime.create).toHaveBeenCalledTimes(1); + }); + + it("kills and replaces an existing orchestrator regardless of strategy", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + expect(mockWorkspace.create).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "app-orchestrator" }), + ); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["status"]).toBe("working"); + expect(meta?.["runtimeHandle"]).not.toContain("old-rt"); + }); + + it("ignores project orchestratorSessionStrategy: ignore and still replaces", async () => { + const configWithIgnore: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"]!, + orchestratorSessionStrategy: "ignore", + }, + }, + }; + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config: configWithIgnore, registry: mockRegistry }); + + await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + expect(mockWorkspace.create).toHaveBeenCalled(); + }); + + it("coalesces concurrent relaunch calls into a single replacement", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const [s1, s2] = await Promise.all([ + sm.relaunchOrchestrator({ projectId: "my-app" }), + sm.relaunchOrchestrator({ projectId: "my-app" }), + ]); + + expect(s1.id).toBe("app-orchestrator"); + expect(s2.id).toBe("app-orchestrator"); + expect(mockRuntime.destroy).toHaveBeenCalledTimes(1); + expect(mockWorkspace.create).toHaveBeenCalledTimes(1); + expect(mockRuntime.create).toHaveBeenCalledTimes(1); + }); + + it("ensureOrchestrator waits for an in-flight relaunch before returning", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + let releaseWorkspace: () => void = () => {}; + const blockingWorkspace = new Promise((resolve) => { + releaseWorkspace = resolve; + }); + (mockWorkspace.create as ReturnType).mockImplementationOnce(async (cfg) => { + await blockingWorkspace; + return { + path: join(tmpDir, "ws-relaunch"), + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" }); + await Promise.resolve(); + const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" }); + + releaseWorkspace(); + + const [relaunched, ensured] = await Promise.all([relaunchPromise, ensurePromise]); + expect(relaunched.id).toBe("app-orchestrator"); + expect(ensured.id).toBe("app-orchestrator"); + // Both should resolve to the *post-relaunch* session, not the killed one. + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + }); + + it("waits for an in-flight ensureOrchestrator before replacing", async () => { + let releaseWorkspace: () => void = () => {}; + const blockingWorkspace = new Promise((resolve) => { + releaseWorkspace = resolve; + }); + const ensureWorkspacePath = join(tmpDir, "ws-ensure"); + (mockWorkspace.create as ReturnType).mockImplementationOnce(async (cfg) => { + await blockingWorkspace; + return { + path: ensureWorkspacePath, + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" }); + // Yield so ensure can register itself in the in-flight map. + await Promise.resolve(); + const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" }); + + releaseWorkspace(); + + const [ensured, relaunched] = await Promise.all([ensurePromise, relaunchPromise]); + + expect(ensured.id).toBe("app-orchestrator"); + expect(relaunched.id).toBe("app-orchestrator"); + // Relaunch's kill must run, destroying the runtime ensure just spawned. + expect(mockRuntime.destroy).toHaveBeenCalled(); + }); + + it("rewrites the orchestrator-prompt file with the new system prompt", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.relaunchOrchestrator({ + projectId: "my-app", + systemPrompt: "FRESH ORCHESTRATOR PROMPT", + }); + + const promptPath = join( + getProjectDir("my-app"), + "orchestrator-prompt-app-orchestrator.md", + ); + expect(existsSync(promptPath)).toBe(true); + expect(readFileSync(promptPath, "utf-8")).toBe("FRESH ORCHESTRATOR PROMPT"); + }); + }); }); diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index e7a51e9df..b20f05ffa 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -499,6 +499,11 @@ export function createMockSessionManager(): OpenCodeSessionManager { .mockResolvedValue( makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), ), + relaunchOrchestrator: vi + .fn() + .mockResolvedValue( + makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), + ), restore: vi.fn().mockResolvedValue(makeSession()), list: vi.fn().mockResolvedValue([]), listCached: vi.fn().mockResolvedValue([]), diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index b4879b8ee..84dbe6a1e 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -538,6 +538,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM expiresAt: number; } | null = null; const ensureOrchestratorPromises = new Map>(); + const relaunchOrchestratorPromises = new Map>(); function invalidateCache(): void { sessionCache = null; @@ -1815,6 +1816,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } const sessionId = getOrchestratorSessionId(project); + + // If a relaunch is mid-flight for this sessionId, wait it out — otherwise + // we could return a session that relaunch is about to kill, or race the + // relaunch's spawnOrchestrator on the same reservation. + const pendingRelaunch = relaunchOrchestratorPromises.get(sessionId); + if (pendingRelaunch) { + await pendingRelaunch.catch((err) => { + console.warn( + `[ensureOrchestrator] in-flight relaunch for ${sessionId} failed before ensure proceeded:`, + err, + ); + }); + } + const existing = await get(sessionId); if (existing) { const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( @@ -1876,6 +1891,61 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return promise; } + async function relaunchOrchestratorInternal( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + const sessionId = getOrchestratorSessionId(project); + const sessionsDir = getProjectSessionsDir(orchestratorConfig.projectId); + + // If ensureOrchestrator is mid-flight for this sessionId, wait it out. + // Otherwise get() would return null (metadata not yet written) and we'd + // skip the kill, then race the in-flight spawnOrchestrator on the same + // reservation — surfacing "session already exists" instead of replacing. + const pendingEnsure = ensureOrchestratorPromises.get(sessionId); + if (pendingEnsure) { + await pendingEnsure.catch((err) => { + console.warn( + `[relaunchOrchestrator] in-flight ensure for ${sessionId} failed before relaunch proceeded:`, + err, + ); + }); + } + + const existing = await get(sessionId); + if (existing) { + const existingAgent = resolveSelectionForSession( + project, + sessionId, + readMetadataRaw(sessionsDir, sessionId) ?? {}, + ).agentName; + await kill(sessionId, { purgeOpenCode: existingAgent === "opencode" }); + deleteMetadata(sessionsDir, sessionId); + } + return spawnOrchestrator(orchestratorConfig); + } + + async function relaunchOrchestrator( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + const sessionId = getOrchestratorSessionId(project); + const existingPromise = relaunchOrchestratorPromises.get(sessionId); + if (existingPromise) return existingPromise; + + const promise = relaunchOrchestratorInternal(orchestratorConfig).finally(() => { + relaunchOrchestratorPromises.delete(sessionId); + }); + relaunchOrchestratorPromises.set(sessionId, promise); + return promise; + } + async function list(projectId?: string): Promise { const allSessions = Object.entries(config.projects).flatMap(([entryProjectId, project]) => { if (projectId && entryProjectId !== projectId) return []; @@ -3054,6 +3124,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM spawn, spawnOrchestrator, ensureOrchestrator, + relaunchOrchestrator, restore, list, listCached, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d759c7769..1d5af5b3e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1845,6 +1845,13 @@ export interface SessionManager { spawn(config: SessionSpawnConfig): Promise; spawnOrchestrator(config: OrchestratorSpawnConfig): Promise; ensureOrchestrator(config: OrchestratorSpawnConfig): Promise; + /** + * Replace the canonical orchestrator with a fresh one. If an orchestrator + * already exists for the project, it is killed, its metadata deleted, and a + * new orchestrator spawned with no carryover state. Ignores + * `orchestratorSessionStrategy` — replacement is the whole point. + */ + relaunchOrchestrator(config: OrchestratorSpawnConfig): Promise; restore(sessionId: SessionId): Promise; list(projectId?: string): Promise; get(sessionId: SessionId): Promise; diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 84f769e97..0a32bf0dc 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -129,6 +129,7 @@ const mockSessionManager: SessionManager = { cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })), spawnOrchestrator: vi.fn(), ensureOrchestrator: vi.fn(), + relaunchOrchestrator: vi.fn(), remap: vi.fn(async () => "ses_mock"), restore: vi.fn(async (id: string) => { const session = testSessions.find((s) => s.id === id); @@ -232,7 +233,7 @@ import { GET as sessionDetailGET, PATCH as sessionDetailPATCH, } from "@/app/api/sessions/[id]/route"; -import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route"; +import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; @@ -1173,53 +1174,49 @@ describe("API Routes", () => { expect(data.recovery).toBe("reuse-or-recreate-workspace"); expect(data.error).toContain('AO found an older orchestrator workspace for "my-app"'); }); - }); - describe("GET /api/orchestrators", () => { - it("returns orchestrators for a project", async () => { - const orchestrator = makeSession({ - id: "my-app-orchestrator", - projectId: "my-app", - metadata: { role: "orchestrator" }, + it("calls relaunchOrchestrator instead of spawnOrchestrator when clean is true", async () => { + (mockSessionManager.relaunchOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); + + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean: true }), + headers: { "Content-Type": "application/json" }, }); - (mockSessionManager.list as ReturnType).mockResolvedValueOnce([orchestrator]); + const res = await orchestratorsPOST(req); - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), - ); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.orchestrators).toHaveLength(1); - expect(data.orchestrators[0].id).toBe("my-app-orchestrator"); - expect(data.projectName).toBe("My App"); + expect(res.status).toBe(201); + expect(mockSessionManager.relaunchOrchestrator).toHaveBeenCalledWith({ + projectId: "my-app", + systemPrompt: expect.stringContaining("# My App Orchestrator"), + }); + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); }); - it("returns 400 when project parameter is missing", async () => { - const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators")); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toMatch(/Missing project query parameter/); - }); + it("uses spawnOrchestrator when clean is false or omitted", async () => { + (mockSessionManager.spawnOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); - it("returns 404 for unknown project", async () => { - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"), - ); - expect(res.status).toBe(404); - const data = await res.json(); - expect(data.error).toMatch(/Unknown project/); - }); + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean: false }), + headers: { "Content-Type": "application/json" }, + }); + await orchestratorsPOST(req); - it("returns 500 when list fails", async () => { - (mockSessionManager.list as ReturnType).mockRejectedValueOnce( - new Error("boom"), - ); - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), - ); - expect(res.status).toBe(500); - const data = await res.json(); - expect(data.error).toBe("boom"); + expect(mockSessionManager.spawnOrchestrator).toHaveBeenCalled(); + expect(mockSessionManager.relaunchOrchestrator).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/__tests__/orchestrators.test.tsx b/packages/web/src/__tests__/orchestrators.test.tsx deleted file mode 100644 index 8aaba6d00..000000000 --- a/packages/web/src/__tests__/orchestrators.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import OrchestratorsRoute from "@/app/orchestrators/page"; -import { getServices } from "@/lib/services"; -import { getAllProjects } from "@/lib/project-name"; - -// ── Mocks ───────────────────────────────────────────────────────────── - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ - push: vi.fn(), - }), -})); - -vi.mock("next/link", () => ({ - default: ({ children, href }: { children: React.ReactNode; href: string }) => ( - {children} - ), -})); - -vi.mock("@/lib/services", () => ({ - getServices: vi.fn(), -})); - -vi.mock("@/lib/project-name", () => ({ - getAllProjects: vi.fn(), -})); - -global.fetch = vi.fn(); - -// ── Tests ───────────────────────────────────────────────────────────── - -describe("Orchestrators Page (OrchestratorsRoute)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders the page with searchParams and listed orchestrators", async () => { - const mockSessionManager = { - list: vi.fn().mockResolvedValue([ - { - id: "app-orchestrator", - projectId: "my-app", - status: "working", - activity: "active", - metadata: { role: "orchestrator" }, - createdAt: new Date(), - lastActivityAt: new Date(), - }, - ]), - }; - - (getServices as any).mockResolvedValue({ - config: { - projects: { - "my-app": { name: "My App", sessionPrefix: "app" }, - }, - }, - sessionManager: mockSessionManager, - }); - - (getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]); - - const searchParams = Promise.resolve({ project: "my-app" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("My App")).toBeInTheDocument(); - expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); - }); - - it("shows error when project is missing in searchParams", async () => { - const searchParams = Promise.resolve({}); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("Missing Project")).toBeInTheDocument(); - }); - - it("shows error when project is not found in config", async () => { - (getServices as any).mockResolvedValue({ - config: { projects: {} }, - sessionManager: { list: vi.fn() }, - }); - (getAllProjects as any).mockReturnValue([]); - - const searchParams = Promise.resolve({ project: "ghost" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument(); - }); - - it("handles service errors gracefully", async () => { - (getServices as any).mockRejectedValue(new Error("Database down")); - - const searchParams = Promise.resolve({ project: "my-app" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("Database down")).toBeInTheDocument(); - }); -}); diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts index 886761693..54890fa85 100644 --- a/packages/web/src/app/api/orchestrators/route.ts +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -1,8 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; -import { generateOrchestratorPrompt, generateSessionPrefix } from "@aoagents/ao-core"; +import { generateOrchestratorPrompt } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { validateIdentifier, validateConfiguredProject } from "@/lib/validation"; -import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; function classifySpawnError(projectId: string, error: unknown): { status: number; @@ -35,46 +34,6 @@ function classifySpawnError(projectId: string, error: unknown): { }; } -/** - * GET /api/orchestrators?project= - * List existing orchestrator sessions for a project. - */ -export async function GET(request: NextRequest) { - const projectId = request.nextUrl.searchParams.get("project"); - - if (!projectId) { - return NextResponse.json({ error: "Missing project query parameter" }, { status: 400 }); - } - - const projectErr = validateIdentifier(projectId, "projectId"); - if (projectErr) { - return NextResponse.json({ error: projectErr }, { status: 400 }); - } - - try { - const { config, sessionManager } = await getServices(); - const configProjectErr = validateConfiguredProject(config.projects, projectId); - if (configProjectErr) { - return NextResponse.json({ error: configProjectErr }, { status: 404 }); - } - const project = config.projects[projectId]; - const sessionPrefix = project.sessionPrefix ?? projectId; - - const allSessions = await sessionManager.list(projectId); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - const orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes); - - return NextResponse.json({ orchestrators, projectName: project.name }); - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to list orchestrators" }, - { status: 500 }, - ); - } -} - export async function POST(request: NextRequest) { const body = (await request.json().catch(() => null)) as Record | null; if (!body) { @@ -86,6 +45,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: projectErr }, { status: 400 }); } + const clean = body.clean === true; + try { const { config, sessionManager } = await getServices(); const projectId = body.projectId as string; @@ -96,7 +57,9 @@ export async function POST(request: NextRequest) { const project = config.projects[projectId]; const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); - const session = await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); + const session = clean + ? await sessionManager.relaunchOrchestrator({ projectId, systemPrompt }) + : await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); return NextResponse.json( { diff --git a/packages/web/src/app/api/projects/[id]/route.ts b/packages/web/src/app/api/projects/[id]/route.ts index f940aefae..c98964402 100644 --- a/packages/web/src/app/api/projects/[id]/route.ts +++ b/packages/web/src/app/api/projects/[id]/route.ts @@ -31,7 +31,7 @@ function sanitizeString(value: unknown): string | undefined { } function revalidateProjectPaths(projectId: string): void { - for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) { + for (const route of ["/", "/prs", `/projects/${projectId}`]) { try { revalidatePath(route); } catch { diff --git a/packages/web/src/app/api/projects/route.ts b/packages/web/src/app/api/projects/route.ts index 0947558a6..2bafa5212 100644 --- a/packages/web/src/app/api/projects/route.ts +++ b/packages/web/src/app/api/projects/route.ts @@ -33,7 +33,7 @@ function isGitRepository(projectPath: string): boolean { } function revalidatePortfolioPaths(projectId: string): void { - for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) { + for (const route of ["/", "/prs", `/projects/${projectId}`]) { try { revalidatePath(route); } catch { diff --git a/packages/web/src/app/orchestrators/page.tsx b/packages/web/src/app/orchestrators/page.tsx deleted file mode 100644 index 63444c915..000000000 --- a/packages/web/src/app/orchestrators/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { Metadata } from "next"; -import { OrchestratorSelector, type Orchestrator } from "@/components/OrchestratorSelector"; -import { getServices } from "@/lib/services"; -import { getAllProjects } from "@/lib/project-name"; -import { generateSessionPrefix } from "@aoagents/ao-core"; -import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; - -export const dynamic = "force-dynamic"; - -export async function generateMetadata(props: { - searchParams: Promise<{ project?: string }>; -}): Promise { - const searchParams = await props.searchParams; - const projectId = searchParams.project; - let projectName = "Orchestrator"; - if (projectId) { - const projects = getAllProjects(); - const project = projects.find((p) => p.id === projectId); - if (project) { - projectName = project.name; - } - } - return { title: { absolute: `ao | ${projectName} - Orchestrator` } }; -} - -export default async function OrchestratorsRoute(props: { - searchParams: Promise<{ project?: string }>; -}) { - const searchParams = await props.searchParams; - const projectId = searchParams.project; - - if (!projectId) { - return ( -
-
-

- Missing Project -

-

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

-
-
- ); - } - - let orchestrators: Orchestrator[] = []; - let projectName = projectId; - let error: string | null = null; - - try { - const { config, sessionManager } = await getServices(); - const project = config.projects[projectId]; - - if (!project) { - error = `Project "${projectId}" not found`; - } else { - projectName = project.name; - const sessionPrefix = project.sessionPrefix ?? projectId; - const allSessions = await sessionManager.list(projectId); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - orchestrators = mapSessionsToOrchestrators( - allSessions, - sessionPrefix, - project.name, - allSessionPrefixes, - ); - } - } catch (err) { - error = err instanceof Error ? err.message : "Failed to load orchestrators"; - } - - return ( - - ); -} diff --git a/packages/web/src/components/OrchestratorSelector.tsx b/packages/web/src/components/OrchestratorSelector.tsx deleted file mode 100644 index a178a7a6a..000000000 --- a/packages/web/src/components/OrchestratorSelector.tsx +++ /dev/null @@ -1,273 +0,0 @@ -"use client"; - -import { useState, useRef } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { cn } from "@/lib/cn"; -import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; - -export interface Orchestrator { - id: string; - projectId: string; - projectName: string; - status: string; - activity: string | null; - createdAt: string | null; - lastActivityAt: string | null; -} - -interface OrchestratorSelectorProps { - orchestrators: Orchestrator[]; - projectId: string; - projectName: string; - error: string | null; -} - -function formatRelativeTime(isoDate: string | null): string { - if (!isoDate) return "Unknown"; - const date = new Date(isoDate); - const timestamp = date.getTime(); - // Guard against invalid dates (NaN) and future timestamps - if (!Number.isFinite(timestamp)) return "Unknown"; - const now = new Date(); - const diffMs = now.getTime() - timestamp; - // Handle future timestamps - if (diffMs < 0) return "Just now"; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - return `${diffDays}d ago`; -} - -function getStatusColor(status: string): string { - switch (status) { - case "working": - return "var(--color-status-working)"; - case "spawning": - return "var(--color-status-attention)"; - case "pr_open": - case "review_pending": - case "approved": - case "mergeable": - return "var(--color-status-ready)"; - case "ci_failed": - case "changes_requested": - return "var(--color-status-error)"; - case "merged": - case "done": - case "killed": - case "terminated": - return "var(--color-text-tertiary)"; - default: - return "var(--color-text-secondary)"; - } -} - -function getActivityLabel(activity: string | null): string { - if (!activity) return ""; - switch (activity) { - case "active": - return "Active"; - case "ready": - return "Ready"; - case "idle": - return "Idle"; - case "waiting_input": - return "Waiting"; - case "blocked": - return "Blocked"; - case "exited": - return "Exited"; - default: - return activity; - } -} - -export function OrchestratorSelector({ - orchestrators, - projectId, - projectName, - error, -}: OrchestratorSelectorProps) { - const router = useRouter(); - const [isSpawning, setIsSpawning] = useState(false); - const [spawnError, setSpawnError] = useState(null); - const spawnLockRef = useRef(false); - - const handleSpawnNew = async () => { - // Synchronous re-entrancy guard: React state updates are async, - // so two clicks before rerender would fire two POSTs without this. - if (spawnLockRef.current) return; - spawnLockRef.current = true; - setIsSpawning(true); - setSpawnError(null); - - try { - const response = await fetch("/api/orchestrators", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId }), - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to spawn orchestrator"); - } - - const data = await response.json(); - router.push(projectSessionPath(projectId, data.orchestrator.id)); - } catch (err) { - setSpawnError(err instanceof Error ? err.message : "Failed to spawn orchestrator"); - } finally { - setIsSpawning(false); - spawnLockRef.current = false; - } - }; - - if (error) { - return ( -
-
-

Error

-

{error}

- - Go to Dashboard - -
-
- ); - } - - return ( -
- {/* Header */} -
-
-
-

- {projectName} -

-

Project orchestrator

-
- - Dashboard - -
-
- - {/* Content */} -
-
- {/* Info banner */} -
-

- AO keeps one main orchestrator per project. Opening or spawning here reuses that - canonical session instead of creating another duplicate. -

-
- - {/* Existing orchestrators */} -
-

- Main Session -

-
- {orchestrators.map((orch) => ( - -
-
-
-
{orch.id}
-
- {orch.status.replace(/_/g, " ")} - {orch.activity && ( - <> - · - {getActivityLabel(orch.activity)} - - )} -
-
-
-
-
Created {formatRelativeTime(orch.createdAt)}
- {orch.lastActivityAt && ( -
Active {formatRelativeTime(orch.lastActivityAt)}
- )} -
- - ))} -
-
- - {/* Start new section */} -
-

- Open Orchestrator -

- - {spawnError && ( -

{spawnError}

- )} -
-
-
-
- ); -} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index d904d559d..5cb0d0f35 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -66,6 +66,7 @@ export function SessionDetail({ const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [showTerminal, setShowTerminal] = useState(false); + const [relaunchError, setRelaunchError] = useState(null); const pr = session.pr; const terminalEnded = isDashboardSessionTerminal(session); const isRestorable = isDashboardSessionRestorable(session); @@ -119,6 +120,47 @@ export function SessionDetail({ } }, [session.id]); + const handleRelaunchClean = useCallback(async () => { + const confirmed = window.confirm( + "This will discard the current orchestrator's conversation and state. Continue?", + ); + if (!confirmed) return; + setRelaunchError(null); + try { + const res = await fetch("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: session.projectId, clean: true }), + }); + if (!res.ok) { + // Surface server-side errors. Note: a failure here may indicate the + // existing orchestrator was killed but respawn failed — the banner + // tells the user the previous session was terminated so they don't + // assume the page they're looking at is still live. + let message = ""; + try { + const data = (await res.json()) as { error?: string }; + message = data.error ?? ""; + } catch { + message = await res.text().catch(() => ""); + } + throw new Error(message || `HTTP ${res.status}`); + } + // Hard-navigate to the freshly spawned orchestrator's session page. + // Orchestrator session IDs are fixed per project, so this is the same + // path in practice — but reading from the response keeps us correct if + // the contract ever changes (and a hard nav forces the terminal + // WebSocket to reconnect against the new tmux session). + const data = (await res.json()) as { orchestrator?: { id: string } }; + const newId = data.orchestrator?.id ?? session.id; + window.location.href = projectSessionPath(session.projectId, newId); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to relaunch orchestrator"; + console.error("Failed to relaunch orchestrator:", err); + setRelaunchError(message); + } + }, [session.id, session.projectId]); + const orchestratorHref = useMemo(() => { if (isOrchestrator) return projectSessionPath(session.projectId, session.id); if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId); @@ -158,6 +200,7 @@ export function SessionDetail({ onToggleSidebar={handleToggleSidebar} onRestore={handleRestore} onKill={handleKill} + onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined} />
+ {relaunchError ? ( +
+
+
+ Relaunch failed: {relaunchError} +
+ The previous orchestrator may already be terminated. Try again from the + project dashboard. +
+
+ +
+
+ ) : null}
{!showTerminal ? (
diff --git a/packages/web/src/components/SessionDetailHeader.tsx b/packages/web/src/components/SessionDetailHeader.tsx index f6090efcd..25432df01 100644 --- a/packages/web/src/components/SessionDetailHeader.tsx +++ b/packages/web/src/components/SessionDetailHeader.tsx @@ -36,6 +36,7 @@ interface SessionDetailHeaderProps { onToggleSidebar: () => void; onRestore: () => void; onKill: () => void; + onRelaunchClean?: () => void; } function normalizeActivityLabelForClass(activityLabel: string): string { @@ -80,6 +81,7 @@ export function SessionDetailHeader({ onToggleSidebar, onRestore, onKill, + onRelaunchClean, }: SessionDetailHeaderProps) { const pr = session.pr; const allGreen = pr ? isPRMergeReady(pr) : false; @@ -302,6 +304,30 @@ export function SessionDetailHeader({ ) : null} + {isOrchestrator && onRelaunchClean ? ( + + ) : null} + {orchestratorHref ? ( ({ - useRouter: () => ({ push: mockPush }), -})); - -const mockOrchestrators = [ - { - id: "app-orchestrator", - projectId: "my-project", - projectName: "My Project", - status: "working", - activity: "active", - createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago - lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago - }, -]; - -const defaultProps = { - orchestrators: mockOrchestrators, - projectId: "my-project", - projectName: "My Project", - error: null, -}; - -describe("OrchestratorSelector", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPush.mockClear(); - global.fetch = vi.fn(); - }); - - it("renders orchestrator list", () => { - render(); - - expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); - }); - - it("displays project name in header", () => { - render(); - - expect(screen.getByText("My Project")).toBeInTheDocument(); - expect(screen.getByText("Project orchestrator")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute( - "href", - "/projects/my-project", - ); - }); - - it("explains that orchestrator opening reuses the canonical session", () => { - render(); - - expect(screen.getByText(/one main orchestrator per project/i)).toBeInTheDocument(); - }); - - it("shows error state", () => { - render(); - - expect(screen.getByText("Error")).toBeInTheDocument(); - expect(screen.getByText("Project not found")).toBeInTheDocument(); - }); - - it("shows open orchestrator button", () => { - render(); - - expect(screen.getByRole("button", { name: /open orchestrator/i })).toBeInTheDocument(); - }); - - it("spawns new orchestrator on button click and navigates", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - orchestrator: { id: "app-orchestrator" }, - }), - }); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/orchestrators", expect.any(Object)); - }); - - await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator"); - }); - }); - - it("shows loading state while spawning", async () => { - const mockFetch = vi.fn().mockImplementation( - () => new Promise(() => {}), // Never resolves - ); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(screen.getByText(/opening orchestrator/i)).toBeInTheDocument(); - }); - }); - - it("shows error when spawn fails", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ error: "Failed to spawn" }), - }); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(screen.getByText("Failed to spawn")).toBeInTheDocument(); - }); - }); - - it("links to orchestrator session page", () => { - render(); - - const link = screen.getByRole("link", { name: /app-orchestrator/i }); - expect(link).toHaveAttribute("href", "/projects/my-project/sessions/app-orchestrator"); - }); - - it("displays status and activity for each orchestrator", () => { - render(); - - expect(screen.getByText("working")).toBeInTheDocument(); - expect(screen.getByText("Active")).toBeInTheDocument(); - }); - - it("covers relative time for days and status colors/labels", () => { - const wideOrchestrators = [ - { - id: "orch-2", - projectId: "my-project", - projectName: "My Project", - status: "ci_failed", - activity: "waiting_input", - createdAt: new Date(Date.now() - 3600000 * 50).toISOString(), // 2d ago - lastActivityAt: null, - }, - { - id: "orch-3", - projectId: "my-project", - projectName: "My Project", - status: "killed", - activity: "ready", - createdAt: new Date(Date.now() - 1000).toISOString(), // Just now - lastActivityAt: null, - }, - { - id: "orch-4", - projectId: "my-project", - projectName: "My Project", - status: "unknown", - activity: "blocked", - createdAt: new Date().toISOString(), - lastActivityAt: null, - }, - { - id: "orch-5", - projectId: "my-project", - projectName: "My Project", - status: "mergeable", - activity: "exited", - createdAt: new Date().toISOString(), - lastActivityAt: null, - }, - ]; - - render(); - - expect(screen.getByText(/2d ago/)).toBeInTheDocument(); - expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0); - expect(screen.getByText(/Waiting/)).toBeInTheDocument(); - expect(screen.getByText(/Ready/)).toBeInTheDocument(); - expect(screen.getByText(/Blocked/)).toBeInTheDocument(); - expect(screen.getByText(/Exited/)).toBeInTheDocument(); - expect(screen.getByText(/ci failed/i)).toBeInTheDocument(); - }); - - describe("formatRelativeTime edge cases", () => { - it("shows Unknown for invalid date strings", () => { - const orchestratorsWithInvalidDate = [ - { - ...mockOrchestrators[0], - createdAt: "not-a-valid-date", - lastActivityAt: null, - }, - ]; - render( - , - ); - - // The "Created Unknown" text should appear for invalid dates - expect(screen.getByText(/Created Unknown/)).toBeInTheDocument(); - }); - - it("shows Just now for future timestamps", () => { - const futureDate = new Date(Date.now() + 60000).toISOString(); // 1 minute in future - const orchestratorsWithFutureDate = [ - { - ...mockOrchestrators[0], - createdAt: futureDate, - lastActivityAt: null, - }, - ]; - render( - , - ); - - // Future timestamps should show "Just now" instead of negative values - expect(screen.getByText(/Created Just now/)).toBeInTheDocument(); - }); - - it("shows Unknown for null dates", () => { - const orchestratorsWithNullDate = [ - { - ...mockOrchestrators[0], - createdAt: null, - lastActivityAt: null, - }, - ]; - render(); - - expect(screen.getByText(/Created Unknown/)).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index d63ad18f8..e6df9645b 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -304,6 +304,161 @@ describe("SessionDetail desktop layout", () => { ).not.toBeInTheDocument(); }); + it("renders Relaunch (clean) on live orchestrator sessions and navigates to the new session", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { + ...window.location, + set href(value: string) { + hrefSetter(value); + }, + }, + writable: true, + }); + vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/orchestrators") { + return { + ok: true, + json: async () => ({ + orchestrator: { id: "my-app-orchestrator", projectId: "my-app" }, + }), + } as Response; + } + return { ok: true, json: async () => ({}), text: async () => "" } as Response; + }); + + render( + , + ); + + const relaunchBtn = within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }); + fireEvent.click(relaunchBtn); + + expect(confirmSpy).toHaveBeenCalled(); + await act(async () => {}); + + expect(global.fetch).toHaveBeenCalledWith("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: "my-app", clean: true }), + }); + expect(hrefSetter).toHaveBeenCalledWith("/projects/my-app/sessions/my-app-orchestrator"); + + confirmSpy.mockRestore(); + }); + + it("keeps Relaunch (clean) visible on terminated orchestrator sessions", () => { + render( + , + ); + + expect( + within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }), + ).toBeInTheDocument(); + }); + + it("surfaces a relaunch error banner when POST fails after confirm", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/orchestrators") { + return { + ok: false, + status: 500, + json: async () => ({ error: "kill+respawn failed" }), + text: async () => "kill+respawn failed", + } as Response; + } + return { ok: true, json: async () => ({}), text: async () => "" } as Response; + }); + + render( + , + ); + + fireEvent.click( + within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }), + ); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent(/kill\+respawn failed/i); + expect(alert).toHaveTextContent(/previous orchestrator may already be terminated/i); + + fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + confirmSpy.mockRestore(); + }); + + it("does not render Relaunch (clean) on worker sessions", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: /launch orchestrator \(clean context\)/i }), + ).not.toBeInTheDocument(); + }); + it("restores without using router refresh on the client-only session page", async () => { render( isOrchestratorSession(s, sessionPrefix, allSessionPrefixes) && !isTerminalSession(s)) - .sort((a, b) => { - if (a.id === canonicalId) return -1; - if (b.id === canonicalId) return 1; - return (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0); - }) - .map((s) => ({ - id: s.id, - projectId: s.projectId, - projectName, - status: s.status, - activity: s.activity, - createdAt: s.createdAt?.toISOString() ?? null, - lastActivityAt: s.lastActivityAt?.toISOString() ?? null, - })); -} From 406b26e8373e3c07db5ad8a48ee8002f4ebf19a7 Mon Sep 17 00:00:00 2001 From: Pritom Mazumdar Date: Sun, 17 May 2026 23:45:35 +0530 Subject: [PATCH 06/26] feat(web): sidebar and dashboard header UI/UX polish (#1846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(web): eliminate sidebar re-renders on every SSE tick Resolves #1844 The SSE hook delivers a new sessions array reference every 5 seconds even when content is identical, causing sessionsByProject to recompute and all session rows to re-render on every tick. Three fixes in ProjectSidebar.tsx: * sessionsKey + sessionsRef: replace unstable array reference in memo deps with a content-derived string; memo only fires on real changes * SessionRow memoized component: rows skip re-renders when props are unchanged; navigate and startRename stabilised with useCallback * SessionDot memoized: status indicator skips re-renders when level prop is unchanged A quiet SSE tick now touches zero React components in the sidebar. * fix(web): add displayName, displayNameUserSet, branch to sessionsKey hash Without these fields, a session rename delivered via SSE did not trigger sessionsByProject to recompute. The stale session object held the old displayName, and once the optimistic pendingRename was cleared the sidebar silently reverted to the pre-rename title. Addresses review feedback on PR #1846. * feat(web): sidebar and dashboard header UI/UX polish Removes state text labels from sidebar session rows so the colored dot is the sole status indicator, matching the intended design. Fixes the sidebar compact header height to align with the 48px main header. Adds session count summary pills to the dashboard project header. Converts CopyDebugBundleButton to an icon-only compact form so the actions row stays vertically centered. Fixes the project page wrapper missing flex-1 which caused a right-side viewport gap in the horizontal shell layout. * fix(web): resolve lint errors from UI/UX polish Remove LEVEL_LABELS, _isLoading prefix for unused loading var, and dead title variable from ProjectSidebar. Remove unused isDashboardSessionStatus and isActivityStateValue from the project session page. Remove react-hooks/exhaustive-deps eslint-disable comments for a rule not in the ESLint config. Stabilize startRename via pendingRenamesRef so the callback does not recreate on every rename state change, preventing unnecessary SessionRow re-renders. Remove non-null assertion in Dashboard.tsx handleToggleSidebar with a null guard. * fix(web): replace native Node 25 localStorage stub with full in-memory mock Node.js 25 exposes a native localStorage via --localstorage-file that lacks .clear() and .key(), causing all UpdateBanner tests to throw TypeError. Replace the global with a complete in-memory implementation so test suites work across all Node versions. * fix(web): seed sidebar with all sessions on hard refresh and eliminate per-project layout re-render - Hoist sidebar layout from projects/[projectId]/layout.tsx to projects/layout.tsx so it renders once for the entire /projects/* subtree and never re-mounts when switching between projects - Pass getDashboardPageData("all") so initial sessions cover every project, not just the primary - Extract ProjectLayoutClient from the old client layout for clean server/client split - Simplify per-project empty state to "No active sessions" only, removing the second hint line - Restore accidentally deleted Dashboard empty-state test for zero-projects install - Fix Reflect.deleteProperty lint error in localStorage mock; drop unused AttentionLevel import and dead effectiveDisplayName/pending variables from ProjectSidebar editing block Co-Authored-By: Claude Sonnet 4.6 * fix(web): address PR review — orchestrators prop, sessionsKey, badge count, mobile overlay, tests * fix(web): remove duplicate skeleton sidebar from project loading state * fix(web): restore orchestrator button and eliminate Session unavailable flash Re-add the orchestrator icon + menu item that were removed during the merge conflict resolution. Convert the icon from a to an that calls navigate() with the full session object so ProjectSessionPage gets an instant sessionStorage cache hit instead of starting with session=null and briefly showing the "Session unavailable" error card. * fix(web): eliminate Session unavailable flash on orchestrator navigation React Strict Mode aborts the first fetchSession() during its unmount/remount cycle. The aborted finally reset fetchingSessionRef but set loading=false, briefly showing the error card before the retry completed. Fix: keep loading=true on abort (no session yet), and immediately retry via fetchSession() once the ref is clear. mountedRef guards the retry so it only fires on Strict Mode remounts — not on genuine navigation-away unmounts, which would leak requests. * fix(web): fix working pill count and layout of error states in project session page - Dashboard topbar "working" pill now counts only actively working sessions, not working + pending (which inflated the number incorrectly) - Error/loading/missing states in ProjectSessionPage wrapped in dashboard-main--desktop so they fill the flex shell beside the sidebar instead of shrinking to content width Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- packages/web/src/__tests__/setup.ts | 16 + packages/web/src/app/globals.css | 23 +- .../__tests__/project-layout-client.test.tsx | 122 ++++ .../src/app/projects/[projectId]/layout.tsx | 5 + .../app/projects/[projectId]/loading.test.tsx | 5 +- .../src/app/projects/[projectId]/loading.tsx | 123 +--- .../web/src/app/projects/[projectId]/page.tsx | 2 +- .../[projectId]/project-layout-client.tsx | 91 +++ .../[projectId]/sessions/[id]/page.tsx | 460 +++++++++++++- packages/web/src/app/projects/layout.tsx | 19 + .../web/src/app/sessions/[id]/page.test.tsx | 209 ------ packages/web/src/app/sessions/[id]/page.tsx | 10 +- .../src/components/CopyDebugBundleButton.tsx | 4 +- packages/web/src/components/Dashboard.tsx | 601 ++++++++++-------- .../web/src/components/ProjectSidebar.tsx | 402 ++++++++---- packages/web/src/components/SessionDetail.tsx | 216 +++---- .../__tests__/Dashboard.emptyState.test.tsx | 1 + .../__tests__/ProjectSidebar.test.tsx | 2 + .../__tests__/SessionDetail.desktop.test.tsx | 10 +- 19 files changed, 1457 insertions(+), 864 deletions(-) create mode 100644 packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx create mode 100644 packages/web/src/app/projects/[projectId]/layout.tsx create mode 100644 packages/web/src/app/projects/[projectId]/project-layout-client.tsx create mode 100644 packages/web/src/app/projects/layout.tsx diff --git a/packages/web/src/__tests__/setup.ts b/packages/web/src/__tests__/setup.ts index b3258b10f..1ccc65a90 100644 --- a/packages/web/src/__tests__/setup.ts +++ b/packages/web/src/__tests__/setup.ts @@ -2,6 +2,22 @@ import { cleanup } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, expect } from "vitest"; +// Node.js 25 exposes a native localStorage stub via --localstorage-file that +// lacks .clear()/.key()/.length. Replace it with a complete in-memory mock so +// tests that call window.localStorage.clear() work on any Node version. +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = String(v); }, + removeItem: (k: string) => { Reflect.deleteProperty(store, k); }, + clear: () => { store = {}; }, + get length() { return Object.keys(store).length; }, + key: (i: number) => Object.keys(store)[i] ?? null, + }; +})(); +Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true, configurable: true }); + expect.extend(matchers); afterEach(() => { cleanup(); diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 8bb4b4016..14f6fcf83 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1230,6 +1230,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { color 100ms ease, transform 120ms cubic-bezier(0.23, 1, 0.32, 1); } +.dashboard-app-btn--icon { + width: 27px; + padding: 0; + justify-content: center; +} .dashboard-app-btn:hover { background: var(--color-bg-subtle); @@ -1328,6 +1333,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .topbar-status-pill__label { color: inherit; } +.topbar-status-pill__dot--working { + background: var(--color-status-working); +} +.topbar-status-pill__dot--attention { + background: var(--color-status-attention); +} /* Branch pill — compact topbar variant */ .topbar-branch-pill { @@ -3846,7 +3857,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { display: flex; align-items: center; justify-content: space-between; - padding: 10px 12px 8px; + height: 48px; + padding: 0 12px; border-bottom: 1px solid var(--sidebar-border); flex-shrink: 0; } @@ -4483,6 +4495,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .project-sidebar__sess-row--active { background: transparent; + border-left: 2px solid var(--color-accent-amber); + padding-left: 24px; } /* Session label */ @@ -4627,6 +4641,13 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { font-size: 11px; } +.project-sidebar__empty-hint { + display: block; + font-size: 10px; + margin-top: 3px; + color: var(--color-text-muted); +} + .project-sidebar__footer { margin-top: auto; } diff --git a/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx b/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx new file mode 100644 index 000000000..e94256838 --- /dev/null +++ b/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, act } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let mockPathname = "/projects/proj-1"; +let mockParams: Record = { projectId: "proj-1" }; + +vi.mock("next/navigation", () => ({ + useParams: () => mockParams, + usePathname: () => mockPathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }), +})); + +vi.mock("@/providers/MuxProvider", () => ({ + useMuxOptional: () => ({ status: "connecting", sessions: [], lastError: null }), +})); + +vi.mock("@/hooks/useSessionEvents", () => ({ + useSessionEvents: ({ initialSessions }: { initialSessions: unknown[] }) => ({ + sessions: initialSessions, + liveSessionsResolved: true, + attentionLevels: {}, + loadError: null, + }), +})); + +vi.mock("@/components/ProjectSidebar", () => ({ + ProjectSidebar: (props: { activeProjectId?: string; orchestrators?: unknown[] }) => ( +
+ ), +})); + +import { ProjectLayoutClient } from "../project-layout-client"; + +const projects = [{ id: "proj-1", name: "Project One", sessionPrefix: "proj-1" }]; +const orchestrators = [{ id: "proj-1-orchestrator", projectId: "proj-1" }]; + +beforeEach(() => { + mockPathname = "/projects/proj-1"; + mockParams = { projectId: "proj-1" }; +}); + +describe("ProjectLayoutClient", () => { + it("renders children and sidebar", () => { + render( + +
Page
+
, + ); + + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("page-content")).toBeInTheDocument(); + }); + + it("passes activeProjectId from route params to sidebar", () => { + mockParams = { projectId: "proj-1" }; + + render( + +
+ , + ); + + expect(screen.getByTestId("sidebar").dataset.project).toBe("proj-1"); + }); + + it("passes initialOrchestrators directly to sidebar", () => { + render( + +
+ , + ); + + const sidebar = screen.getByTestId("sidebar"); + expect(JSON.parse(sidebar.dataset.orchestrators ?? "[]")).toEqual(orchestrators); + }); + + it("resets mobile sidebar when pathname changes", async () => { + const { rerender } = render( + +
+ , + ); + + // Simulate pathname change + mockPathname = "/projects/proj-1/sessions/sess-1"; + + await act(async () => { + rerender( + +
+ , + ); + }); + + // Sidebar wrapper should not have the mobile-open class + const wrapper = document.querySelector(".sidebar-wrapper"); + expect(wrapper?.classList.contains("sidebar-wrapper--mobile-open")).toBe(false); + }); +}); diff --git a/packages/web/src/app/projects/[projectId]/layout.tsx b/packages/web/src/app/projects/[projectId]/layout.tsx new file mode 100644 index 000000000..330afda9c --- /dev/null +++ b/packages/web/src/app/projects/[projectId]/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export default function ProjectIdLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/packages/web/src/app/projects/[projectId]/loading.test.tsx b/packages/web/src/app/projects/[projectId]/loading.test.tsx index 54d978a5b..61726031e 100644 --- a/packages/web/src/app/projects/[projectId]/loading.test.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.test.tsx @@ -8,7 +8,8 @@ describe("ProjectRouteLoading", () => { expect(screen.getByText("Agent Orchestrator")).toBeInTheDocument(); expect(screen.getByText("Loading project…")).toBeInTheDocument(); - expect(screen.getByText("Projects")).toBeInTheDocument(); - expect(screen.getByLabelText("Loading project dashboard")).toBeInTheDocument(); + // Sidebar is owned by ProjectLayoutClient — no duplicate skeleton sidebar here + expect(screen.queryByText("Projects")).not.toBeInTheDocument(); + expect(screen.getByText("Working")).toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/projects/[projectId]/loading.tsx b/packages/web/src/app/projects/[projectId]/loading.tsx index cc385e2fd..ffeee105c 100644 --- a/packages/web/src/app/projects/[projectId]/loading.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.tsx @@ -1,101 +1,40 @@ -function ProjectLoadingSidebar() { - return ( - - ); -} - export default function ProjectRouteLoading() { return ( -
-
- - -
- +
+ -
-
-
-
-
- -
); } diff --git a/packages/web/src/app/projects/[projectId]/page.tsx b/packages/web/src/app/projects/[projectId]/page.tsx index 2c677fb1f..39d317391 100644 --- a/packages/web/src/app/projects/[projectId]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/page.tsx @@ -29,7 +29,7 @@ export default async function ProjectPage(props: { const pageData = await getDashboardPageData(projectId); return ( -
+
{ + setMobileSidebarOpen(false); + }, [pathname]); + + const mux = useMuxOptional(); + const { sessions, liveSessionsResolved } = useSessionEvents({ + initialSessions, + muxSessions: mux?.status === "connected" ? mux.sessions : undefined, + muxLastError: mux?.lastError, + attentionZones: "simple", + }); + + const handleToggleSidebar = useCallback(() => { + if (isMobile) { + setMobileSidebarOpen((v) => !v); + } else { + setSidebarCollapsed((v) => !v); + } + }, [isMobile]); + + return ( + +
+
+
+ setSidebarCollapsed((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} + /> +
+ {mobileSidebarOpen && ( +
setMobileSidebarOpen(false)} /> + )} + {children} +
+
+ + ); +} diff --git a/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx b/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx index 3e7be8541..39c70fcab 100644 --- a/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx @@ -1 +1,459 @@ -export { default } from "../../../../sessions/[id]/page"; +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { isOrchestratorSession } from "@aoagents/ao-core/types"; +import { SessionDetail } from "@/components/SessionDetail"; +import { ErrorDisplay } from "@/components/ErrorDisplay"; +import { + type DashboardSession, + type ActivityState, + getAttentionLevel, +} from "@/lib/types"; +import { activityIcon } from "@/lib/activity-icons"; +import type { ProjectInfo } from "@/lib/project-name"; +import { getSessionTitle } from "@/lib/format"; +import { useMuxSessionActivity } from "@/hooks/useMuxSessionActivity"; +import { projectSessionPath } from "@/lib/routes"; +import { fetchJsonWithTimeout } from "@/lib/client-fetch"; + +function truncate(s: string, max: number): string { + const codePoints = Array.from(s); + return codePoints.length > max ? codePoints.slice(0, max).join("") + "..." : s; +} + +function buildSessionTitle( + session: DashboardSession, + prefixByProject: Map, + activityOverride?: ActivityState | null, +): string { + const id = session.id; + const activity = activityOverride !== undefined ? activityOverride : session.activity; + const emoji = activity ? (activityIcon[activity] ?? "") : ""; + const allPrefixes = [...prefixByProject.values()]; + const isOrchestrator = isOrchestratorSession( + session, + prefixByProject.get(session.projectId), + allPrefixes, + ); + const detail = isOrchestrator ? "Orchestrator Terminal" : truncate(getSessionTitle(session), 40); + return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`; +} + +interface ZoneCounts { + merge: number; + respond: number; + review: number; + pending: number; + working: number; + done: number; +} + +interface ProjectSessionsBody { + sessions?: DashboardSession[]; + orchestratorId?: string | null; + orchestrators?: Array<{ id: string; projectId: string; projectName: string }>; +} + +let cachedProjects: ProjectInfo[] | null = null; + +const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000; +const SESSION_FETCH_TIMEOUT_MS = 8000; +const PROJECT_SESSIONS_FETCH_TIMEOUT_MS = 5000; +const PROJECTS_FETCH_TIMEOUT_MS = 5000; +function areProjectsEqual(previous: ProjectInfo[] | null, next: ProjectInfo[]): boolean { + if (!previous || previous.length !== next.length) return false; + return previous.every((p, i) => JSON.stringify(p) === JSON.stringify(next[i])); +} + +function isAbortLikeError(error: unknown): boolean { + if (error instanceof DOMException) return error.name === "AbortError"; + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return msg.includes("aborted") || msg.includes("aborterror"); + } + return false; +} + +function getSessionLoadErrorMessage(error: Error): string { + const normalized = error.message.toLowerCase(); + if (normalized.includes("timed out")) + return "The session request is taking too long. You can retry, or return to the project and reopen a different session."; + if (normalized.includes("network")) + return "The session request failed before the dashboard got a response. Check the local server connection and try again."; + if (normalized.includes("404")) + return "This session is no longer available. It may have been removed while the page was open."; + if (normalized.includes("500")) + return "The server returned an internal error while loading this session. Try again to re-fetch the latest state."; + return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state."; +} + +function LoadingContent() { + return ( +
+
+ + + +
Loading session…
+
+
+ ); +} + +export default function ProjectSessionPage() { + const params = useParams(); + const pathname = usePathname(); + const router = useRouter(); + const id = params.id as string; + const expectedProjectId = typeof params.projectId === "string" ? params.projectId : undefined; + + // Read optimistic session data written by sidebar navigation + const cachedSession = (() => { + if (typeof sessionStorage === "undefined") return null; + try { + const raw = sessionStorage.getItem(`ao-session-nav:${id}`); + if (raw) { + sessionStorage.removeItem(`ao-session-nav:${id}`); + return JSON.parse(raw) as DashboardSession; + } + } catch { + /* ignore */ + } + return null; + })(); + + const [session, setSession] = useState(cachedSession); + const [zoneCounts, setZoneCounts] = useState(null); + const [projectOrchestratorId, setProjectOrchestratorId] = useState( + undefined, + ); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(cachedSession === null); + const [routeError, setRouteError] = useState(null); + const [sessionMissing, setSessionMissing] = useState(false); + const [prefixByProject, setPrefixByProject] = useState>(new Map()); + + const sessionProjectId = session?.projectId ?? null; + const allPrefixes = [...prefixByProject.values()]; + const sessionIsOrchestrator = session + ? isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes) + : false; + + const sessionProjectIdRef = useRef(null); + const sessionIsOrchestratorRef = useRef(false); + const resolvedProjectSessionsKeyRef = useRef(null); + const prefixByProjectRef = useRef>(new Map()); + const hasLoadedSessionRef = useRef(cachedSession !== null); + const fetchingSessionRef = useRef(false); + const fetchingProjectSessionsRef = useRef(false); + const sessionFetchControllerRef = useRef(null); + const projectSessionsFetchControllerRef = useRef(null); + const pageUnloadingRef = useRef(false); + const mountedRef = useRef(true); + + const sseActivity = useMuxSessionActivity(id); + + useEffect(() => { + prefixByProjectRef.current = prefixByProject; + }, [prefixByProject]); + + useEffect(() => { + if (session) { + document.title = buildSessionTitle(session, prefixByProject, sseActivity?.activity); + } else { + document.title = `${id} | Session Detail`; + } + }, [session, id, prefixByProject, sseActivity]); + + useEffect(() => { + sessionProjectIdRef.current = sessionProjectId; + }, [sessionProjectId]); + + useEffect(() => { + sessionIsOrchestratorRef.current = sessionIsOrchestrator; + }, [sessionIsOrchestrator]); + + useEffect(() => { + if (!session || !projects.some((p) => p.id === session.projectId)) return; + if ( + pathname?.startsWith("/projects/") && + expectedProjectId && + session.projectId !== expectedProjectId + ) { + router.replace(projectSessionPath(session.projectId, session.id)); + } + }, [expectedProjectId, pathname, projects, router, session]); + + useEffect(() => { + if (!sessionIsOrchestrator) setZoneCounts(null); + }, [sessionIsOrchestrator]); + + const fetchProjects = useCallback(async () => { + if (cachedProjects) { + setProjects(cachedProjects); + setPrefixByProject(new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id]))); + } + try { + const data = await fetchJsonWithTimeout<{ projects?: ProjectInfo[] } | null>( + "/api/projects", + { + timeoutMs: PROJECTS_FETCH_TIMEOUT_MS, + timeoutMessage: `Projects request timed out after ${PROJECTS_FETCH_TIMEOUT_MS}ms`, + }, + ); + if (!data?.projects) return; + if (!areProjectsEqual(cachedProjects, data.projects)) { + cachedProjects = data.projects; + setProjects(data.projects); + setPrefixByProject(new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id]))); + } + } catch (err) { + console.error("Failed to fetch projects:", err); + } + }, []); + + const fetchSession = useCallback(async () => { + if (fetchingSessionRef.current) return; + fetchingSessionRef.current = true; + const controller = new AbortController(); + sessionFetchControllerRef.current = controller; + try { + const data = await fetchJsonWithTimeout( + `/api/sessions/${encodeURIComponent(id)}`, + { + signal: controller.signal, + timeoutMs: SESSION_FETCH_TIMEOUT_MS, + timeoutMessage: `Session request timed out after ${SESSION_FETCH_TIMEOUT_MS}ms`, + }, + ); + setSession(data as DashboardSession); + setRouteError(null); + setSessionMissing(false); + hasLoadedSessionRef.current = true; + } catch (err) { + if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return; + const message = err instanceof Error ? err.message : "Failed to load session"; + const normalized = message.toLowerCase(); + if (normalized.includes("session not found") || normalized.includes("http 404")) { + if (!hasLoadedSessionRef.current) setSessionMissing(true); + setLoading(false); + return; + } + console.error("Failed to fetch session:", err); + if (!hasLoadedSessionRef.current) { + setRouteError(err instanceof Error ? err : new Error("Failed to load session")); + } + } finally { + fetchingSessionRef.current = false; + if (sessionFetchControllerRef.current === controller) + sessionFetchControllerRef.current = null; + if (!controller.signal.aborted || hasLoadedSessionRef.current) { + setLoading(false); + } else if (mountedRef.current) { + // Aborted before any session was loaded and the component is still + // mounted — React Strict Mode fired the cleanup between mount 1 and + // mount 2, aborting the first fetch. Mount 2's fetchSession() was + // blocked by fetchingSessionRef (not yet reset). Retry immediately + // now that the ref is clear. mountedRef guards against the navigation- + // away case where the component is genuinely unmounted and we should + // not start a new request. + void fetchSession(); + } + } + }, [id]); + + const fetchProjectSessions = useCallback(async () => { + if (fetchingProjectSessionsRef.current) return; + const projectId = sessionProjectIdRef.current; + if (!projectId) return; + const isOrchestrator = sessionIsOrchestratorRef.current; + const projectSessionsKey = `${projectId}:${isOrchestrator ? "orchestrator" : "worker"}`; + if (!isOrchestrator && resolvedProjectSessionsKeyRef.current === projectSessionsKey) return; + fetchingProjectSessionsRef.current = true; + const controller = new AbortController(); + projectSessionsFetchControllerRef.current = controller; + try { + const query = isOrchestrator + ? `/api/sessions?project=${encodeURIComponent(projectId)}&fresh=true` + : `/api/sessions?project=${encodeURIComponent(projectId)}&orchestratorOnly=true&fresh=true`; + const body = await fetchJsonWithTimeout(query, { + signal: controller.signal, + timeoutMs: PROJECT_SESSIONS_FETCH_TIMEOUT_MS, + timeoutMessage: `Project sessions request timed out after ${PROJECT_SESSIONS_FETCH_TIMEOUT_MS}ms`, + }); + const sessions = body.sessions ?? []; + const orchestratorId = + body.orchestratorId ?? + body.orchestrators?.find((o) => o.projectId === projectId)?.id ?? + null; + setProjectOrchestratorId((current) => + current === orchestratorId ? current : orchestratorId, + ); + + if (!isOrchestrator) { + resolvedProjectSessionsKeyRef.current = projectSessionsKey; + return; + } + + const counts: ZoneCounts = { + merge: 0, + respond: 0, + review: 0, + pending: 0, + working: 0, + done: 0, + }; + const allPfxs = [...prefixByProjectRef.current.values()]; + for (const s of sessions) { + if (!isOrchestratorSession(s, prefixByProjectRef.current.get(s.projectId), allPfxs)) { + const level = getAttentionLevel(s); + if (level === "action") continue; + counts[level]++; + } + } + setZoneCounts(counts); + } catch (err) { + if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return; + console.error("Failed to fetch project sessions:", err); + } finally { + fetchingProjectSessionsRef.current = false; + if (projectSessionsFetchControllerRef.current === controller) + projectSessionsFetchControllerRef.current = null; + } + }, []); + + useEffect(() => { + void Promise.all([fetchProjects(), fetchSession()]); + }, [fetchProjects, fetchSession]); + + useEffect(() => { + if (!sessionProjectId) return; + void fetchProjectSessions(); + }, [fetchProjectSessions, sessionIsOrchestrator, sessionProjectId]); + + useEffect(() => { + const interval = setInterval(() => { + void fetchSession(); + void fetchProjectSessions(); + }, SESSION_PAGE_REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchSession, fetchProjectSessions]); + + useEffect(() => { + pageUnloadingRef.current = false; + const mark = () => { + pageUnloadingRef.current = true; + }; + window.addEventListener("pagehide", mark); + window.addEventListener("beforeunload", mark); + return () => { + window.removeEventListener("pagehide", mark); + window.removeEventListener("beforeunload", mark); + }; + }, []); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + return () => { + sessionFetchControllerRef.current?.abort(); + projectSessionsFetchControllerRef.current?.abort(); + }; + }, []); + + if (loading) return
; + + if (sessionMissing) { + return ( +
+
+ void fetchSession() }} + compact + chrome="card" + /> +
+
+ ); + } + + if (routeError) { + return ( +
+
+ { + setRouteError(null); + setSessionMissing(false); + setLoading(true); + void Promise.all([fetchProjects(), fetchSession()]); + }, + }} + secondaryAction={{ + label: "Back to dashboard", + href: session?.projectId ? `/projects/${session.projectId}` : "/", + }} + error={routeError} + compact + chrome="card" + /> +
+
+ ); + } + + if (!session) { + return ( +
+
+ void fetchSession() }} + secondaryAction={{ + label: "Back to dashboard", + href: expectedProjectId ? `/projects/${expectedProjectId}` : "/", + }} + compact + chrome="card" + /> +
+
+ ); + } + + return ( + + ); +} diff --git a/packages/web/src/app/projects/layout.tsx b/packages/web/src/app/projects/layout.tsx new file mode 100644 index 000000000..91140bcd1 --- /dev/null +++ b/packages/web/src/app/projects/layout.tsx @@ -0,0 +1,19 @@ +import { getDashboardPageData } from "@/lib/dashboard-page-data"; +import { ProjectLayoutClient } from "./[projectId]/project-layout-client"; +import type { ReactNode } from "react"; + +export const dynamic = "force-dynamic"; + +export default async function ProjectLayout({ children }: { children: ReactNode }) { + const pageData = await getDashboardPageData("all"); + + return ( + + {children} + + ); +} diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index 1f3158e8d..6fa1990e9 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -454,77 +454,6 @@ describe("SessionPage project polling", () => { expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0); }); - it("marks sidebar data as loading until the sessions list resolves", async () => { - const workerSession = makeWorkerSession(); - let resolveSidebarSessions: ((value: Response) => void) | null = null; - - global.fetch = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response); - } - - if (url === "/api/sessions/worker-1") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => workerSession, - } as Response); - } - - if (url === "/api/sessions?fresh=true") { - return new Promise((resolve) => { - resolveSidebarSessions = resolve; - }); - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response); - } - - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - const latestBeforeSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestBeforeSidebarResolve.sidebarLoading).toBe(true); - expect(latestBeforeSidebarResolve.sidebarSessions).toBeNull(); - - await act(async () => { - resolveSidebarSessions?.({ - ok: true, - status: 200, - json: async () => ({ sessions: [workerSession] }), - } as Response); - await Promise.resolve(); - }); - - const latestAfterSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestAfterSidebarResolve.sidebarLoading).toBe(false); - expect(latestAfterSidebarResolve.sidebarSessions).toEqual([workerSession]); - }); it("revalidates projects and sidebar sessions on remount even when cache exists", async () => { const workerSession = makeWorkerSession(); @@ -642,145 +571,7 @@ describe("SessionPage project polling", () => { ); }); - it("surfaces sidebar fetch failures instead of leaving the loading skeleton active", async () => { - const workerSession = makeWorkerSession(); - global.fetch = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return { - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response; - } - - if (url === "/api/sessions/worker-1") { - return { - ok: true, - status: 200, - json: async () => workerSession, - } as Response; - } - - if (url === "/api/sessions?fresh=true") { - return { - ok: false, - status: 500, - json: async () => ({}), - } as Response; - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return { - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response; - } - - throw new Error(`Unexpected fetch: ${url}`); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - const latestProps = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarError?: boolean; - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestProps.sidebarLoading).toBe(false); - expect(latestProps.sidebarError).toBe(true); - expect(latestProps.sidebarSessions).toEqual([]); - }); - - it("applies mux snapshots that arrive before the initial sidebar fetch resolves", async () => { - const workerSession = makeWorkerSession(); - const muxPatchedLastActivityAt = "2026-04-14T12:00:00.000Z"; - let resolveSidebarSessions: ((value: Response) => void) | null = null; - - mockMuxState.current = { - status: "connected", - sessions: [ - { - id: "worker-1", - status: "working", - activity: "ready", - attentionLevel: "pending", - lastActivityAt: muxPatchedLastActivityAt, - }, - ], - }; - - global.fetch = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response); - } - - if (url === "/api/sessions/worker-1") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => workerSession, - } as Response); - } - - if (url === "/api/sessions?fresh=true") { - return new Promise((resolve) => { - resolveSidebarSessions = resolve; - }); - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response); - } - - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - await act(async () => { - resolveSidebarSessions?.({ - ok: true, - status: 200, - json: async () => ({ sessions: [workerSession] }), - } as Response); - await Promise.resolve(); - }); - - const latestProps = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestProps.sidebarSessions).toEqual([ - { - ...workerSession, - activity: "ready", - lastActivityAt: muxPatchedLastActivityAt, - }, - ]); - }); it("redirects the legacy session URL to the project-scoped route for clean projects", async () => { mockPathname = "/sessions/worker-1"; diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index f16c5c365..a69d80e9c 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -5,10 +5,7 @@ import { useParams, usePathname, useRouter } from "next/navigation"; import { ACTIVITY_STATE, SESSION_STATUS, isOrchestratorSession } from "@aoagents/ao-core/types"; import { SessionDetail } from "@/components/SessionDetail"; import { ErrorDisplay } from "@/components/ErrorDisplay"; -import { - ProjectSidebar, - type ProjectSidebarOrchestrator, -} from "@/components/ProjectSidebar"; +import { ProjectSidebar, type ProjectSidebarOrchestrator } from "@/components/ProjectSidebar"; import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery"; import { type DashboardSession, @@ -938,11 +935,6 @@ export default function SessionPage() { orchestratorZones={zoneCounts ?? undefined} projectOrchestratorId={projectOrchestratorId} projects={projects} - sidebarSessions={sidebarSessions} - sidebarOrchestrators={sidebarOrchestrators} - sidebarLoading={sidebarSessions === null} - sidebarError={sidebarError} - onRetrySidebar={fetchSidebarSessions} /> ); } diff --git a/packages/web/src/components/CopyDebugBundleButton.tsx b/packages/web/src/components/CopyDebugBundleButton.tsx index a58cc9607..847494c67 100644 --- a/packages/web/src/components/CopyDebugBundleButton.tsx +++ b/packages/web/src/components/CopyDebugBundleButton.tsx @@ -108,9 +108,10 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps) return ( ); } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 3b790b182..c270831f1 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -18,14 +18,15 @@ import { AttentionZone } from "./AttentionZone"; import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon"; import { useSessionEvents } from "@/hooks/useSessionEvents"; import { useMuxOptional } from "@/providers/MuxProvider"; -import { ProjectSidebar } from "./ProjectSidebar"; import type { ProjectInfo } from "@/lib/project-name"; import { EmptyState } from "./Skeleton"; import { ToastProvider, useToast } from "./Toast"; import { ConnectionBar } from "./ConnectionBar"; import { UpdateBanner } from "./UpdateBanner"; import { CopyDebugBundleButton } from "./CopyDebugBundleButton"; -import { SidebarContext } from "./workspace/SidebarContext"; +import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext"; +import { ProjectSidebar } from "./ProjectSidebar"; +import { isOrchestratorSession } from "@aoagents/ao-core/types"; import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; import { BottomSheet } from "./BottomSheet"; @@ -175,6 +176,25 @@ function DashboardInner({ if (!projectId) return sessions; return sessions.filter((s) => s.projectId === projectId); }, [sessions, projectId]); + + const allSessionPrefixes = useMemo( + () => projects.map((p) => p.sessionPrefix ?? p.id), + [projects], + ); + + const sidebarOrchestrators = useMemo( + () => + sessions + .filter((s) => + isOrchestratorSession( + s, + projects.find((p) => p.id === s.projectId)?.sessionPrefix ?? s.projectId, + allSessionPrefixes, + ), + ) + .map((s) => ({ id: s.id, projectId: s.projectId })), + [sessions, projects, allSessionPrefixes], + ); const connectionStatus: "connected" | "reconnecting" | "disconnected" = mux?.status === "disconnected" ? "disconnected" @@ -195,9 +215,20 @@ function DashboardInner({ useState(orchestratorLinks); const [spawningProjectIds, setSpawningProjectIds] = useState([]); const [spawnErrors, setSpawnErrors] = useState>({}); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const isMobile = useMediaQuery(MOBILE_BREAKPOINT); + // Detect if a parent layout already owns the sidebar — if so, skip rendering our own. + const parentCtx = useSidebarContext(); + const isInsideLayout = parentCtx !== null; + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const handleToggleSidebar = useCallback(() => { + if (isInsideLayout && parentCtx) { parentCtx.onToggleSidebar(); return; } + if (isMobile) { + setMobileSidebarOpen((v) => !v); + } else { + setSidebarCollapsed((v) => !v); + } + }, [isMobile, isInsideLayout, parentCtx]); const [collapsedZones, setCollapsedZones] = useState>( () => new Set(["done", "working"]), ); @@ -250,10 +281,6 @@ function DashboardInner({ document.title = needsAttention > 0 ? `${label} (${needsAttention} need attention)` : label; }, [attentionLevels, projectName]); - useEffect(() => { - setMobileMenuOpen(false); - }, [searchParams]); - const grouped = useMemo(() => { const zones: Record = { merge: [], @@ -516,292 +543,336 @@ function DashboardInner({ return next; }); }; - const handleToggleSidebar = () => { - if (typeof window !== "undefined" && window.innerWidth < 768) { - setMobileMenuOpen((current) => !current); - } else { - setSidebarCollapsed((current) => !current); - } - }; - return ( - - <> - - -
-
- +
+ Agent Orchestrator +
+ {showHeaderProjectLabel ? ( + <> +
+ +
+ +
+

Dashboard

+

+ Live agent sessions, pull requests, and merge status. +

+
+ +
+ {loadErrorBanner} + {anyRateLimited && !rateLimitDismissed && ( +
+ - ) : ( - - )} - -
-
- {showHeaderProjectLabel ? ( - <> -
+
+ ); + + const bottomSheet = ( + + ); + + if (isInsideLayout) { + return ( + <> + + + {mainPanel} + {bottomSheet} + ); + } + + return ( + + + +
+
+
+ setSidebarCollapsed((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} + /> +
+ {mobileSidebarOpen && ( +
setMobileSidebarOpen(false)} + /> + )} + {mainPanel} +
+
+ {bottomSheet} ); } diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index 2b6b78e1c..c6b29a7f6 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -1,11 +1,11 @@ "use client"; import Link from "next/link"; -import { useState, useEffect, useMemo, useRef } from "react"; +import { useState, useEffect, useMemo, useRef, useCallback, memo } from "react"; import { useRouter } from "next/navigation"; import { cn } from "@/lib/cn"; import type { ProjectInfo } from "@/lib/project-name"; -import { getAttentionLevel, type DashboardSession, type AttentionLevel } from "@/lib/types"; +import { getAttentionLevel, type DashboardSession } from "@/lib/types"; import { isOrchestratorSession } from "@aoagents/ao-core/types"; import { getSessionTitle, humanizeBranch } from "@/lib/format"; import { usePopoverClamp } from "@/hooks/usePopoverClamp"; @@ -42,7 +42,7 @@ interface ProjectSidebarProps { type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done"; -function SessionDot({ level }: { level: SessionDotLevel }) { +const SessionDot = memo(function SessionDot({ level }: { level: SessionDotLevel }) { return (
); -} +}); // ProjectSidebar consumes `getAttentionLevel()` without passing a mode, // so the function defaults to "detailed" and `action` never appears here @@ -70,15 +70,41 @@ function loadShowSessionId(): boolean { } } -const LEVEL_LABELS: Record = { - working: "working", - pending: "pending", - review: "review", - respond: "respond", - action: "action", - merge: "merge", - done: "done", -}; +const SHOW_KILLED_KEY = "ao:sidebar:show-killed"; +const SHOW_DONE_KEY = "ao:sidebar:show-done"; +const EXPANDED_PROJECTS_KEY = "ao:sidebar:expanded-projects"; + +function loadShowKilled(): boolean { + if (typeof window === "undefined") return false; + try { + return window.sessionStorage.getItem(SHOW_KILLED_KEY) === "true"; + } catch { + return false; + } +} + +function loadShowDone(): boolean { + if (typeof window === "undefined") return false; + try { + return window.sessionStorage.getItem(SHOW_DONE_KEY) === "true"; + } catch { + return false; + } +} + +function loadExpandedProjects(): Set | null { + if (typeof window === "undefined") return null; + try { + const raw = window.sessionStorage.getItem(EXPANDED_PROJECTS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return new Set(parsed); + return null; + } catch { + return null; + } +} + export function ProjectSidebar(props: ProjectSidebarProps) { if (props.projects.length === 0) { @@ -87,6 +113,102 @@ export function ProjectSidebar(props: ProjectSidebarProps) { return ; } +interface SessionRowProps { + session: DashboardSession; + level: SessionDotLevel; + isActive: boolean; + showSessionId: boolean; + pendingRename: string | undefined; + onNavigate: (href: string, session: DashboardSession) => void; + onStartRename: (session: DashboardSession, title: string) => void; +} + +const SessionRow = memo(function SessionRow({ + session, + level, + isActive, + showSessionId, + pendingRename, + onNavigate, + onStartRename, +}: SessionRowProps) { + const effectiveDisplayName = + pendingRename !== undefined + ? pendingRename + : session.displayNameUserSet + ? (session.displayName ?? "") + : ""; + const title = + effectiveDisplayName !== "" + ? effectiveDisplayName + : (session.branch ?? getSessionTitle(session)); + const sessionHref = projectSessionPath(session.projectId, session.id); + + return ( +
+ { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; + e.preventDefault(); + onNavigate(sessionHref, session); + }} + className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]" + aria-current={isActive ? "page" : undefined} + aria-label={`Open ${title}`} + > + +
+ + {title} + + {showSessionId ? ( +
+ {session.id} +
+ ) : null} +
+
+ +
+ ); +}); + function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) { const [addProjectOpen, setAddProjectOpen] = useState(false); @@ -162,13 +284,15 @@ function ProjectSidebarInner({ onMobileClose, }: ProjectSidebarProps) { const router = useRouter(); - const isLoading = loading || sessions === null; + const _isLoading = loading || sessions === null; const [expandedProjects, setExpandedProjects] = useState>( - () => new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []), + () => + loadExpandedProjects() ?? + new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []), ); - const [showKilled, setShowKilled] = useState(false); - const [showDone, setShowDone] = useState(false); + const [showKilled, setShowKilled] = useState(loadShowKilled); + const [showDone, setShowDone] = useState(loadShowDone); const [showSessionId, setShowSessionId] = useState(loadShowSessionId); // Inline session-rename state. Only one row is edited at a time. `pendingRenames` // mirrors the in-flight / just-saved value so the new label appears immediately @@ -198,6 +322,30 @@ function ProjectSidebarInner({ } }, [showSessionId]); + useEffect(() => { + try { + window.sessionStorage.setItem(SHOW_KILLED_KEY, String(showKilled)); + } catch { + // sessionStorage unavailable — accept in-memory state. + } + }, [showKilled]); + + useEffect(() => { + try { + window.sessionStorage.setItem(SHOW_DONE_KEY, String(showDone)); + } catch { + // sessionStorage unavailable — accept in-memory state. + } + }, [showDone]); + + useEffect(() => { + try { + window.sessionStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify([...expandedProjects])); + } catch { + // sessionStorage unavailable — accept in-memory state. + } + }, [expandedProjects]); + // Close the settings popover on outside click or Escape. useEffect(() => { if (!settingsOpen) return; @@ -266,20 +414,37 @@ function ProjectSidebarInner({ [visibleProjects], ); - // The API (selectPreferredOrchestratorId) sends at most one entry per - // project, so collapsing into a Map keyed by projectId is lossless. If a - // future API change starts emitting multiples, the last one wins here. const orchestratorByProject = useMemo( () => new Map((orchestrators ?? []).map((o) => [o.projectId, o])), [orchestrators], ); + // Stable ref so sessionsByProject can read latest sessions without depending + // on the array reference (which changes every SSE tick even when content is unchanged). + const sessionsRef = useRef(sessions); + sessionsRef.current = sessions; + + // Content-based key — only changes when session IDs, statuses, or projects change. + // Used as the sole sessions-related dependency of sessionsByProject below. + const sessionsKey = useMemo( + () => + (sessions ?? []) + .map( + (s) => + `${s.id}:${s.status}:${s.activity ?? ""}:${s.projectId}:${s.displayName ?? ""}:${s.displayNameUserSet ? "1" : "0"}:${s.branch ?? ""}:${s.issueTitle ?? ""}:${s.pr?.title ?? ""}:${s.summary ?? ""}`, + ) + .join("|"), + [sessions], + ); + const sessionsByProject = useMemo(() => { const map = new Map(); // Build a set of valid project IDs to filter sessions strictly const validProjectIds = new Set(visibleProjects.map((p) => p.id)); - for (const s of sessions ?? []) { + // Read via ref so this memo only reruns when sessionsKey changes (content + // changed), not when sessions gets a new array reference with identical data. + for (const s of sessionsRef.current ?? []) { // Only include sessions whose projectId matches a configured project if (!validProjectIds.has(s.projectId)) continue; if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue; @@ -295,7 +460,8 @@ function ProjectSidebarInner({ map.set(s.projectId, list); } return map; - }, [sessions, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]); + }, [sessionsKey, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]); + // Clear an optimistic rename once the prop session.displayName catches up. // Without this, we'd keep masking the server value forever after a save. @@ -313,18 +479,23 @@ function ProjectSidebarInner({ if (changed) setPendingRenames(next); }, [sessions, pendingRenames]); - const startRename = (session: DashboardSession, currentTitle: string) => { - // Prefer the in-flight optimistic value over the prop — if the user opens - // rename while a previous PATCH is still propagating, the prop still shows - // the pre-rename value but we want the input to start from the latest. - // Auto-derived displayName isn't pre-filled (user-set flag absent) — start - // from the live title so the user types over the visible label. - const pending = pendingRenames.get(session.id); - const initial = - pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : ""); - setEditingSessionId(session.id); - setEditingValue(initial || currentTitle); - }; + const pendingRenamesRef = useRef(pendingRenames); + pendingRenamesRef.current = pendingRenames; + + const startRename = useCallback( + (session: DashboardSession, currentTitle: string) => { + // Prefer the in-flight optimistic value over the prop — if the user opens + // rename while a previous PATCH is still propagating, the prop still shows + // the pre-rename value but we want the input to start from the latest. + // Auto-derived displayName isn't pre-filled (user-set flag absent) — start + // from the live title so the user types over the visible label. + const pending = pendingRenamesRef.current.get(session.id); + const initial = pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : ""); + setEditingSessionId(session.id); + setEditingValue(initial || currentTitle); + }, + [], + ); const cancelRename = () => { setEditingSessionId(null); @@ -367,17 +538,20 @@ function ProjectSidebarInner({ } }; - const navigate = (url: string, session?: DashboardSession) => { - if (session) { - try { - sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session)); - } catch { - // sessionStorage unavailable — silent fallback + const navigate = useCallback( + (url: string, session?: DashboardSession) => { + if (session) { + try { + sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session)); + } catch { + // sessionStorage unavailable — silent fallback + } } - } - router.push(url); - onMobileClose?.(); - }; + router.push(url); + onMobileClose?.(); + }, + [router, onMobileClose], + ); const toggleExpand = (projectId: string) => { setExpandedProjects((prev) => { @@ -544,6 +718,16 @@ function ProjectSidebarInner({ {/* Project tree */}
+ {sessions === null ? ( +
+ {Array.from({ length: 4 }, (_, i) => ( +
+
+
+
+ ))} +
+ ) : null} {visibleProjects.map((project) => { const workerSessions = sessionsByProject.get(project.id) ?? []; const isExpanded = expandedProjects.has(project.id); @@ -553,8 +737,14 @@ function ProjectSidebarInner({ // sessionsByProject already applies the showDone filter consistently. const visibleSessions = workerSessions; const hasActiveSessions = visibleSessions.length > 0; - const orchestratorLink = orchestratorByProject.get(project.id) ?? null; + // Look up the full session object so navigate() can cache it in + // sessionStorage — prevents the "Session unavailable" flash on + // first load. Orchestrators are filtered out of sessionsByProject + // but still present in the raw sessions prop. + const orchestratorSession = orchestratorLink + ? (sessions?.find((s) => s.id === orchestratorLink.id) ?? null) + : null; return (
@@ -625,7 +815,7 @@ function ProjectSidebarInner({ hasActiveSessions && "project-sidebar__proj-badge--active", )} > - {workerSessions.length} + {sessionsByProject.get(project.id)?.length ?? 0} )} @@ -656,14 +846,17 @@ function ProjectSidebarInner({ ) : null} - {/* Orchestrator button */} {!isDegraded && orchestratorLink && ( - { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; + e.preventDefault(); e.stopPropagation(); - onMobileClose?.(); + navigate( + projectSessionPath(project.id, orchestratorLink.id), + orchestratorSession ?? undefined, + ); }} className="project-sidebar__proj-action" aria-label={`Open ${project.name} orchestrator`} @@ -683,7 +876,7 @@ function ProjectSidebarInner({ - + )}
{ setProjectMenuOpenId(null); - navigate(projectSessionPath(project.id, orchestratorLink.id)); + navigate( + projectSessionPath(project.id, orchestratorLink.id), + orchestratorSession ?? undefined, + ); }} > Open orchestrator @@ -768,7 +964,7 @@ function ProjectSidebarInner({ {/* Sessions */} {!isDegraded && isExpanded && (
- {isLoading ? ( + {sessions === null ? (
{Array.from({ length: 3 }, (_, index) => (
{ const level = getAttentionLevel(session); const isSessionActive = activeSessionId === session.id; - // Display precedence: optimistic rename (just-saved value) - // → user-set displayName → branch → fallback chain. - // Auto-derived displayName (displayNameUserSet=false) is - // intentionally skipped here so PR/issue titles surfaced - // by getSessionTitle aren't shadowed — mirrors the gate in - // format.ts:getSessionTitle. - const pending = pendingRenames.get(session.id); - const effectiveDisplayName = - pending !== undefined - ? pending - : session.displayNameUserSet - ? (session.displayName ?? "") - : ""; - const title = - effectiveDisplayName !== "" - ? effectiveDisplayName - : (session.branch ?? getSessionTitle(session)); - const sessionHref = projectSessionPath(project.id, session.id); const isEditing = editingSessionId === session.id; if (isEditing) { return ( @@ -839,76 +1017,16 @@ function ProjectSidebarInner({ ); } return ( - + session={session} + level={level} + isActive={isSessionActive} + showSessionId={showSessionId} + pendingRename={pendingRenames.get(session.id)} + onNavigate={navigate} + onStartRename={startRename} + /> ); }) ) : error ? ( @@ -923,7 +1041,9 @@ function ProjectSidebarInner({
) : ( -
No sessions shown
+
+ No active sessions +
)}
)} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 5cb0d0f35..e94d2d972 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -11,10 +11,9 @@ import { import dynamic from "next/dynamic"; import { getSessionTitle } from "@/lib/format"; import type { ProjectInfo } from "@/lib/project-name"; -import { SidebarContext } from "./workspace/SidebarContext"; +import { useSidebarContext } from "./workspace/SidebarContext"; import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; -import { ProjectSidebar, type ProjectSidebarOrchestrator } from "./ProjectSidebar"; import { MobileBottomNav } from "./MobileBottomNav"; import { SessionDetailHeader, type OrchestratorZones } from "./SessionDetailHeader"; import { SessionEndedSummary } from "./SessionEndedSummary"; @@ -40,11 +39,6 @@ interface SessionDetailProps { orchestratorZones?: OrchestratorZones; projectOrchestratorId?: string | null; projects?: ProjectInfo[]; - sidebarSessions?: DashboardSession[] | null; - sidebarOrchestrators?: ProjectSidebarOrchestrator[]; - sidebarLoading?: boolean; - sidebarError?: boolean; - onRetrySidebar?: () => void; } export function SessionDetail({ @@ -53,18 +47,12 @@ export function SessionDetail({ orchestratorZones, projectOrchestratorId = null, projects = [], - sidebarSessions = [], - sidebarOrchestrators, - sidebarLoading = false, - sidebarError = false, - onRetrySidebar, }: SessionDetailProps) { const router = useRouter(); const searchParams = useSearchParams(); const isMobile = useMediaQuery(MOBILE_BREAKPOINT); + const sidebarCtx = useSidebarContext(); const startFullscreen = searchParams.get("fullscreen") === "true"; - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [showTerminal, setShowTerminal] = useState(false); const [relaunchError, setRelaunchError] = useState(null); const pr = session.pr; @@ -162,10 +150,10 @@ export function SessionDetail({ }, [session.id, session.projectId]); const orchestratorHref = useMemo(() => { - if (isOrchestrator) return projectSessionPath(session.projectId, session.id); + if (isOrchestrator) return null; if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId); return null; - }, [isOrchestrator, projectOrchestratorId, session.id, session.projectId]); + }, [isOrchestrator, projectOrchestratorId, session.projectId]); useEffect(() => { const frame = window.requestAnimationFrame(() => setShowTerminal(true)); @@ -175,130 +163,86 @@ export function SessionDetail({ }; }, [session.id]); - const handleToggleSidebar = useCallback(() => { - if (isMobile) { - setMobileSidebarOpen((v) => !v); - } else { - setSidebarCollapsed((v) => !v); - } - }, [isMobile]); - return ( - -
- - -
- {projects.length > 0 ? ( -
- setSidebarCollapsed((current) => !current)} - onMobileClose={() => setMobileSidebarOpen(false)} - /> -
- ) : null} - {mobileSidebarOpen && ( -
setMobileSidebarOpen(false)} /> - )} - -
-
- {relaunchError ? ( -
-
-
- Relaunch failed: {relaunchError} -
- The previous orchestrator may already be terminated. Try again from the - project dashboard. -
-
- -
+
+ {})} + onRestore={handleRestore} + onKill={handleKill} + onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined} + /> +
+ {relaunchError ? ( +
+
+
+ Relaunch failed: {relaunchError} +
+ The previous orchestrator may already be terminated. Try again from the + project dashboard.
- ) : null} -
- {!showTerminal ? ( -
- ) : terminalEnded ? ( - - ) : ( - - )}
-
+ +
+ ) : null} +
+ {!showTerminal ? ( +
+ ) : terminalEnded ? ( + + ) : ( + + )}
- -
- +
+ +
); } diff --git a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx index 418789a74..8055feced 100644 --- a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx @@ -165,6 +165,7 @@ describe("Dashboard empty state", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + it("mounts the sidebar empty state on a fresh install with zero projects", () => { render(); diff --git a/packages/web/src/components/__tests__/ProjectSidebar.test.tsx b/packages/web/src/components/__tests__/ProjectSidebar.test.tsx index 13536ee04..6f7150c37 100644 --- a/packages/web/src/components/__tests__/ProjectSidebar.test.tsx +++ b/packages/web/src/components/__tests__/ProjectSidebar.test.tsx @@ -399,6 +399,8 @@ describe("ProjectSidebar", () => { />, ); + // Only the killed-but-still-needs-attention session is counted; the merged + // session is filtered out by sessionsByProject (showDone = false by default). expect(screen.getByRole("button", { name: /^Project One 1$/ })).toBeInTheDocument(); expect( screen.getByRole("link", { name: "Open Runtime missing but needs review" }), diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index e6df9645b..07a77e48f 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -483,7 +483,7 @@ describe("SessionDetail desktop layout", () => { expect(routerRefreshMock).not.toHaveBeenCalled(); }); - it("keeps the desktop orchestrator button on orchestrator session pages", () => { + it("hides the desktop orchestrator button on orchestrator session pages", () => { render( { ); expect( - within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }), - ).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator"); + within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }), + ).not.toBeInTheDocument(); expect(screen.getByText("orchestrator")).toBeInTheDocument(); }); @@ -557,8 +557,8 @@ describe("SessionDetail desktop layout", () => { ); expect( - within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }), - ).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator"); + within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }), + ).not.toBeInTheDocument(); }); it("routes to the project orchestrator after killing a worker session", async () => { From eac581768da1f4832762491c51e101fe04f87490 Mon Sep 17 00:00:00 2001 From: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com> Date: Mon, 18 May 2026 01:37:39 +0530 Subject: [PATCH 07/26] fix: recover native session restore fallback (#1797) * fix: recover native session restore fallback Persist native agent restore metadata from dead runtimes and fall back to a fresh launch when native restore context is missing, so stuck sessions do not permanently block startup. * fix: avoid redundant dead-session metadata discovery * test: harden Linear list issue polling --- .../__tests__/session-manager/query.test.ts | 42 +++++ .../__tests__/session-manager/restore.test.ts | 113 ++++++++++++- .../core/src/portfolio-session-service.ts | 1 + packages/core/src/recovery/actions.ts | 1 + packages/core/src/session-manager.ts | 158 ++++++++++++------ .../core/src/utils/session-from-metadata.ts | 3 +- .../src/tracker-linear.integration.test.ts | 4 +- 7 files changed, 261 insertions(+), 61 deletions(-) diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index cf7c040bf..17300fca6 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -8,6 +8,7 @@ import { createSessionManager } from "../../session-manager.js"; import { writeMetadata, readMetadataRaw, + updateMetadata, } from "../../metadata.js"; import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js"; import type { @@ -63,6 +64,47 @@ describe("list", () => { expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]); }); + it("skips dead-runtime agent metadata discovery when native restore metadata is already persisted", async () => { + writeMetadata(sessionsDir, "app-1", { + worktree: config.projects["my-app"]!.path, + branch: "feat/a", + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + }); + updateMetadata(sessionsDir, "app-1", { codexThreadId: "thread-1" }); + + const deadRuntime: Runtime = { + ...mockRuntime, + isAlive: vi.fn().mockResolvedValue(false), + }; + const agentWithSessionInfo: Agent = { + ...mockAgent, + name: "codex", + getSessionInfo: vi.fn().mockResolvedValue({ + summary: null, + agentSessionId: "rollout-1", + metadata: { codexThreadId: "thread-1" }, + }), + }; + const registryWithDeadRuntime: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return deadRuntime; + if (slot === "agent") return agentWithSessionInfo; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const sm = createSessionManager({ config, registry: registryWithDeadRuntime }); + await sm.list("my-app"); + await sm.list("my-app"); + + expect(agentWithSessionInfo.getSessionInfo).not.toHaveBeenCalled(); + expect(readMetadataRaw(sessionsDir, "app-1")!["codexThreadId"]).toBe("thread-1"); + }); + it("does not backfill role onto foreign bare-id orchestrator records (issue #1048)", async () => { // Regression guard for PR #1075 review comment: a legacy record whose id // is `{projectId}-orchestrator` (pre-numbered scheme, wrong prefix) must diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index 9445d16f3..3ba13d03c 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -575,7 +575,7 @@ describe("restore", () => { expect(meta!["restoreFallbackReason"]).toBe("mock-agent.getRestoreCommand returned null"); }); - it("does not launch a fresh chat when a native-restore agent cannot build restore command", async () => { + it("falls back to a fresh launch when a native-restore agent cannot build restore command", async () => { const wsPath = join(tmpDir, "ws-app-native-restore-missing"); mkdirSync(wsPath, { recursive: true }); @@ -605,12 +605,119 @@ describe("restore", () => { const sm = createSessionManager({ config, registry: registryWithNativeRestoreAgent }); - await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError); - expect(mockRuntime.create).not.toHaveBeenCalled(); + await sm.restore("app-1"); + + expect(mockRuntime.create).toHaveBeenCalled(); + const createCall = (mockRuntime.create as ReturnType).mock.calls[0][0]; + expect(createCall.launchCommand).toBe("mock-agent --start"); const meta = readMetadataRaw(sessionsDir, "app-1"); expect(meta!["restoreFallbackReason"]).toBe("codex.getRestoreCommand returned null"); }); + it("persists native restore metadata even when runtime is already dead", async () => { + const wsPath = join(tmpDir, "ws-app-dead-runtime-metadata"); + mkdirSync(wsPath, { recursive: true }); + + const deadRuntime: Runtime = { + ...mockRuntime, + isAlive: vi.fn().mockResolvedValue(false), + }; + const agentWithDiscoverableThread: Agent = { + ...mockAgent, + name: "codex", + getSessionInfo: vi.fn().mockResolvedValue({ + summary: null, + agentSessionId: "rollout-1", + metadata: { codexThreadId: "thread-1" }, + }), + getRestoreCommand: vi + .fn() + .mockImplementation(async (session) => + session.metadata?.codexThreadId ? `codex resume ${session.metadata.codexThreadId}` : null, + ), + }; + + const registryWithDeadRuntime: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return deadRuntime; + if (slot === "agent") return agentWithDiscoverableThread; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-1", + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + }); + + const sm = createSessionManager({ config, registry: registryWithDeadRuntime }); + const restored = await sm.restore("app-1"); + + expect(agentWithDiscoverableThread.getSessionInfo).toHaveBeenCalled(); + expect(agentWithDiscoverableThread.getRestoreCommand).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ codexThreadId: "thread-1" }), + }), + expect.any(Object), + ); + expect(restored.metadata["codexThreadId"]).toBe("thread-1"); + const createCall = (deadRuntime.create as ReturnType).mock.calls[0][0]; + expect(createCall.launchCommand).toBe("codex resume thread-1"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta!["codexThreadId"]).toBe("thread-1"); + }); + + it("uses project path as restore workspace when worktree metadata is missing", async () => { + mkdirSync(config.projects["my-app"]!.path, { recursive: true }); + + const agentWithWorkspaceAssertion: Agent = { + ...mockAgent, + name: "codex", + getRestoreCommand: vi + .fn() + .mockImplementation(async (session) => + session.workspacePath === config.projects["my-app"]!.path + ? "codex resume thread-1" + : null, + ), + }; + + const registryWithWorkspaceAssertion: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithWorkspaceAssertion; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: "", + branch: "feat/TEST-1", + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + }); + + const sm = createSessionManager({ config, registry: registryWithWorkspaceAssertion }); + const restored = await sm.restore("app-1"); + + expect(restored.workspacePath).toBe(config.projects["my-app"]!.path); + expect(agentWithWorkspaceAssertion.getRestoreCommand).toHaveBeenCalledWith( + expect.objectContaining({ workspacePath: config.projects["my-app"]!.path }), + expect.any(Object), + ); + const createCall = (mockRuntime.create as ReturnType).mock.calls[0][0]; + expect(createCall.workspacePath).toBe(config.projects["my-app"]!.path); + expect(createCall.launchCommand).toBe("codex resume thread-1"); + }); + it("clears restore fallback reason when getRestoreCommand succeeds", async () => { const wsPath = join(tmpDir, "ws-app-restore-clears-fallback"); mkdirSync(wsPath, { recursive: true }); diff --git a/packages/core/src/portfolio-session-service.ts b/packages/core/src/portfolio-session-service.ts index 0d1ce5472..4be551226 100644 --- a/packages/core/src/portfolio-session-service.ts +++ b/packages/core/src/portfolio-session-service.ts @@ -141,6 +141,7 @@ function metadataToSession(sessionId: string, project: PortfolioProject, metadat return sessionFromMetadata(sessionId, metadataToRecord(metadata), { projectId: project.id, + workspacePathFallback: project.repoPath, status: (metadata.status as Session["status"]) || "spawning", activity: null, runtimeHandle: metadata.runtimeHandle ?? null, diff --git a/packages/core/src/recovery/actions.ts b/packages/core/src/recovery/actions.ts index 14e315b08..43a9eec87 100644 --- a/packages/core/src/recovery/actions.ts +++ b/packages/core/src/recovery/actions.ts @@ -132,6 +132,7 @@ export async function recoverSession( const session = sessionFromMetadata(sessionId, updatedMetadata, { projectId: assessment.projectId, + workspacePathFallback: assessment.workspacePath ?? undefined, status: preservedStatus, runtimeHandle: assessment.runtimeHandle, lastActivityAt: new Date(), diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 84dbe6a1e..6f51650bc 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -338,27 +338,33 @@ function parseLifecycleFromRaw( } } +interface MetadataToSessionOptions { + projectId: string; + sessionPrefix?: string; + createdAt?: Date; + modifiedAt?: Date; + workspacePathFallback?: string; +} + /** Reconstruct a Session object from raw metadata key=value pairs. */ function metadataToSession( sessionId: SessionId, meta: Record, - projectId: string, - sessionPrefix?: string, - createdAt?: Date, - modifiedAt?: Date, + options: MetadataToSessionOptions, ): Session { const sessionKind = meta["role"] === "orchestrator" || - (sessionPrefix - ? new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId) + (options.sessionPrefix + ? new RegExp(`^${escapeRegex(options.sessionPrefix)}-orchestrator-\\d+$`).test(sessionId) : false) ? "orchestrator" : "worker"; return sessionFromMetadata(sessionId, meta, { - projectId, + projectId: options.projectId, + workspacePathFallback: options.workspacePathFallback, sessionKind, - createdAt, - lastActivityAt: modifiedAt ?? new Date(), + createdAt: options.createdAt, + lastActivityAt: options.modifiedAt ?? new Date(), }); } @@ -453,18 +459,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix); } - function requiresNativeRestore(agentName: string): boolean { - // kimicode is intentionally excluded: kimi's session dir only exists if the - // previous launch ran far enough to write to ~/.kimi/sessions. A failed - // launch (e.g. the --agent-file YAML crash) leaves no session to resume, - // and falling back to a fresh getLaunchCommand is the only sensible choice. - return ( - agentName === "claude-code" || - agentName === "codex" || - agentName === "opencode" - ); - } - function applyMetadataUpdatesToRaw( raw: Record, updates: Partial>, @@ -1020,12 +1014,68 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM */ const TERMINAL_SESSION_STATUSES = new Set(["killed", "done", "merged", "terminated", "cleanup"]); + function hasPersistedNativeRestoreMetadata(session: Session, agent: Agent): boolean { + const metadata = session.metadata ?? {}; + + switch (agent.name) { + case "claude-code": + return typeof metadata["claudeSessionUuid"] === "string" && metadata["claudeSessionUuid"].trim().length > 0; + case "codex": + return typeof metadata["codexThreadId"] === "string" && metadata["codexThreadId"].trim().length > 0; + case "opencode": + return asValidOpenCodeSessionId(metadata["opencodeSessionId"]) !== null; + default: + return false; + } + } + + function canDiscoverSessionInfoAfterRuntimeExit(agent: Agent): boolean { + return agent.name === "claude-code" || agent.name === "codex"; + } + async function enrichSessionWithRuntimeState( session: Session, plugins: ReturnType, handleFromMetadata: boolean, sessionsDir: string, ): Promise { + async function persistAgentSessionInfo(options?: { skipIfNativeRestoreMetadataPresent?: boolean }): Promise { + if (!plugins.agent) return; + if ( + options?.skipIfNativeRestoreMetadataPresent && + hasPersistedNativeRestoreMetadata(session, plugins.agent) + ) { + return; + } + + let info: Awaited>; + try { + info = await plugins.agent.getSessionInfo(session); + } catch { + // Can't get session info — keep existing values + info = null; + } + + if (!info) return; + + session.agentInfo = info; + const metadataUpdates = info.metadata ?? {}; + const allAlreadyPersisted = Object.keys(metadataUpdates).every( + (key) => session.metadata?.[key] === metadataUpdates[key], + ); + if (allAlreadyPersisted) return; + + if (Object.keys(metadataUpdates).length > 0) { + try { + updateMetadata(sessionsDir, session.id, metadataUpdates); + session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates); + invalidateCache(); + } catch { + // Persisting agent metadata is best-effort; keep live agent info. + } + } + } + // Check runtime liveness first — for all statuses except "spawning". // Skip spawning sessions because tmux may not be fully initialized yet, // and a false-negative from isAlive() would permanently mark the session @@ -1066,6 +1116,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM activity: "exited", source: "runtime", }); + // Dead-runtime session info discovery is intentionally limited to + // agents that recover restore metadata from persisted local files. + if (plugins.agent && canDiscoverSessionInfoAfterRuntimeExit(plugins.agent)) { + await persistAgentSessionInfo({ skipIfNativeRestoreMetadataPresent: true }); + } return; } } catch { @@ -1102,28 +1157,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM session.activitySignal = createActivitySignal("probe_failure", { source: "native" }); } - // Enrich with live agent session info (summary, cost). - let info: Awaited>; - try { - info = await plugins.agent.getSessionInfo(session); - } catch { - // Can't get session info — keep existing values - info = null; - } - - if (info) { - session.agentInfo = info; - const metadataUpdates = info.metadata ?? {}; - if (Object.keys(metadataUpdates).length > 0) { - try { - updateMetadata(sessionsDir, session.id, metadataUpdates); - session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates); - invalidateCache(); - } catch { - // Persisting agent metadata is best-effort; keep live agent info. - } - } - } + // Enrich with agent session info (summary, cost, native restore metadata). + await persistAgentSessionInfo(); } } @@ -1977,10 +2012,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const session = metadataToSession( sessionName, raw, - sessionProjectId, - project.sessionPrefix, - createdAt, - modifiedAt, + { + projectId: sessionProjectId, + sessionPrefix: project.sessionPrefix, + createdAt, + modifiedAt, + workspacePathFallback: project.path, + }, ); const selection = resolveSelectionForSession(project, sessionName, raw); const effectiveAgentName = selection.agentName; @@ -2098,10 +2136,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const session = metadataToSession( sessionId, repaired.raw, - projectId, - project.sessionPrefix, - createdAt, - modifiedAt, + { + projectId, + sessionPrefix: project.sessionPrefix, + createdAt, + modifiedAt, + workspacePathFallback: project.path, + }, ); const selection = resolveSelectionForSession(project, sessionId, repaired.raw); @@ -2872,7 +2913,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // metadataToSession sets activity: null, so without enrichment a crashed // session (status "working", agent exited) would not be detected as terminal // and isRestorable would reject it. - const session = metadataToSession(sessionId, raw, projectId, project.sessionPrefix); + const session = metadataToSession( + sessionId, + raw, + { + projectId, + sessionPrefix: project.sessionPrefix, + workspacePathFallback: project.path, + }, + ); const plugins = resolvePlugins(project, selection.agentName); await enrichSessionWithRuntimeState(session, plugins, true, sessionsDir); @@ -3008,13 +3057,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM launchCommand = restoreCmd; updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: "" }); } else { + // Agents with native restore can still launch fresh when no resumable + // session metadata exists; this keeps restore from becoming a hard stop. const reason = `${plugins.agent.name}.getRestoreCommand returned null`; updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: reason, }); - if (requiresNativeRestore(plugins.agent.name)) { - throw new SessionNotRestorableError(sessionId, reason); - } launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); } } else { diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index aeda52d79..9230ff18b 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -14,6 +14,7 @@ import { safeJsonParse, validateStatus } from "./validation.js"; interface SessionFromMetadataOptions { projectId?: string; + workspacePathFallback?: string; status?: SessionStatus; sessionKind?: SessionKind; activity?: Session["activity"]; @@ -86,7 +87,7 @@ export function sessionFromMetadata( }; })() : null, - workspacePath: meta["worktree"] || null, + workspacePath: meta["worktree"] || options.workspacePathFallback || null, runtimeHandle: lifecycle.runtime.handle ?? runtimeHandle, agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null, createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : (options.createdAt ?? new Date()), diff --git a/packages/integration-tests/src/tracker-linear.integration.test.ts b/packages/integration-tests/src/tracker-linear.integration.test.ts index c7406ce8c..6749761ea 100644 --- a/packages/integration-tests/src/tracker-linear.integration.test.ts +++ b/packages/integration-tests/src/tracker-linear.integration.test.ts @@ -252,10 +252,10 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { // Linear API has eventual consistency — poll until the issue appears in list results const found = await pollUntil( async () => { - const issues = await tracker.listIssues!({ state: "open", limit: 50 }, project); + const issues = await tracker.listIssues!({ state: "open", limit: 100 }, project); return issues.find((i: { id: string }) => i.id === issueIdentifier); }, - { timeoutMs: 5_000, intervalMs: 500 }, + { timeoutMs: 15_000, intervalMs: 1_000 }, ); expect(found).toBeDefined(); From 87c6a3d9f47d4602f6756e9673a0ebdc03ca66f1 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Mon, 18 May 2026 03:48:28 +0530 Subject: [PATCH 08/26] fix(web): hide unenriched PR diff stats (#1912) Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> --- .../src/components/SessionDetailPRCard.tsx | 11 +++-- .../SessionDetailPRCard.diffStats.test.tsx | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/components/__tests__/SessionDetailPRCard.diffStats.test.tsx diff --git a/packages/web/src/components/SessionDetailPRCard.tsx b/packages/web/src/components/SessionDetailPRCard.tsx index 55fc306fa..3f83d7f5a 100644 --- a/packages/web/src/components/SessionDetailPRCard.tsx +++ b/packages/web/src/components/SessionDetailPRCard.tsx @@ -178,6 +178,7 @@ export function SessionDetailPRCard({ const allGreen = isPRMergeReady(pr); const blockerIssues = buildBlockerChips(pr, metadata, lifecyclePrReason); const fileCount = pr.changedFiles ?? 0; + const showDiffStats = !isPRUnenriched(pr); const showConflictActions = hasMergeConflicts(pr) && pr.state === "open"; const compareUrl = showConflictActions ? buildGitHubCompareUrl(pr) : ""; @@ -213,10 +214,12 @@ export function SessionDetailPRCard({ > PR #{pr.number}: {pr.title} - - +{pr.additions}{" "} - -{pr.deletions} - + {showDiffStats ? ( + + +{pr.additions}{" "} + -{pr.deletions} + + ) : null} {fileCount > 0 ? ( {fileCount} file{fileCount !== 1 ? "s" : ""} diff --git a/packages/web/src/components/__tests__/SessionDetailPRCard.diffStats.test.tsx b/packages/web/src/components/__tests__/SessionDetailPRCard.diffStats.test.tsx new file mode 100644 index 000000000..0f57e6b70 --- /dev/null +++ b/packages/web/src/components/__tests__/SessionDetailPRCard.diffStats.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SessionDetailPRCard } from "../SessionDetailPRCard"; +import { makePR } from "../../__tests__/helpers"; + +describe("SessionDetailPRCard diff stats", () => { + it("shows diff stats for enriched PRs", () => { + render( + , + ); + + expect(screen.getByText("+629")).toBeInTheDocument(); + expect(screen.getByText("-44")).toBeInTheDocument(); + }); + + it("hides diff stats for unenriched PRs", () => { + render( + , + ); + + expect(screen.queryByText("+629")).not.toBeInTheDocument(); + expect(screen.queryByText("-44")).not.toBeInTheDocument(); + }); +}); From d5d0f077ad49e28aaa5aa8b4725839320c05e0ae Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Mon, 18 May 2026 04:02:26 +0530 Subject: [PATCH 09/26] fix(cli): rebuild better-sqlite3 on install + quieter ABI-mismatch warning (closes #1822) (#1824) * fix(cli): rebuild better-sqlite3 when binding is missing * fix(cli): support Windows postinstall rebuild shims --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> --- .changeset/quiet-sqlite-rebuild.md | 7 + packages/ao/bin/postinstall.js | 187 +++++++++++++++--- .../cli/__tests__/scripts/postinstall.test.ts | 64 ++++++ packages/core/package.json | 4 +- packages/core/src/__tests__/events-db.test.ts | 72 +++++++ packages/core/src/events-db.ts | 48 ++++- pnpm-lock.yaml | 11 +- 7 files changed, 352 insertions(+), 41 deletions(-) create mode 100644 .changeset/quiet-sqlite-rebuild.md create mode 100644 packages/cli/__tests__/scripts/postinstall.test.ts create mode 100644 packages/core/src/__tests__/events-db.test.ts diff --git a/.changeset/quiet-sqlite-rebuild.md b/.changeset/quiet-sqlite-rebuild.md new file mode 100644 index 000000000..79dbfd120 --- /dev/null +++ b/.changeset/quiet-sqlite-rebuild.md @@ -0,0 +1,7 @@ +--- +"@aoagents/ao": patch +"@aoagents/ao-core": patch +"@aoagents/ao-cli": patch +--- + +Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic. diff --git a/packages/ao/bin/postinstall.js b/packages/ao/bin/postinstall.js index feb8f21e2..58caae5fc 100644 --- a/packages/ao/bin/postinstall.js +++ b/packages/ao/bin/postinstall.js @@ -11,19 +11,28 @@ * If not (common with nvm/fnm/volta), rebuilds from source via npx node-gyp. * See: https://github.com/ComposioHQ/agent-orchestrator/issues/987 * - * 3. Clears stale Next.js runtime cache (.next/cache) from @composio/ao-web + * 3. Verifies better-sqlite3 has a native binding for this Node ABI. + * Node majors can ship new NODE_MODULE_VERSION values before better-sqlite3 + * publishes matching prebuilds; global installs must rebuild from source. + * See: https://github.com/ComposioHQ/agent-orchestrator/issues/1822 + * + * 4. Clears stale Next.js runtime cache (.next/cache) from @composio/ao-web * after a version upgrade, so `ao start` serves fresh dashboard assets. * Writes a version stamp (.next/AO_VERSION) to skip cleanup on subsequent runs. */ import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -function findPackageUp(startDir, ...segments) { +function isWindows() { + return process.platform === "win32"; +} + +export function findPackageUp(startDir, ...segments) { let dir = resolve(startDir); while (true) { const candidate = resolve(dir, "node_modules", ...segments); @@ -35,12 +44,12 @@ function findPackageUp(startDir, ...segments) { return null; } -function resolveNodeModulesPackage(fromDir, ...segments) { +export function resolveNodeModulesPackage(fromDir, ...segments) { const packageDir = resolve(fromDir, "node_modules", ...segments); return existsSync(resolve(packageDir, "package.json")) ? packageDir : null; } -function findWebDir() { +export function findWebDir() { const directWebDir = findPackageUp(__dirname, "@aoagents", "ao-web"); if (directWebDir) return directWebDir; @@ -50,8 +59,112 @@ function findWebDir() { return resolveNodeModulesPackage(cliDir, "@aoagents", "ao-web"); } -// --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) --- -if (process.platform !== "win32") { +export function findBetterSqlite3Dir() { + const directBetterSqlite3Dir = findPackageUp(__dirname, "better-sqlite3"); + if (directBetterSqlite3Dir) return directBetterSqlite3Dir; + + const cliDir = findPackageUp(__dirname, "@aoagents", "ao-cli"); + if (!cliDir) return null; + + const coreDir = resolveNodeModulesPackage(cliDir, "@aoagents", "ao-core"); + if (!coreDir) return null; + + return ( + resolveNodeModulesPackage(coreDir, "better-sqlite3") ?? findPackageUp(coreDir, "better-sqlite3") + ); +} + +export function betterSqlite3BindingCandidates( + packageDir, + { + platform = process.platform, + arch = process.arch, + modules = process.versions.modules, + nodeVersion = process.versions.node, + } = {}, +) { + return [ + resolve(packageDir, "build", "better_sqlite3.node"), + resolve(packageDir, "build", "Debug", "better_sqlite3.node"), + resolve(packageDir, "build", "Release", "better_sqlite3.node"), + resolve(packageDir, "out", "Debug", "better_sqlite3.node"), + resolve(packageDir, "Debug", "better_sqlite3.node"), + resolve(packageDir, "out", "Release", "better_sqlite3.node"), + resolve(packageDir, "Release", "better_sqlite3.node"), + resolve(packageDir, "build", "default", "better_sqlite3.node"), + resolve(packageDir, "compiled", nodeVersion, platform, arch, "better_sqlite3.node"), + resolve(packageDir, "addon-build", "release", "install-root", "better_sqlite3.node"), + resolve(packageDir, "addon-build", "debug", "install-root", "better_sqlite3.node"), + resolve(packageDir, "addon-build", "default", "install-root", "better_sqlite3.node"), + resolve( + packageDir, + "lib", + "binding", + `node-v${modules}-${platform}-${arch}`, + "better_sqlite3.node", + ), + ]; +} + +export function hasBetterSqlite3Binding(packageDir, options = {}) { + const fileExists = options.existsSync ?? existsSync; + return betterSqlite3BindingCandidates(packageDir, options).some((candidate) => + fileExists(candidate), + ); +} + +export function betterSqlite3RebuildCommand(packageDir, env = process.env) { + const packageManager = + `${env.npm_config_user_agent ?? ""} ${env.npm_execpath ?? ""}`.toLowerCase(); + if (packageManager.includes("npm") && !packageManager.includes("pnpm")) { + return { command: "npm", args: ["rebuild"], display: `cd ${packageDir} && npm rebuild` }; + } + return { + command: "pnpm", + args: ["--dir", packageDir, "rebuild"], + display: `pnpm --dir ${packageDir} rebuild`, + }; +} + +function checkBetterSqlite3Binding() { + const betterSqlite3Dir = findBetterSqlite3Dir(); + if (!betterSqlite3Dir) { + console.warn( + "⚠️ better-sqlite3 package not found; skipping activity-events native binding check", + ); + return; + } + + const abi = process.versions.modules; + if (hasBetterSqlite3Binding(betterSqlite3Dir)) { + console.log( + `✓ better-sqlite3 native binding present for Node ${process.version} (ABI v${abi})`, + ); + return; + } + + const { command, args, display } = betterSqlite3RebuildCommand(betterSqlite3Dir); + try { + execFileSync(command, args, { + cwd: betterSqlite3Dir, + stdio: "ignore", + timeout: 120000, + shell: isWindows(), + windowsHide: true, + }); + console.log( + `✓ better-sqlite3 native binding rebuilt for Node ${process.version} (ABI v${abi})`, + ); + } catch { + console.warn( + `⚠️ better-sqlite3 rebuild failed for Node ${process.version} (ABI v${abi}) — activity events may be unavailable. Manual fix: ${display}`, + ); + } +} + +function fixNodePty() { + if (isWindows()) return; + const nodePtyDir = findPackageUp(__dirname, "node-pty"); if (nodePtyDir) { const spawnHelper = resolve( @@ -84,7 +197,11 @@ if (process.platform !== "win32") { }, ); } catch { - console.log("⚠️ node-pty prebuilt binary incompatible with Node.js " + process.version + ", rebuilding..."); + console.log( + "⚠️ node-pty prebuilt binary incompatible with Node.js " + + process.version + + ", rebuilding...", + ); try { execSync("npx --yes node-gyp rebuild", { cwd: nodePtyDir, @@ -100,27 +217,43 @@ if (process.platform !== "win32") { } } -// --- 3. Clear stale Next.js runtime cache after version upgrade --- -try { - const webDir = findWebDir(); - if (webDir) { - const pkgPath = resolve(webDir, "package.json"); - if (existsSync(pkgPath)) { - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); - const version = pkg.version; - const cacheDir = resolve(webDir, ".next", "cache"); - const stampPath = resolve(webDir, ".next", "AO_VERSION"); +function clearDashboardCache() { + try { + const webDir = findWebDir(); + if (webDir) { + const pkgPath = resolve(webDir, "package.json"); + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + const version = pkg.version; + const cacheDir = resolve(webDir, ".next", "cache"); + const stampPath = resolve(webDir, ".next", "AO_VERSION"); - if (existsSync(cacheDir)) { - rmSync(cacheDir, { recursive: true, force: true }); - console.log("✓ Cleared stale .next/cache"); - } - if (existsSync(resolve(webDir, ".next"))) { - writeFileSync(stampPath, version, "utf8"); - console.log(`✓ Dashboard version stamp set to ${version}`); + if (existsSync(cacheDir)) { + rmSync(cacheDir, { recursive: true, force: true }); + console.log("✓ Cleared stale .next/cache"); + } + if (existsSync(resolve(webDir, ".next"))) { + writeFileSync(stampPath, version, "utf8"); + console.log(`✓ Dashboard version stamp set to ${version}`); + } } } + } catch (err) { + console.warn(`⚠️ Could not clear dashboard cache (non-critical): ${err.message}`); } -} catch (err) { - console.warn(`⚠️ Could not clear dashboard cache (non-critical): ${err.message}`); +} + +export function runPostinstall() { + // --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) --- + fixNodePty(); + + // --- 3. Ensure better-sqlite3 has a native binding for this Node ABI --- + checkBetterSqlite3Binding(); + + // --- 4. Clear stale Next.js runtime cache after version upgrade --- + clearDashboardCache(); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runPostinstall(); } diff --git a/packages/cli/__tests__/scripts/postinstall.test.ts b/packages/cli/__tests__/scripts/postinstall.test.ts new file mode 100644 index 000000000..9d277e854 --- /dev/null +++ b/packages/cli/__tests__/scripts/postinstall.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + betterSqlite3BindingCandidates, + betterSqlite3RebuildCommand, + hasBetterSqlite3Binding, +} from "../../../ao/bin/postinstall.js"; + +describe("ao postinstall better-sqlite3 native binding detection", () => { + const env = { + platform: "darwin", + arch: "arm64", + modules: "141", + nodeVersion: "25.9.0", + }; + + it("checks the current Node ABI binding path", () => { + const packageDir = "virtual-better-sqlite3"; + const candidates = betterSqlite3BindingCandidates(packageDir, env); + + expect(candidates.some((candidate) => candidate.includes("node-v141-darwin-arm64"))).toBe(true); + }); + + it("reports the binding present when a mocked candidate exists", () => { + const packageDir = "virtual-better-sqlite3"; + const candidates = betterSqlite3BindingCandidates(packageDir, env); + const currentAbiBinding = candidates.find((candidate) => + candidate.includes("node-v141-darwin-arm64"), + ); + if (!currentAbiBinding) { + throw new Error("expected current ABI binding candidate"); + } + + const existingFiles = new Set([currentAbiBinding]); + + expect( + hasBetterSqlite3Binding(packageDir, { + ...env, + existsSync: (candidate: string) => existingFiles.has(candidate), + }), + ).toBe(true); + }); + + it("reports the binding missing when no mocked candidate exists", () => { + expect( + hasBetterSqlite3Binding("virtual-better-sqlite3", { + ...env, + existsSync: () => false, + }), + ).toBe(false); + }); + + it("uses pnpm to rebuild inside the resolved better-sqlite3 package", () => { + expect( + betterSqlite3RebuildCommand("virtual-better-sqlite3", { + npm_config_user_agent: "pnpm/9.15.4 npm/? node/v25.9.0 darwin arm64", + }), + ).toEqual({ + command: "pnpm", + args: ["--dir", "virtual-better-sqlite3", "rebuild"], + display: "pnpm --dir virtual-better-sqlite3 rebuild", + }); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 0b83ed14b..3ac2696d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,7 +91,7 @@ "zod": "^3.24.0" }, "optionalDependencies": { - "better-sqlite3": "^11.0.0" + "better-sqlite3": "^12.10.0" }, "devDependencies": { "@rollup/plugin-typescript": "^12.3.0", @@ -99,8 +99,8 @@ "@types/node": "^25.2.3", "@vitest/coverage-v8": "^4.0.18", "rollup": "^4.60.1", - "tsx": "^4.21.0", "tslib": "^2.8.1", + "tsx": "^4.21.0", "typescript": "^5.7.0", "vitest": "^4.0.18" }, diff --git a/packages/core/src/__tests__/events-db.test.ts b/packages/core/src/__tests__/events-db.test.ts new file mode 100644 index 000000000..f1f21237a --- /dev/null +++ b/packages/core/src/__tests__/events-db.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + __resetActivityEventsDbWarningForTests, + emitActivityEventsDbUnavailableWarning, + formatActivityEventsDbUnavailableWarning, +} from "../events-db.js"; + +describe("activity-events DB unavailable warning", () => { + const originalArgv = process.argv; + const originalDebug = process.env["AO_DEBUG"]; + + beforeEach(() => { + __resetActivityEventsDbWarningForTests(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + delete process.env["AO_DEBUG"]; + process.argv = ["node", "ao"]; + }); + + afterEach(() => { + process.argv = originalArgv; + if (originalDebug === undefined) { + delete process.env["AO_DEBUG"]; + } else { + process.env["AO_DEBUG"] = originalDebug; + } + vi.restoreAllMocks(); + }); + + it("formats missing native binding errors without the bindings search path", () => { + const message = formatActivityEventsDbUnavailableWarning( + new Error( + "Could not locate the bindings file. Tried:\n → /tmp/build/Release/better_sqlite3.node", + ), + ); + + expect(message).toBe( + `[ao] activity-events disabled: better-sqlite3 not compiled for Node ${process.version} (ABI v${process.versions.modules}). Run \`pnpm rebuild better-sqlite3\` or use a supported Node version.`, + ); + expect(message).not.toContain("Tried:"); + expect(message).not.toContain("/tmp/build/Release"); + }); + + it("prints the runtime warning once per process", () => { + process.env["AO_DEBUG"] = "1"; + const err = new Error( + "Could not locate the bindings file. Tried:\n → /tmp/better_sqlite3.node", + ); + + emitActivityEventsDbUnavailableWarning(err); + emitActivityEventsDbUnavailableWarning(err); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(vi.mocked(console.warn).mock.calls[0]?.[0]).toContain( + "activity-events disabled: better-sqlite3 not compiled", + ); + }); + + it("suppresses non-events invocations unless AO_DEBUG=1", () => { + const err = new Error( + "Could not locate the bindings file. Tried:\n → /tmp/better_sqlite3.node", + ); + + process.argv = ["node", "ao", "spawn", "demo"]; + emitActivityEventsDbUnavailableWarning(err); + expect(console.warn).not.toHaveBeenCalled(); + + process.argv = ["node", "ao", "events", "stats"]; + emitActivityEventsDbUnavailableWarning(err); + expect(console.warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/events-db.ts b/packages/core/src/events-db.ts index 71444a9a3..ca04f0062 100644 --- a/packages/core/src/events-db.ts +++ b/packages/core/src/events-db.ts @@ -24,6 +24,7 @@ type BetterSqlite3Database = { let _db: BetterSqlite3Database | null = null; let _dbFailed = false; let _ftsEnabled = false; +let _dbUnavailableWarningEmitted = false; const PRUNE_BATCH_SIZE = 1000; function getEventsDbPath(): string { @@ -90,14 +91,12 @@ function initFts(db: BetterSqlite3Database): void { } function pruneOldEvents(db: BetterSqlite3Database, cutoff: number): void { - db - .prepare( - `DELETE FROM activity_events + db.prepare( + `DELETE FROM activity_events WHERE rowid IN ( SELECT rowid FROM activity_events WHERE ts_epoch < ? LIMIT ? )`, - ) - .run(cutoff, PRUNE_BATCH_SIZE); + ).run(cutoff, PRUNE_BATCH_SIZE); } function openDb(): BetterSqlite3Database { @@ -161,6 +160,42 @@ export function closeDb(): void { _ftsEnabled = false; } +function isAoEventsInvocation(argv = process.argv): boolean { + return argv.slice(2).includes("events"); +} + +function isMissingBetterSqlite3Binding(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return ( + message.includes("Could not locate the bindings file") || + message.includes("better_sqlite3.node") || + message.includes("Cannot find module 'better-sqlite3'") + ); +} + +function firstErrorLine(err: unknown): string { + return (err instanceof Error ? err.message : String(err)).split(/\r?\n/, 1)[0] ?? "unknown error"; +} + +export function formatActivityEventsDbUnavailableWarning(err: unknown): string { + if (isMissingBetterSqlite3Binding(err)) { + return `[ao] activity-events disabled: better-sqlite3 not compiled for Node ${process.version} (ABI v${process.versions.modules}). Run \`pnpm rebuild better-sqlite3\` or use a supported Node version.`; + } + return `[ao] activity-events disabled: better-sqlite3 failed to load: ${firstErrorLine(err)}`; +} + +export function emitActivityEventsDbUnavailableWarning(err: unknown): void { + if (_dbUnavailableWarningEmitted) return; + if (process.env["AO_DEBUG"] !== "1" && !isAoEventsInvocation()) return; + _dbUnavailableWarningEmitted = true; + // eslint-disable-next-line no-console + console.warn(formatActivityEventsDbUnavailableWarning(err)); +} + +export function __resetActivityEventsDbWarningForTests(): void { + _dbUnavailableWarningEmitted = false; +} + /** * Get the lazily-initialized DB connection. * Returns null if better-sqlite3 failed to load or init — callers should treat null as no-op. @@ -173,8 +208,7 @@ export function getDb(): BetterSqlite3Database | null { return _db; } catch (err) { _dbFailed = true; - // Log once so operators know events are being dropped; subsequent calls return null silently. - console.warn("[ao] activity-events DB unavailable — events will be dropped:", err instanceof Error ? err.message : String(err)); + emitActivityEventsDbUnavailableWarning(err); return null; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 632a7cb12..0b7d3e9a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,8 +163,8 @@ importers: version: 3.25.76 optionalDependencies: better-sqlite3: - specifier: ^11.0.0 - version: 11.10.0 + specifier: ^12.10.0 + version: 12.10.0 devDependencies: '@rollup/plugin-typescript': specifier: ^12.3.0 @@ -2352,8 +2352,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} - better-sqlite3@11.10.0: - resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -5917,7 +5918,7 @@ snapshots: dependencies: is-windows: 1.0.2 - better-sqlite3@11.10.0: + better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 From 33789445d2941b657aa27e5a57fc641d6d4d1f40 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Mon, 18 May 2026 04:43:09 +0530 Subject: [PATCH 10/26] fix: remove 3 orphaned submodule refs leaked by PR #1819 (#1913) PR #1819 accidentally introduced ao-pr1483, hermes-agent, and libkrunfw as submodule entries (mode 160000) with no .gitmodules. These are orphaned refs that serve no purpose and should not be on main. Co-authored-by: AO Bot --- ao-pr1483 | 1 - hermes-agent | 1 - libkrunfw | 1 - 3 files changed, 3 deletions(-) delete mode 160000 ao-pr1483 delete mode 160000 hermes-agent delete mode 160000 libkrunfw diff --git a/ao-pr1483 b/ao-pr1483 deleted file mode 160000 index 2abb1c1e3..000000000 --- a/ao-pr1483 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2abb1c1e3dd804da34baf8262fa44c6766421032 diff --git a/hermes-agent b/hermes-agent deleted file mode 160000 index 27eeea055..000000000 --- a/hermes-agent +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 27eeea0555a0a15b19cea615f46d1f8b88b9dbc1 diff --git a/libkrunfw b/libkrunfw deleted file mode 160000 index 351d354b4..000000000 --- a/libkrunfw +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 351d354b4b3b3e45f38e29897af8acec9966fd41 From befd910eebddf3c10fa131baa24c99b311ea3943 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Mon, 18 May 2026 17:25:23 +0530 Subject: [PATCH 11/26] feat: emit plugin-internal activity events for distinct failure shapes (#1699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: emit plugin-internal activity events for distinct failure shapes Plugins (scm/tracker/workspace/notifier) call recordActivityEvent directly to surface failure shapes the lifecycle layer can't distinguish from generic "plugin call failed": - scm-github: scm.gh_unavailable (gh CLI missing, deduped per-process), scm.batch_enrich_pr_failed (one PR fails inside an OK batch), scm.ci_summary_failclosed (getCIChecks threw, fell closed to "failing") - scm-gitlab: scm.ci_summary_failclosed, scm.review_fetch_failed - workspace-worktree: workspace.post_create_failed (which command failed), workspace.branch_collision (concurrent session on same branch), workspace.destroy_fell_back (rmSync after git failure → stale metadata) - workspace-clone: workspace.branch_collision, workspace.corrupt_clone_skipped - tracker-linear: tracker.dep_missing (Composio SDK not installed, deduped per-process), tracker.api_timeout (30s abort) - notifier-openclaw: notifier.auth_failed (401/403 distinct from 5xx), notifier.unreachable (ECONNREFUSED) - notifier-discord: notifier.rate_limited (429 budget exhausted) - notifier-composio: notifier.dep_missing (deduped per-process) Adds "tracker", "workspace", "notifier" to ActivityEventSource and the 13 new kinds to ActivityEventKind. Includes regression tests for the 5 MUST emits. Closes #1659 Co-Authored-By: Claude Opus 4.7 * fix: refine plugin activity event classification * fix(openclaw): retry transient unreachable errors before logging * Deduplicate corrupt clone activity events * Deduplicate poll-path activity events --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: whoisasx --- packages/core/src/activity-events.ts | 17 ++ .../plugins/notifier-composio/src/index.ts | 39 +++- .../plugins/notifier-discord/src/index.ts | 12 ++ .../src/activity-events.test.ts | 174 ++++++++++++++++ .../plugins/notifier-openclaw/src/index.ts | 47 ++++- .../plugins/scm-github/src/graphql-batch.ts | 141 ++++++++----- packages/plugins/scm-github/src/index.ts | 158 ++++++++------ .../scm-github/test/activity-events.test.ts | 152 ++++++++++++++ .../plugins/scm-github/test/index.test.ts | 65 +++++- packages/plugins/scm-gitlab/src/index.ts | 50 ++++- .../plugins/scm-gitlab/test/index.test.ts | 80 +++++++- .../src/activity-events.test.ts | 143 +++++++++++++ packages/plugins/tracker-linear/src/index.ts | 71 ++++++- .../src/__tests__/index.test.ts | 92 ++++++++- packages/plugins/workspace-clone/src/index.ts | 59 +++++- .../src/__tests__/activity-events.test.ts | 192 ++++++++++++++++++ .../src/__tests__/index.test.ts | 5 + .../plugins/workspace-worktree/src/index.ts | 64 +++++- 18 files changed, 1398 insertions(+), 163 deletions(-) create mode 100644 packages/plugins/notifier-openclaw/src/activity-events.test.ts create mode 100644 packages/plugins/scm-github/test/activity-events.test.ts create mode 100644 packages/plugins/tracker-linear/src/activity-events.test.ts create mode 100644 packages/plugins/workspace-worktree/src/__tests__/activity-events.test.ts diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index 6065c3d41..0c1072e88 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -20,6 +20,9 @@ export type ActivityEventSource = | "scm" | "runtime" | "agent" + | "tracker" + | "workspace" + | "notifier" | "reaction" | "report-watcher"; @@ -41,6 +44,20 @@ export type ActivityEventKind = | "runtime.probe_failed" | "agent.process_probe_failed" | "agent.activity_probe_failed" + // Plugin-internal failure shapes (issue #1659) + | "scm.gh_unavailable" + | "scm.batch_enrich_pr_failed" + | "scm.ci_summary_failclosed" + | "workspace.post_create_failed" + | "workspace.branch_collision" + | "workspace.destroy_fell_back" + | "workspace.corrupt_clone_skipped" + | "tracker.dep_missing" + | "tracker.api_timeout" + | "notifier.auth_failed" + | "notifier.unreachable" + | "notifier.rate_limited" + | "notifier.dep_missing" // Reaction lifecycle | "reaction.escalated" | "reaction.send_to_agent_failed" diff --git a/packages/plugins/notifier-composio/src/index.ts b/packages/plugins/notifier-composio/src/index.ts index 08bb21c53..d3dd456ab 100644 --- a/packages/plugins/notifier-composio/src/index.ts +++ b/packages/plugins/notifier-composio/src/index.ts @@ -1,12 +1,21 @@ -import type { - PluginModule, - Notifier, - OrchestratorEvent, - NotifyAction, - NotifyContext, - EventPriority, +import { + recordActivityEvent, + type EventPriority, + type Notifier, + type NotifyAction, + type NotifyContext, + type OrchestratorEvent, + type PluginModule, } from "@aoagents/ao-core"; +// Module-level guard so we only emit notifier.dep_missing once per process. +let depMissingEmitted = false; + +/** Test-only: reset the once-per-process dep_missing guard. */ +export function _resetDepMissingEmittedForTesting(): void { + depMissingEmitted = false; +} + export const manifest = { name: "composio", slot: "notifier" as const, @@ -71,6 +80,22 @@ async function loadComposioSDK(apiKey: string): Promise message.includes("MODULE_NOT_FOUND") || code === "ERR_MODULE_NOT_FOUND" ) { + // User-actionable. Emit once per process so RCA can answer + // "why is the composio notifier silent?" without spamming on every notify call. + if (!depMissingEmitted) { + depMissingEmitted = true; + recordActivityEvent({ + source: "notifier", + kind: "notifier.dep_missing", + level: "error", + summary: "Composio SDK (composio-core) is not installed", + data: { + plugin: "notifier-composio", + package: "composio-core", + installHint: "pnpm add composio-core", + }, + }); + } return null; } throw err; diff --git a/packages/plugins/notifier-discord/src/index.ts b/packages/plugins/notifier-discord/src/index.ts index 7134fa2e2..00191239e 100644 --- a/packages/plugins/notifier-discord/src/index.ts +++ b/packages/plugins/notifier-discord/src/index.ts @@ -1,4 +1,5 @@ import { + recordActivityEvent, validateUrl, type PluginModule, type Notifier, @@ -129,6 +130,17 @@ async function postWithRetry( // Rate-limit budget exhausted — fail immediately rather than falling through // to the error retry path (which would compound the two counters). const body = await response.text().catch(() => ""); + recordActivityEvent({ + source: "notifier", + kind: "notifier.rate_limited", + level: "warn", + summary: `Discord webhook rate-limit retry budget exhausted`, + data: { + plugin: "notifier-discord", + status: 429, + rateLimitRetries, + }, + }); lastError = new Error(`Discord webhook rate-limited (HTTP 429)${body ? `: ${body.trim()}` : ""}`); throw lastError; } diff --git a/packages/plugins/notifier-openclaw/src/activity-events.test.ts b/packages/plugins/notifier-openclaw/src/activity-events.test.ts new file mode 100644 index 000000000..da89f5e2f --- /dev/null +++ b/packages/plugins/notifier-openclaw/src/activity-events.test.ts @@ -0,0 +1,174 @@ +/** + * Regression tests for plugin-internal activity events (issue #1659). + * + * Covers notifier.auth_failed (MUST) and notifier.unreachable (SHOULD) — + * the two failure shapes RCA needs to distinguish. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OrchestratorEvent } from "@aoagents/ao-core"; + +const { recordActivityEventMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +import { create } from "./index.js"; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "reaction.escalated", + priority: "urgent", + sessionId: "ao-5", + projectId: "ao", + timestamp: new Date("2026-03-08T12:00:00Z"), + message: "Reaction escalated", + data: {}, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + delete process.env.OPENCLAW_HOOKS_TOKEN; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("notifier.auth_failed (MUST emit)", () => { + it("emits on 401 (distinct from notifier.unreachable on ECONNREFUSED)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(/OpenClaw rejected the auth token/); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "notifier", + kind: "notifier.auth_failed", + level: "error", + sessionId: "ao-5", + data: expect.objectContaining({ + plugin: "notifier-openclaw", + status: 401, + }), + }), + ); + }); + + it("emits on 403", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve("forbidden") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "notifier.auth_failed", + data: expect.objectContaining({ status: 403 }), + }), + ); + }); +}); + +describe("notifier.unreachable (SHOULD emit)", () => { + it.each(["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ENETUNREACH"])( + "emits on %s (distinct from notifier.auth_failed)", + async (code) => { + const fetchMock = vi.fn().mockRejectedValue(new Error(`fetch failed: ${code}`)); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(/Can't reach OpenClaw gateway/); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "notifier", + kind: "notifier.unreachable", + level: "warn", + sessionId: "ao-5", + data: expect.objectContaining({ + plugin: "notifier-openclaw", + errorMessage: expect.stringContaining(code), + }), + }), + ); + + // Critically: should NOT also fire auth_failed — distinct shapes. + const authFailedCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "notifier.auth_failed", + ); + expect(authFailedCalls).toHaveLength(0); + }, + ); + + it.each(["ETIMEDOUT", "ENOTFOUND", "ENETUNREACH"])( + "does not emit on transient %s when a retry succeeds", + async (code) => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error(`fetch failed: ${code}`)) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 1, retryDelayMs: 0 }); + await notifier.notify(makeEvent()); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const unreachableCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "notifier.unreachable", + ); + expect(unreachableCalls).toHaveLength(0); + }, + ); + + it.each(["ETIMEDOUT", "ENOTFOUND", "ENETUNREACH"])( + "emits on transient %s only after retry budget is exhausted", + async (code) => { + const fetchMock = vi.fn().mockRejectedValue(new Error(`fetch failed: ${code}`)); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 1, retryDelayMs: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(/Can't reach OpenClaw gateway/); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const unreachableCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "notifier.unreachable", + ); + expect(unreachableCalls).toHaveLength(1); + expect(unreachableCalls[0]?.[0].data).toMatchObject({ + errorMessage: expect.stringContaining(code), + }); + }, + ); + + it("does not emit unreachable for unrelated network errors", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed: certificate expired")); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(/fetch failed: certificate expired/); + + const unreachableCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "notifier.unreachable", + ); + expect(unreachableCalls).toHaveLength(0); + }); +}); diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts index fcd5ae6cf..ced31e7cf 100644 --- a/packages/plugins/notifier-openclaw/src/index.ts +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -9,6 +9,7 @@ import { type OrchestratorEvent, type PluginModule, getObservabilityBaseDir, + recordActivityEvent, } from "@aoagents/ao-core"; import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@aoagents/ao-core/utils"; @@ -39,6 +40,13 @@ export const manifest = { }; const DEFAULT_TIMEOUT_MS = 10_000; +const UNREACHABLE_NETWORK_ERROR_CODES = [ + "ECONNREFUSED", + "ETIMEDOUT", + "ENOTFOUND", + "ENETUNREACH", +] as const; +type UnreachableNetworkErrorCode = (typeof UNREACHABLE_NETWORK_ERROR_CODES)[number]; type WakeMode = "now" | "next-heartbeat"; @@ -117,6 +125,10 @@ function recordHealthFailure(path: string | null, error: unknown): void { writeHealthSummary(path, summary); } +function getUnreachableNetworkErrorCode(error: Error): UnreachableNetworkErrorCode | undefined { + return UNREACHABLE_NETWORK_ERROR_CODES.find((code) => error.message.includes(code)); +} + async function postWithRetry( url: string, payload: OpenClawWebhookPayload, @@ -130,6 +142,7 @@ async function postWithRetry( for (let attempt = 0; attempt <= retries; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + let shouldRethrowResponseError = false; try { const response = await fetch(url, { method: "POST", @@ -143,17 +156,33 @@ async function postWithRetry( const body = await response.text(); if (response.status === 401 || response.status === 403) { + // User-actionable: distinct from generic 5xx — token expired or wrong. + recordActivityEvent({ + sessionId: context.sessionId, + source: "notifier", + kind: "notifier.auth_failed", + level: "error", + summary: `OpenClaw rejected auth token (HTTP ${response.status})`, + data: { + plugin: "notifier-openclaw", + status: response.status, + url, + fixHint: "ao setup openclaw", + }, + }); lastError = new Error( `OpenClaw rejected the auth token (HTTP ${response.status}).\n` + ` Check that hooks.token in your OpenClaw config matches the token configured for AO.\n` + ` Reconfigure: ao setup openclaw`, ); + shouldRethrowResponseError = true; throw lastError; } lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`); if (!isRetryableHttpStatus(response.status)) { + shouldRethrowResponseError = true; throw lastError; } @@ -163,10 +192,24 @@ async function postWithRetry( ); } } catch (err) { - if (err === lastError) throw err; + if (shouldRethrowResponseError && err === lastError) throw err; lastError = err instanceof Error ? err : new Error(String(err)); - if (lastError.message.includes("ECONNREFUSED")) { + const unreachableCode = getUnreachableNetworkErrorCode(lastError); + if (unreachableCode && (unreachableCode === "ECONNREFUSED" || attempt >= retries)) { + recordActivityEvent({ + sessionId: context.sessionId, + source: "notifier", + kind: "notifier.unreachable", + level: "warn", + summary: `OpenClaw gateway unreachable at ${url}`, + data: { + plugin: "notifier-openclaw", + url, + errorMessage: lastError.message, + fixHint: "openclaw status", + }, + }); throw new Error( `Can't reach OpenClaw gateway at ${url}.\n` + ` Is OpenClaw running? Check: openclaw status\n` + diff --git a/packages/plugins/scm-github/src/graphql-batch.ts b/packages/plugins/scm-github/src/graphql-batch.ts index 108d47168..cf92add29 100644 --- a/packages/plugins/scm-github/src/graphql-batch.ts +++ b/packages/plugins/scm-github/src/graphql-batch.ts @@ -9,6 +9,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { execGhObserved, + recordActivityEvent, type BatchObserver, type CICheck, type CIStatus, @@ -56,10 +57,10 @@ export function setExecGhAsync( * Configuration constants for cache sizing. * LRU cache automatically evicts oldest entries when these limits are reached. */ -const MAX_PR_LIST_ETAGS = 100; // Number of repos to cache -const MAX_COMMIT_STATUS_ETAGS = 500; // Number of commits to cache -const MAX_REVIEW_COMMENTS_ETAGS = 500; // Number of PRs to cache review ETags -const MAX_PR_METADATA = 200; // Number of PRs to cache full data +const MAX_PR_LIST_ETAGS = 100; // Number of repos to cache +const MAX_COMMIT_STATUS_ETAGS = 500; // Number of commits to cache +const MAX_REVIEW_COMMENTS_ETAGS = 500; // Number of PRs to cache review ETags +const MAX_PR_METADATA = 200; // Number of PRs to cache full data /** * ETag cache for REST API endpoints. @@ -125,11 +126,7 @@ export function getPRListETag(owner: string, repo: string): string | undefined { /** * Get commit status ETag for a specific commit. */ -export function getCommitStatusETag( - owner: string, - repo: string, - sha: string, -): string | undefined { +export function getCommitStatusETag(owner: string, repo: string, sha: string): string | undefined { return etagCache.commitStatus.get(`${owner}/${repo}#${sha}`); } @@ -145,12 +142,7 @@ export function setPRListETag(owner: string, repo: string, etag: string): void { * Set commit status ETag for a specific commit. * Exported for testing. */ -export function setCommitStatusETag( - owner: string, - repo: string, - sha: string, - etag: string, -): void { +export function setCommitStatusETag(owner: string, repo: string, sha: string, etag: string): void { etagCache.commitStatus.set(`${owner}/${repo}#${sha}`, etag); } @@ -161,10 +153,9 @@ export function setCommitStatusETag( * * Uses LRU eviction to ensure bounded memory usage. */ -const prMetadataCache = new LRUCache< - string, - { headSha: string | null; ciStatus: CIStatus } ->(MAX_PR_METADATA); +const prMetadataCache = new LRUCache( + MAX_PR_METADATA, +); /** * Cache for full PR enrichment data. @@ -241,7 +232,11 @@ export async function shouldRefreshPREnrichment( } if (repos.size === 0) { - return { shouldRefresh: false, details: ["No repos to check"], prListUnchangedRepos: new Set() }; + return { + shouldRefresh: false, + details: ["No repos to check"], + prListUnchangedRepos: new Set(), + }; } // Guard 1: Check PR list ETag for each repository @@ -297,9 +292,7 @@ export async function shouldRefreshPREnrichment( ); if (statusChanged) { shouldRefresh = true; - details.push( - `CI status changed for ${pr.owner}/${pr.repo}#${pr.number} (Guard 2)`, - ); + details.push(`CI status changed for ${pr.owner}/${pr.repo}#${pr.number} (Guard 2)`); } } } @@ -310,10 +303,7 @@ export async function shouldRefreshPREnrichment( /** * Get cached PR metadata for testing. */ -export function getPRMetadataCache(): Map< - string, - { headSha: string | null; ciStatus: CIStatus } -> { +export function getPRMetadataCache(): Map { return prMetadataCache.toMap(); } @@ -350,6 +340,11 @@ interface ErrorWithCause extends Error { cause?: unknown; } +// Module-level guard so we only emit gh_unavailable once per process. +// The error is system-wide (gh missing globally), not session-specific. +let ghUnavailableEmitted = false; +const batchEnrichPRFailedEmitted = new Set(); + /** * Pre-flight check to verify gh CLI is available and authenticated. * This prevents silent failures during GraphQL batch queries. @@ -357,7 +352,21 @@ interface ErrorWithCause extends Error { async function verifyGhCLI(): Promise { try { await execFileAsync("gh", ["--version"], { timeout: 5000 }); - } catch { + } catch (err) { + if (!ghUnavailableEmitted) { + ghUnavailableEmitted = true; + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "scm", + kind: "scm.gh_unavailable", + level: "error", + summary: "gh CLI not available or not authenticated", + data: { + plugin: "scm-github", + errorMessage, + }, + }); + } const error = new Error( "gh CLI not available or not authenticated. GraphQL batch enrichment requires gh CLI to be installed and configured.", ) as ErrorWithCause; @@ -366,6 +375,16 @@ async function verifyGhCLI(): Promise { } } +/** Test-only: reset the once-per-process gh_unavailable guard. */ +export function _resetGhUnavailableEmittedForTesting(): void { + ghUnavailableEmitted = false; +} + +/** Test-only: reset the once-per-PR batch extraction failure guard. */ +export function _resetBatchEnrichPRFailedEmittedForTesting(): void { + batchEnrichPRFailedEmitted.clear(); +} + /** * Maximum number of PRs to query in a single GraphQL batch. * GitHub has limits on query complexity and we stay well under this limit. @@ -533,7 +552,10 @@ async function checkCommitStatusETag( if (is304(errorMsg)) { return false; } - observer?.log("warn", `[ETag Guard 2] Commit status check failed for ${commitKey}: ${errorMsg}`); + observer?.log( + "warn", + `[ETag Guard 2] Commit status check failed for ${commitKey}: ${errorMsg}`, + ); return true; // Assume changed to be safe } } @@ -596,7 +618,10 @@ export async function checkReviewCommentsETag( if (is304(errorMsg)) { return false; } - observer?.log("warn", `[ETag Guard 3] Review comments check failed for ${cacheKey}: ${errorMsg}`); + observer?.log( + "warn", + `[ETag Guard 3] Review comments check failed for ${cacheKey}: ${errorMsg}`, + ); return true; // Assume changed to be safe } } @@ -704,9 +729,7 @@ export function generateBatchQuery(prs: PRInfo[]): { * * @throws Error if the query fails with GraphQL errors or parsing issues. */ -async function executeBatchQuery( - prs: PRInfo[], -): Promise> { +async function executeBatchQuery(prs: PRInfo[]): Promise> { const { query, variables } = generateBatchQuery(prs); // Handle empty array - no query needed @@ -866,9 +889,7 @@ function parseCheckContexts(contexts: unknown): CICheck[] { * Uses only the top-level aggregate state to determine overall CI status. * Individual check details are parsed separately via parseCheckContexts(). */ -function parseCIState( - statusCheckRollup: unknown, -): CIStatus { +function parseCIState(statusCheckRollup: unknown): CIStatus { if (!statusCheckRollup || typeof statusCheckRollup !== "object") { return "none"; } @@ -885,8 +906,7 @@ function parseCIState( if (state === "PENDING" || state === "EXPECTED") return "pending"; if (state === "TIMED_OUT" || state === "CANCELLED" || state === "ACTION_REQUIRED") return "failing"; - if (state === "QUEUED" || state === "IN_PROGRESS" || state === "WAITING") - return "pending"; + if (state === "QUEUED" || state === "IN_PROGRESS" || state === "WAITING") return "pending"; return "none"; } @@ -927,11 +947,7 @@ function extractPREnrichment( const pr = pullRequest as Record; // Check for at least one required field to validate this is a valid PR object - if ( - pr["state"] === undefined && - pr["title"] === undefined && - pr["commits"] === undefined - ) { + if (pr["state"] === undefined && pr["title"] === undefined && pr["commits"] === undefined) { return null; } @@ -954,9 +970,7 @@ function extractPREnrichment( // Extract merge info const mergeable = pr["mergeable"]; const mergeStateStatus = - typeof pr["mergeStateStatus"] === "string" - ? pr["mergeStateStatus"].toUpperCase() - : ""; + typeof pr["mergeStateStatus"] === "string" ? pr["mergeStateStatus"].toUpperCase() : ""; const hasConflicts = mergeable === "CONFLICTING"; const isBehind = mergeStateStatus === "BEHIND"; @@ -974,9 +988,7 @@ function extractPREnrichment( // contexts(first: 20) silently truncates PRs with >20 checks — when truncated, // the failing check may be missing, so we set ciChecks to undefined to force // the getCIChecks() REST fallback in maybeDispatchCIFailureDetails. - const contextsField = statusCheckRollup?.["contexts"] as - | Record - | undefined; + const contextsField = statusCheckRollup?.["contexts"] as Record | undefined; const pageInfo = contextsField?.["pageInfo"]; const contextsHasNextPage = pageInfo !== null && @@ -984,15 +996,12 @@ function extractPREnrichment( typeof pageInfo === "object" && (pageInfo as Record)["hasNextPage"] === true; const ciChecks = - contextsField && !contextsHasNextPage - ? parseCheckContexts(contextsField) - : undefined; + contextsField && !contextsHasNextPage ? parseCheckContexts(contextsField) : undefined; // Build blockers list const blockers: string[] = []; if (ciStatus === "failing") blockers.push("CI is failing"); - if (reviewDecision === "changes_requested") - blockers.push("Changes requested in review"); + if (reviewDecision === "changes_requested") blockers.push("Changes requested in review"); if (reviewDecision === "pending") blockers.push("Review required"); if (hasConflicts) blockers.push("Merge conflicts"); if (isBehind) blockers.push("Branch is behind base branch"); @@ -1126,6 +1135,25 @@ export async function enrichSessionsPRBatch( result.set(prKey, enrichment); // Update PR metadata cache for future ETag checks updatePRMetadataCache(prKey, enrichment, headSha); + } else { + // GraphQL returned a PR object but extractPREnrichment couldn't + // parse it (missing fields, schema drift). Distinct from the + // whole-batch failure D02 catches further down. + if (!batchEnrichPRFailedEmitted.has(prKey)) { + batchEnrichPRFailedEmitted.add(prKey); + recordActivityEvent({ + source: "scm", + kind: "scm.batch_enrich_pr_failed", + level: "warn", + summary: `batch enrich extraction failed for PR #${pr.number}`, + data: { + plugin: "scm-github", + prNumber: pr.number, + prOwner: pr.owner, + prRepo: pr.repo, + }, + }); + } } } else { // PR not found (deleted/closed/permission issue) @@ -1147,7 +1175,10 @@ export async function enrichSessionsPRBatch( durationMs: batchDuration, }; observer?.recordSuccess(successData); - observer?.log("info", `[GraphQL Batch Success] Batch ${batchIndex + 1}/${batches.length} succeeded: added ${prCountAfter - prCountBefore} PRs to cache (${batchDuration}ms)`); + observer?.log( + "info", + `[GraphQL Batch Success] Batch ${batchIndex + 1}/${batches.length} succeeded: added ${prCountAfter - prCountBefore} PRs to cache (${batchDuration}ms)`, + ); } } catch (err) { // Calculate duration even on failure diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 8d065db01..fa59cecdc 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -11,6 +11,7 @@ import { CI_STATUS, execGhObserved, memoizeAsync, + recordActivityEvent, type PluginModule, type PreflightContext, type SCM, @@ -62,6 +63,12 @@ const BOT_AUTHORS = new Set([ ]); const CI_FAILURE_LOG_TAIL_LINES = 120; +const ciSummaryFailClosedEmitted = new Set(); + +/** Test-only: reset once-per-PR activity event guards. */ +export function _resetGitHubActivityEventDedupeForTesting(): void { + ciSummaryFailClosedEmitted.clear(); +} // --------------------------------------------------------------------------- // Helpers @@ -237,10 +244,7 @@ async function getFailedJobLog( ]); } catch (err) { if (!runReference.jobId) throw err; - return gh([ - "api", - `repos/${pr.owner}/${pr.repo}/actions/jobs/${runReference.jobId}/logs`, - ]); + return gh(["api", `repos/${pr.owner}/${pr.repo}/actions/jobs/${runReference.jobId}/logs`]); } } @@ -522,6 +526,10 @@ function repoFlag(pr: PRInfo): string { return `${pr.owner}/${pr.repo}`; } +function prEventKey(pr: PRInfo): string { + return `${repoFlag(pr)}#${pr.number}`; +} + function parseDate(val: string | undefined | null): Date { if (!val) return new Date(0); const d = new Date(val); @@ -932,7 +940,7 @@ function createGitHubSCM(): SCM { let checks: CICheck[]; try { checks = await this.getCIChecks(pr); - } catch { + } catch (err) { // Before fail-closing, check if the PR is merged/closed — // GitHub may not return check data for those, and reporting // "failing" for a merged PR is wrong. @@ -943,7 +951,26 @@ function createGitHubSCM(): SCM { // Can't determine state either; fall through to fail-closed. } // Fail closed for open PRs: report as failing rather than - // "none" (which getMergeability treats as passing). + // "none" (which getMergeability treats as passing). Emit so RCA + // can distinguish "really failing" from "we couldn't tell". + const eventKey = prEventKey(pr); + if (!ciSummaryFailClosedEmitted.has(eventKey)) { + ciSummaryFailClosedEmitted.add(eventKey); + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "scm", + kind: "scm.ci_summary_failclosed", + level: "warn", + summary: `getCISummary failed-closed for PR #${pr.number}`, + data: { + plugin: "scm-github", + prNumber: pr.number, + prOwner: pr.owner, + prRepo: pr.repo, + errorMessage, + }, + }); + } return "failing"; } if (checks.length === 0) return "none"; @@ -1035,16 +1062,16 @@ function createGitHubSCM(): SCM { try { // Use GraphQL with variables to get review threads with actual isResolved status const raw = await gh([ - "api", - "graphql", - "-f", - `owner=${pr.owner}`, - "-f", - `name=${pr.repo}`, - "-F", - `number=${pr.number}`, - "-f", - `query=query($owner: String!, $name: String!, $number: Int!) { + "api", + "graphql", + "-f", + `owner=${pr.owner}`, + "-f", + `name=${pr.repo}`, + "-F", + `number=${pr.number}`, + "-f", + `query=query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100) { @@ -1067,58 +1094,58 @@ function createGitHubSCM(): SCM { } } }`, - ]); + ]); - const data: { - data: { - repository: { - pullRequest: { - reviewThreads: { - nodes: Array<{ - id: string; - isResolved: boolean; - comments: { - nodes: Array<{ - id: string; - author: { login: string } | null; - body: string; - path: string | null; - line: number | null; - url: string; - createdAt: string; - }>; - }; - }>; + const data: { + data: { + repository: { + pullRequest: { + reviewThreads: { + nodes: Array<{ + id: string; + isResolved: boolean; + comments: { + nodes: Array<{ + id: string; + author: { login: string } | null; + body: string; + path: string | null; + line: number | null; + url: string; + createdAt: string; + }>; + }; + }>; + }; }; }; }; - }; - } = JSON.parse(raw); + } = JSON.parse(raw); - const threads = data.data.repository.pullRequest.reviewThreads.nodes; + const threads = data.data.repository.pullRequest.reviewThreads.nodes; - return threads - .filter((t) => { - if (t.isResolved) return false; // only pending (unresolved) threads - const c = t.comments.nodes[0]; - if (!c) return false; // skip threads with no comments - const author = c.author?.login ?? ""; - return !BOT_AUTHORS.has(author); - }) - .map((t) => { - const c = t.comments.nodes[0]; - return { - id: c.id, - threadId: t.id, - author: c.author?.login ?? "unknown", - body: c.body, - path: c.path || undefined, - line: c.line ?? undefined, - isResolved: t.isResolved, - createdAt: parseDate(c.createdAt), - url: c.url, - }; - }); + return threads + .filter((t) => { + if (t.isResolved) return false; // only pending (unresolved) threads + const c = t.comments.nodes[0]; + if (!c) return false; // skip threads with no comments + const author = c.author?.login ?? ""; + return !BOT_AUTHORS.has(author); + }) + .map((t) => { + const c = t.comments.nodes[0]; + return { + id: c.id, + threadId: t.id, + author: c.author?.login ?? "unknown", + body: c.body, + path: c.path || undefined, + line: c.line ?? undefined, + isResolved: t.isResolved, + createdAt: parseDate(c.createdAt), + url: c.url, + }; + }); } catch (err) { throw new Error("Failed to fetch pending comments", { cause: err }); } @@ -1129,7 +1156,12 @@ function createGitHubSCM(): SCM { const cacheKey = `${pr.owner}/${pr.repo}#${pr.number}`; // Guard 3: check if review comments changed via REST ETag - const reviewsChanged = await checkReviewCommentsETag(pr.owner, pr.repo, pr.number, instanceObserver); + const reviewsChanged = await checkReviewCommentsETag( + pr.owner, + pr.repo, + pr.number, + instanceObserver, + ); if (!reviewsChanged) { const cached = reviewThreadsCache.get(cacheKey); if (cached) return cached; diff --git a/packages/plugins/scm-github/test/activity-events.test.ts b/packages/plugins/scm-github/test/activity-events.test.ts new file mode 100644 index 000000000..ad54976df --- /dev/null +++ b/packages/plugins/scm-github/test/activity-events.test.ts @@ -0,0 +1,152 @@ +/** + * Regression tests for plugin-internal activity events (issue #1659). + * + * Covers scm.gh_unavailable (MUST emit, deduped once-per-process). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { recordActivityEventMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +import { + enrichSessionsPRBatch, + setExecFileAsync, + setExecGhAsync, + clearETagCache, + clearPRMetadataCache, + _resetGhUnavailableEmittedForTesting, + _resetBatchEnrichPRFailedEmittedForTesting, +} from "../src/graphql-batch.js"; +import type { PRInfo } from "@aoagents/ao-core"; + +const samplePRs: PRInfo[] = [ + { + owner: "octocat", + repo: "hello-world", + number: 42, + url: "https://github.com/octocat/hello-world/pull/42", + title: "Add new feature", + branch: "feature/new", + baseBranch: "main", + isDraft: false, + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + clearETagCache(); + clearPRMetadataCache(); + _resetGhUnavailableEmittedForTesting(); + _resetBatchEnrichPRFailedEmittedForTesting(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("scm.gh_unavailable (MUST emit)", () => { + it("emits when verifyGhCLI fails because gh is missing/unauthenticated", async () => { + const execFileMock = vi.fn().mockImplementation((file: string) => { + if (file === "gh") { + const err = new Error("spawn gh ENOENT") as Error & { code?: string }; + err.code = "ENOENT"; + return Promise.reject(err); + } + return Promise.resolve({ stdout: "", stderr: "" }); + }); + setExecFileAsync(execFileMock as unknown as Parameters[0]); + + // The batch-level try/catch swallows verifyGhCLI's throw — but the event + // fires before the throw, which is what we care about for RCA. + const result = await enrichSessionsPRBatch(samplePRs); + expect(result.enrichment.size).toBe(0); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "scm", + kind: "scm.gh_unavailable", + level: "error", + data: expect.objectContaining({ + plugin: "scm-github", + errorMessage: expect.any(String), + }), + }), + ); + }); + + it("emits exactly once across multiple gh-missing failures (deduped per-process)", async () => { + const execFileMock = vi.fn().mockImplementation((file: string) => { + if (file === "gh") { + return Promise.reject(new Error("gh ENOENT")); + } + return Promise.resolve({ stdout: "", stderr: "" }); + }); + setExecFileAsync(execFileMock as unknown as Parameters[0]); + + await enrichSessionsPRBatch(samplePRs); + await enrichSessionsPRBatch(samplePRs); + await enrichSessionsPRBatch(samplePRs); + + const ghUnavailableCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.gh_unavailable", + ); + expect(ghUnavailableCalls).toHaveLength(1); + }); +}); + +describe("scm.batch_enrich_pr_failed (poll-path emit)", () => { + it("emits exactly once per PR across repeated extraction failures", async () => { + const execFileMock = vi.fn().mockResolvedValue({ stdout: "gh version", stderr: "" }); + setExecFileAsync(execFileMock as unknown as Parameters[0]); + + const execGhMock = vi.fn( + async (_args: string[], _timeout: number, operation: string): Promise => { + if (operation === "gh.api.guard-pr-list") { + return 'HTTP/2 200 OK\netag: W/"pr-list"\n\n[]'; + } + if (operation === "gh.api.graphql-batch") { + return `HTTP/2 200 OK\n\n${JSON.stringify({ + data: { + pr0: { + pullRequest: { unexpectedShape: true }, + }, + }, + })}`; + } + throw new Error(`Unexpected gh operation: ${operation}`); + }, + ); + setExecGhAsync(execGhMock); + + await enrichSessionsPRBatch(samplePRs); + await enrichSessionsPRBatch(samplePRs); + + const extractionFailureCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.batch_enrich_pr_failed", + ); + expect(extractionFailureCalls).toHaveLength(1); + expect(extractionFailureCalls[0]?.[0]).toEqual( + expect.objectContaining({ + source: "scm", + kind: "scm.batch_enrich_pr_failed", + level: "warn", + data: expect.objectContaining({ + plugin: "scm-github", + prNumber: 42, + prOwner: "octocat", + prRepo: "hello-world", + }), + }), + ); + }); +}); diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index 501ff4e09..7fee69578 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -4,7 +4,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; // Mock node:child_process — gh CLI calls go through execFileAsync = promisify(execFile) // vi.hoisted ensures the mock fn is available when vi.mock factory runs (hoisted above imports) // --------------------------------------------------------------------------- -const { ghMock } = vi.hoisted(() => ({ ghMock: vi.fn() })); +const { ghMock, recordActivityEventMock } = vi.hoisted(() => ({ + ghMock: vi.fn(), + recordActivityEventMock: vi.fn(), +})); vi.mock("node:child_process", () => { // Attach the custom promisify symbol so `promisify(execFile)` returns ghMock @@ -14,8 +17,24 @@ vi.mock("node:child_process", () => { return { execFile }; }); -import { create, manifest } from "../src/index.js"; -import { _clearProcessCacheForTests, createActivitySignal, type PreflightContext, type PRInfo, type SCMWebhookRequest, type Session, type ProjectConfig } from "@aoagents/ao-core"; +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +import { create, manifest, _resetGitHubActivityEventDedupeForTesting } from "../src/index.js"; +import { + _clearProcessCacheForTests, + createActivitySignal, + type PreflightContext, + type PRInfo, + type SCMWebhookRequest, + type Session, + type ProjectConfig, +} from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Fixtures @@ -101,6 +120,7 @@ describe("scm-github plugin", () => { beforeEach(() => { vi.clearAllMocks(); + _resetGitHubActivityEventDedupeForTesting(); scm = create(); delete process.env["GITHUB_WEBHOOK_SECRET"]; }); @@ -665,13 +685,10 @@ describe("scm-github plugin", () => { url: "https://github.com/acme/repo/actions/runs/124/job/457", }, ]; - const logLines = Array.from( - { length: 125 }, - (_, index) => { - const step = index < 100 ? "Install dependencies" : "Run pnpm test"; - return `build\t${step}\t2026-05-12T00:00:00Z line ${index + 1}`; - }, - ); + const logLines = Array.from({ length: 125 }, (_, index) => { + const step = index < 100 ? "Install dependencies" : "Run pnpm test"; + return `build\t${step}\t2026-05-12T00:00:00Z line ${index + 1}`; + }); mockGhRaw(logLines.join("\n")); const summary = await scm.getCIFailureSummary?.(pr, checks); @@ -774,6 +791,34 @@ describe("scm-github plugin", () => { expect(await scm.getCISummary(pr)).toBe("failing"); }); + it("dedupes fail-closed activity events per PR", async () => { + mockGhError("checks failed"); + mockGhError("state failed"); + mockGhError("checks failed again"); + mockGhError("state failed again"); + + expect(await scm.getCISummary(pr)).toBe("failing"); + expect(await scm.getCISummary(pr)).toBe("failing"); + + const failClosedCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.ci_summary_failclosed", + ); + expect(failClosedCalls).toHaveLength(1); + expect(failClosedCalls[0]?.[0]).toEqual( + expect.objectContaining({ + source: "scm", + kind: "scm.ci_summary_failclosed", + level: "warn", + data: expect.objectContaining({ + plugin: "scm-github", + prNumber: 42, + prOwner: "acme", + prRepo: "repo", + }), + }), + ); + }); + it('returns "none" when all checks are skipped', async () => { mockGh([ { name: "a", state: "SKIPPED" }, diff --git a/packages/plugins/scm-gitlab/src/index.ts b/packages/plugins/scm-gitlab/src/index.ts index df9511244..31402653a 100644 --- a/packages/plugins/scm-gitlab/src/index.ts +++ b/packages/plugins/scm-gitlab/src/index.ts @@ -7,6 +7,7 @@ import { createHash, timingSafeEqual } from "node:crypto"; import { CI_STATUS, + recordActivityEvent, type PluginModule, type SCM, type SCMWebhookEvent, @@ -45,6 +46,14 @@ const BOT_AUTHORS = new Set([ "sonarcloud[bot]", "snyk-bot", ]); +const ciSummaryFailClosedEmitted = new Set(); +const reviewFetchFailedEmitted = new Set(); + +/** Test-only: reset once-per-PR activity event guards. */ +export function _resetGitLabActivityEventDedupeForTesting(): void { + ciSummaryFailClosedEmitted.clear(); + reviewFetchFailedEmitted.clear(); +} function isBot(username: string): boolean { return ( @@ -60,6 +69,10 @@ function repoFlag(pr: PRInfo): string { return `${pr.owner}/${pr.repo}`; } +function prEventKey(pr: PRInfo): string { + return `${repoFlag(pr)}#${pr.number}`; +} + function parseDate(val: string | undefined | null): Date { if (!val) return new Date(0); const d = new Date(val); @@ -106,7 +119,6 @@ function mapPRState(state: string): PRState { return "open"; } - function getGitLabWebhookConfig(project: ProjectConfig) { const webhook = project.scm?.webhook; return { @@ -585,6 +597,24 @@ function createGitLabSCM(config?: Record): SCM { `getCISummary: PR state fallback also failed for MR !${pr.number}: ${(innerErr as Error).message}`, ); } + const eventKey = prEventKey(pr); + if (!ciSummaryFailClosedEmitted.has(eventKey)) { + ciSummaryFailClosedEmitted.add(eventKey); + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "scm", + kind: "scm.ci_summary_failclosed", + level: "warn", + summary: `getCISummary failed-closed for MR !${pr.number}`, + data: { + plugin: "scm-gitlab", + prNumber: pr.number, + prOwner: pr.owner, + prRepo: pr.repo, + errorMessage, + }, + }); + } return "failing"; } if (checks.length === 0) return "none"; @@ -642,6 +672,24 @@ function createGitLabSCM(config?: Record): SCM { console.warn( `getReviews: discussions fetch failed for MR !${pr.number}: ${(err as Error).message}`, ); + const eventKey = prEventKey(pr); + if (!reviewFetchFailedEmitted.has(eventKey)) { + reviewFetchFailedEmitted.add(eventKey); + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "scm", + kind: "scm.review_fetch_failed", + level: "warn", + summary: `getReviews discussions fetch failed for MR !${pr.number}`, + data: { + plugin: "scm-gitlab", + prNumber: pr.number, + prOwner: pr.owner, + prRepo: pr.repo, + errorMessage, + }, + }); + } } return reviews; diff --git a/packages/plugins/scm-gitlab/test/index.test.ts b/packages/plugins/scm-gitlab/test/index.test.ts index 9dad4d0eb..c72d93ee0 100644 --- a/packages/plugins/scm-gitlab/test/index.test.ts +++ b/packages/plugins/scm-gitlab/test/index.test.ts @@ -3,7 +3,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // --------------------------------------------------------------------------- // Mock node:child_process — glab CLI calls go through execFileAsync // --------------------------------------------------------------------------- -const { glabMock } = vi.hoisted(() => ({ glabMock: vi.fn() })); +const { glabMock, recordActivityEventMock } = vi.hoisted(() => ({ + glabMock: vi.fn(), + recordActivityEventMock: vi.fn(), +})); vi.mock("node:child_process", () => { const execFile = Object.assign(vi.fn(), { @@ -12,8 +15,22 @@ vi.mock("node:child_process", () => { return { execFile }; }); -import { create, manifest } from "../src/index.js"; -import { createActivitySignal, type PRInfo, type Session, type ProjectConfig, type SCMWebhookRequest } from "@aoagents/ao-core"; +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +import { create, manifest, _resetGitLabActivityEventDedupeForTesting } from "../src/index.js"; +import { + createActivitySignal, + type PRInfo, + type Session, + type ProjectConfig, + type SCMWebhookRequest, +} from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Fixtures @@ -104,6 +121,7 @@ describe("scm-gitlab plugin", () => { beforeEach(() => { vi.clearAllMocks(); + _resetGitLabActivityEventDedupeForTesting(); warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); scm = create(); delete process.env["GITLAB_WEBHOOK_SECRET"]; @@ -701,6 +719,34 @@ describe("scm-gitlab plugin", () => { expect(await scm.getCISummary(pr)).toBe("failing"); }); + it("dedupes fail-closed activity events per MR", async () => { + mockGlabError("pipeline error"); + mockGlabError("network error"); + mockGlabError("pipeline error again"); + mockGlabError("network error again"); + + expect(await scm.getCISummary(pr)).toBe("failing"); + expect(await scm.getCISummary(pr)).toBe("failing"); + + const failClosedCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.ci_summary_failclosed", + ); + expect(failClosedCalls).toHaveLength(1); + expect(failClosedCalls[0]?.[0]).toEqual( + expect.objectContaining({ + source: "scm", + kind: "scm.ci_summary_failclosed", + level: "warn", + data: expect.objectContaining({ + plugin: "scm-gitlab", + prNumber: 42, + prOwner: "acme", + prRepo: "repo", + }), + }), + ); + }); + it('returns "none" when all jobs are skipped', async () => { mockGlab([{ id: 1 }]); mockGlab([ @@ -807,6 +853,34 @@ describe("scm-gitlab plugin", () => { ); }); + it("dedupes review fetch failed activity events per MR", async () => { + mockGlab({ approved_by: [] }); + mockGlabError("discussions fetch failed"); + mockGlab({ approved_by: [] }); + mockGlabError("discussions fetch failed again"); + + expect(await scm.getReviews(pr)).toEqual([]); + expect(await scm.getReviews(pr)).toEqual([]); + + const reviewFetchCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.review_fetch_failed", + ); + expect(reviewFetchCalls).toHaveLength(1); + expect(reviewFetchCalls[0]?.[0]).toEqual( + expect.objectContaining({ + source: "scm", + kind: "scm.review_fetch_failed", + level: "warn", + data: expect.objectContaining({ + plugin: "scm-gitlab", + prNumber: 42, + prOwner: "acme", + prRepo: "repo", + }), + }), + ); + }); + it("filters bot authors from discussions", async () => { mockGlab({ approved_by: [] }); mockGlab([ diff --git a/packages/plugins/tracker-linear/src/activity-events.test.ts b/packages/plugins/tracker-linear/src/activity-events.test.ts new file mode 100644 index 000000000..06d552ee4 --- /dev/null +++ b/packages/plugins/tracker-linear/src/activity-events.test.ts @@ -0,0 +1,143 @@ +/** + * Regression tests for plugin-internal activity events (issue #1659). + * + * Covers tracker.dep_missing (MUST emit, deduped once-per-process). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { recordActivityEventMock, requestMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), + requestMock: vi.fn(), +})); + +vi.mock("node:https", () => ({ + request: requestMock, +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +// @composio/core is intentionally not installed — the real dynamic import +// will fail with ERR_MODULE_NOT_FOUND, which is exactly the dep_missing +// shape we want to exercise. + +import { create, _resetDepMissingEmittedForTesting } from "./index.js"; +import type { ProjectConfig } from "@aoagents/ao-core"; + +beforeEach(() => { + vi.clearAllMocks(); + recordActivityEventMock.mockReset(); + requestMock.mockReset(); + _resetDepMissingEmittedForTesting(); + process.env.COMPOSIO_API_KEY = "test-key"; + process.env.COMPOSIO_ENTITY_ID = "test-entity"; + delete process.env.LINEAR_API_KEY; +}); + +afterEach(() => { + delete process.env.COMPOSIO_API_KEY; + delete process.env.COMPOSIO_ENTITY_ID; + delete process.env.LINEAR_API_KEY; + vi.useRealTimers(); +}); + +function makeProject(): ProjectConfig { + return { + name: "test-project", + repo: "test/repo", + path: "/repo/path", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { teamId: "TEAM-1" }, + }; +} + +describe("tracker.dep_missing (MUST emit)", () => { + it("emits when Composio SDK is not installed", async () => { + const tracker = create(); + + // Any tracker call routes through the composio transport, which will + // fail to load the missing SDK on first use. + await expect(tracker.getIssue("TEST-1", makeProject())).rejects.toThrow( + /Composio SDK.*not installed/, + ); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "tracker", + kind: "tracker.dep_missing", + level: "error", + data: expect.objectContaining({ + plugin: "tracker-linear", + package: "@composio/core", + }), + }), + ); + }); + + it("emits exactly once across multiple calls (deduped per-process)", async () => { + const tracker = create(); + + await expect(tracker.getIssue("TEST-1", makeProject())).rejects.toThrow(); + await expect(tracker.getIssue("TEST-2", makeProject())).rejects.toThrow(); + await expect(tracker.getIssue("TEST-3", makeProject())).rejects.toThrow(); + + const depMissingCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "tracker.dep_missing", + ); + expect(depMissingCalls).toHaveLength(1); + }); +}); + +describe("tracker.api_timeout", () => { + it("rejects direct transport timeouts even when activity logging throws", async () => { + delete process.env.COMPOSIO_API_KEY; + delete process.env.COMPOSIO_ENTITY_ID; + process.env.LINEAR_API_KEY = "linear-key"; + vi.useFakeTimers(); + + const req = { + setTimeout: vi.fn(), + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }; + req.setTimeout.mockImplementation((_timeoutMs: number, cb: () => void) => { + setTimeout(cb, 0); + return req; + }); + req.on.mockReturnValue(req); + requestMock.mockReturnValue(req); + recordActivityEventMock.mockImplementationOnce(() => { + throw new Error("activity sink failed"); + }); + + const tracker = create(); + const timeoutExpectation = expect(tracker.getIssue("TEST-1", makeProject())).rejects.toThrow( + "Linear API request timed out after 30s", + ); + + await vi.runAllTimersAsync(); + await timeoutExpectation; + expect(req.destroy).toHaveBeenCalled(); + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "tracker", + kind: "tracker.api_timeout", + level: "warn", + data: expect.objectContaining({ + plugin: "tracker-linear", + transport: "direct", + timeoutMs: 30_000, + }), + }), + ); + }); +}); diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index fd93e3eb8..6101ac1c3 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -9,17 +9,35 @@ */ import { request } from "node:https"; -import type { - PluginModule, - Tracker, - Issue, - IssueFilters, - IssueUpdate, - CreateIssueInput, - ProjectConfig, +import { + recordActivityEvent, + type CreateIssueInput, + type Issue, + type IssueFilters, + type IssueUpdate, + type PluginModule, + type ProjectConfig, + type Tracker, } from "@aoagents/ao-core"; import type { Composio } from "@composio/core"; +// Module-level guard so we only emit tracker.dep_missing once per process +// even if multiple sessions trigger the missing-SDK path. +let depMissingEmitted = false; + +/** Test-only: reset the once-per-process dep_missing guard. */ +export function _resetDepMissingEmittedForTesting(): void { + depMissingEmitted = false; +} + +function recordTransportActivityEvent(event: Parameters[0]): void { + try { + recordActivityEvent(event); + } catch { + // Activity logging must never prevent timeout promises from settling. + } +} + // --------------------------------------------------------------------------- // Transport abstraction // --------------------------------------------------------------------------- @@ -111,6 +129,17 @@ function createDirectTransport(): GraphQLTransport { req.setTimeout(30_000, () => { settle(() => { req.destroy(); + recordTransportActivityEvent({ + source: "tracker", + kind: "tracker.api_timeout", + level: "warn", + summary: "Linear API request timed out after 30s", + data: { + plugin: "tracker-linear", + transport: "direct", + timeoutMs: 30_000, + }, + }); reject(new Error("Linear API request timed out after 30s")); }); }); @@ -147,6 +176,21 @@ function createComposioTransport(apiKey: string, entityId: string): GraphQLTrans msg.includes("Cannot find package") || msg.includes("ERR_MODULE_NOT_FOUND") ) { + // User-actionable, system-wide. Emit once per process. + if (!depMissingEmitted) { + depMissingEmitted = true; + recordActivityEvent({ + source: "tracker", + kind: "tracker.dep_missing", + level: "error", + summary: "Composio SDK (@composio/core) is not installed", + data: { + plugin: "tracker-linear", + package: "@composio/core", + installHint: "pnpm add @composio/core", + }, + }); + } throw new Error( "Composio SDK (@composio/core) is not installed. " + "Install it with: pnpm add @composio/core", @@ -175,6 +219,17 @@ function createComposioTransport(apiKey: string, entityId: string): GraphQLTrans let timer: ReturnType | undefined; const timeoutPromise = new Promise((_resolve, reject) => { timer = setTimeout(() => { + recordTransportActivityEvent({ + source: "tracker", + kind: "tracker.api_timeout", + level: "warn", + summary: "Composio Linear API request timed out after 30s", + data: { + plugin: "tracker-linear", + transport: "composio", + timeoutMs: 30_000, + }, + }); reject(new Error("Composio Linear API request timed out after 30s")); }, 30_000); }); diff --git a/packages/plugins/workspace-clone/src/__tests__/index.test.ts b/packages/plugins/workspace-clone/src/__tests__/index.test.ts index 2b194ccd3..0c2b1da45 100644 --- a/packages/plugins/workspace-clone/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-clone/src/__tests__/index.test.ts @@ -3,6 +3,20 @@ import * as childProcess from "node:child_process"; import * as fs from "node:fs"; import type { ProjectConfig } from "@aoagents/ao-core"; +const { getShellMock, recordActivityEventMock } = vi.hoisted(() => ({ + getShellMock: vi.fn(() => ({ cmd: "sh", args: (c: string) => ["-c", c] })), + recordActivityEventMock: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + getShell: getShellMock, + recordActivityEvent: recordActivityEventMock, + }; +}); + // Mock node:child_process with custom promisify support vi.mock("node:child_process", () => { const mockExecFile = vi.fn(); @@ -10,10 +24,6 @@ vi.mock("node:child_process", () => { return { execFile: mockExecFile }; }); -vi.mock("@aoagents/ao-core", () => ({ - getShell: vi.fn(() => ({ cmd: "sh", args: (c: string) => ["-c", c] })), -})); - // Mock node:fs vi.mock("node:fs", () => ({ existsSync: vi.fn(), @@ -267,9 +277,48 @@ describe("workspace.create()", () => { cwd: "/mock-home/.ao-clones/proj/sess", }); + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "workspace", + kind: "workspace.branch_collision", + level: "warn", + projectId: "proj", + sessionId: "sess", + data: expect.objectContaining({ + plugin: "workspace-clone", + branch: "feat/existing", + errorMessage: expect.stringContaining("already exists"), + }), + }), + ); expect(info.branch).toBe("feat/existing"); }); + it("does not emit branch_collision when checkout -b fails for a non-collision reason", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + // git checkout -b fails for a non-collision reason + mockGitError("fatal: cannot lock ref 'refs/heads/feat/locked': Permission denied"); + // git checkout (plain) succeeds + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/locked", + project: makeProject(), + }); + + expect(info.branch).toBe("feat/locked"); + const branchCollisionCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "workspace.branch_collision", + ); + expect(branchCollisionCalls).toHaveLength(0); + }); + it("cleans up partial clone on clone failure", async () => { const workspace = create(); @@ -588,6 +637,41 @@ describe("workspace.list()", () => { warnSpy.mockRestore(); }); + it("emits corrupt_clone_skipped only once per clone path", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + (fs.readdirSync as ReturnType).mockReturnValue([ + { name: "corrupt-repeat", isDirectory: () => true }, + ]); + + mockGitError("fatal: not a git repository"); + mockGitError("fatal: not a git repository"); + + await expect(workspace.list("myproject")).resolves.toEqual([]); + await expect(workspace.list("myproject")).resolves.toEqual([]); + + const corruptCloneCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "workspace.corrupt_clone_skipped", + ); + expect(corruptCloneCalls).toHaveLength(1); + expect(corruptCloneCalls[0][0]).toEqual( + expect.objectContaining({ + projectId: "myproject", + sessionId: "corrupt-repeat", + source: "workspace", + kind: "workspace.corrupt_clone_skipped", + data: expect.objectContaining({ + clonePath: "/mock-home/.ao-clones/myproject/corrupt-repeat", + }), + }), + ); + + warnSpy.mockRestore(); + }); + it("rejects invalid projectId with special characters", async () => { const workspace = create(); diff --git a/packages/plugins/workspace-clone/src/index.ts b/packages/plugins/workspace-clone/src/index.ts index 075fc3119..71100644a 100644 --- a/packages/plugins/workspace-clone/src/index.ts +++ b/packages/plugins/workspace-clone/src/index.ts @@ -3,7 +3,15 @@ import { promisify } from "node:util"; import { existsSync, rmSync, mkdirSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { getShell, type PluginModule, type Workspace, type WorkspaceCreateConfig, type WorkspaceInfo, type ProjectConfig } from "@aoagents/ao-core"; +import { + getShell, + recordActivityEvent, + type PluginModule, + type ProjectConfig, + type Workspace, + type WorkspaceCreateConfig, + type WorkspaceInfo, +} from "@aoagents/ao-core"; const execFileAsync = promisify(execFile); @@ -37,6 +45,16 @@ function expandPath(p: string): string { return p; } +function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isBranchAlreadyExistsError(err: unknown): boolean { + return getErrorMessage(err).toLowerCase().includes("already exists"); +} + +const emittedCorruptClonePaths = new Set(); + export function create(config?: Record): Workspace { const cloneBaseDir = config?.cloneDir ? expandPath(config.cloneDir as string) @@ -97,8 +115,24 @@ export function create(config?: Record): Workspace { // Create and checkout the feature branch try { await git(clonePath, "checkout", "-b", cfg.branch); - } catch { - // Branch may exist on remote — try plain checkout + } catch (branchErr: unknown) { + // Branch may exist on remote — try plain checkout, but only label it + // as a branch_collision when git reported the distinct collision shape. + if (isBranchAlreadyExistsError(branchErr)) { + recordActivityEvent({ + projectId: cfg.projectId, + sessionId: cfg.sessionId, + source: "workspace", + kind: "workspace.branch_collision", + level: "warn", + summary: `branch "${cfg.branch}" already exists; falling back to checkout`, + data: { + plugin: "workspace-clone", + branch: cfg.branch, + errorMessage: getErrorMessage(branchErr), + }, + }); + } try { await git(clonePath, "checkout", cfg.branch); } catch (checkoutErr: unknown) { @@ -142,10 +176,27 @@ export function create(config?: Record): Workspace { try { branch = await git(clonePath, "branch", "--show-current"); } catch (err: unknown) { - // Warn about corrupted clones instead of silently skipping + // Warn about corrupted clones instead of silently skipping. + // RCA: "session shows up on disk but isn't returned by list()". const msg = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console -- expected diagnostic for corrupted clones console.warn(`[workspace-clone] Skipping "${entry.name}": not a valid git repo (${msg})`); + if (!emittedCorruptClonePaths.has(clonePath)) { + emittedCorruptClonePaths.add(clonePath); + recordActivityEvent({ + projectId, + sessionId: entry.name, + source: "workspace", + kind: "workspace.corrupt_clone_skipped", + level: "warn", + summary: `skipped corrupt clone "${entry.name}"`, + data: { + plugin: "workspace-clone", + clonePath, + errorMessage: msg, + }, + }); + } continue; } diff --git a/packages/plugins/workspace-worktree/src/__tests__/activity-events.test.ts b/packages/plugins/workspace-worktree/src/__tests__/activity-events.test.ts new file mode 100644 index 000000000..3a2f6039c --- /dev/null +++ b/packages/plugins/workspace-worktree/src/__tests__/activity-events.test.ts @@ -0,0 +1,192 @@ +/** + * Regression tests for plugin-internal activity events (issue #1659). + * + * Covers the MUST emit: workspace.post_create_failed, plus the SHOULDs + * workspace.branch_collision and workspace.destroy_fell_back. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks — declared before any import that uses the mocked modules +// --------------------------------------------------------------------------- + +const { recordActivityEventMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +vi.mock("node:child_process", () => { + const mockExecFile = vi.fn(); + (mockExecFile as unknown as Record)[Symbol.for("nodejs.util.promisify.custom")] = + vi.fn(); + return { execFile: mockExecFile }; +}); + +vi.mock("node:fs", () => ({ + cpSync: vi.fn(), + existsSync: vi.fn(() => false), + linkSync: vi.fn(), + lstatSync: vi.fn(), + statSync: vi.fn(), + symlinkSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), +})); + +vi.mock("node:os", () => ({ homedir: () => "/mock-home" })); + +import * as childProcess from "node:child_process"; +import { create } from "../index.js"; +import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@aoagents/ao-core/types"; + +const mockExecFileAsync = (childProcess.execFile as unknown as Record)[ + Symbol.for("nodejs.util.promisify.custom") +] as ReturnType; + +function makeProject(overrides?: Partial): ProjectConfig { + return { + name: "test-project", + repo: "test/repo", + path: "/repo/path", + defaultBranch: "main", + sessionPrefix: "test", + ...overrides, + }; +} + +function makeCreateConfig(overrides?: Partial): WorkspaceCreateConfig { + return { + projectId: "myproject", + project: makeProject(), + sessionId: "session-1", + branch: "feat/TEST-1", + ...overrides, + }; +} + +function makeWorkspaceInfo(): WorkspaceInfo { + return { + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("workspace.post_create_failed (MUST emit)", () => { + it("emits when a postCreate command fails, then rethrows", async () => { + const ws = create(); + const project = makeProject({ postCreate: ["pnpm install"] }); + + mockExecFileAsync.mockRejectedValueOnce(new Error("Command failed: exit 127")); + + await expect(ws.postCreate!(makeWorkspaceInfo(), project)).rejects.toThrow( + "Command failed: exit 127", + ); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "workspace", + kind: "workspace.post_create_failed", + level: "error", + sessionId: "session-1", + projectId: "myproject", + data: expect.objectContaining({ + plugin: "workspace-worktree", + command: "pnpm install", + errorMessage: expect.stringContaining("exit 127"), + }), + }), + ); + }); + + it("does NOT emit when postCreate command succeeds", async () => { + const ws = create(); + const project = makeProject({ postCreate: ["echo hi"] }); + + mockExecFileAsync.mockResolvedValueOnce({ stdout: "hi\n", stderr: "" }); + + await ws.postCreate!(makeWorkspaceInfo(), project); + + expect(recordActivityEventMock).not.toHaveBeenCalled(); + }); +}); + +describe("workspace.branch_collision (SHOULD emit)", () => { + it("emits when worktree add -b fails because branch already exists", async () => { + const ws = create(); + + // git remote get-url origin + mockExecFileAsync.mockResolvedValueOnce({ stdout: "origin\n", stderr: "" }); + // git fetch origin --quiet + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + // resolveBaseRef -> rev-parse origin/main + mockExecFileAsync.mockResolvedValueOnce({ stdout: "abc\n", stderr: "" }); + // git worktree add -b ... → already exists + mockExecFileAsync.mockRejectedValueOnce( + new Error("fatal: a branch named 'feat/TEST-1' already exists"), + ); + // rev-parse baseRef for stale-branch comparison + mockExecFileAsync.mockResolvedValueOnce({ stdout: "abc\n", stderr: "" }); + // refExists(branchRef) -> true + mockExecFileAsync.mockResolvedValueOnce({ stdout: "refs/heads/feat/TEST-1\n", stderr: "" }); + // rev-parse existing branch -> same as base, so reuse it + mockExecFileAsync.mockResolvedValueOnce({ stdout: "abc\n", stderr: "" }); + // git worktree add (without -b) — succeeds + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await ws.create(makeCreateConfig()); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "workspace", + kind: "workspace.branch_collision", + level: "warn", + projectId: "myproject", + sessionId: "session-1", + data: expect.objectContaining({ + plugin: "workspace-worktree", + branch: "feat/TEST-1", + errorMessage: expect.stringContaining("already exists"), + }), + }), + ); + }); +}); + +describe("workspace.destroy_fell_back (SHOULD emit)", () => { + it("emits when destroy() falls back to rmSync after git failure", async () => { + const ws = create(); + + // git rev-parse --git-common-dir → fails + mockExecFileAsync.mockRejectedValueOnce(new Error("not a git repository")); + + await ws.destroy("/some/stale/path"); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "workspace", + kind: "workspace.destroy_fell_back", + level: "warn", + data: expect.objectContaining({ + plugin: "workspace-worktree", + workspacePath: "/some/stale/path", + errorMessage: expect.stringContaining("not a git repository"), + }), + }), + ); + }); +}); diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts index f4837fd62..c39afd6d9 100644 --- a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -5,6 +5,10 @@ import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@aoage // Mocks — must be declared before any import that uses the mocked modules // --------------------------------------------------------------------------- +const { recordActivityEventMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), +})); + vi.mock("node:child_process", () => { const mockExecFile = vi.fn(); // Set custom promisify so `promisify(execFile)` returns { stdout, stderr } @@ -27,6 +31,7 @@ vi.mock("node:fs", () => ({ vi.mock("@aoagents/ao-core", () => ({ getShell: vi.fn(() => ({ cmd: "sh", args: (c: string) => ["-c", c] })), isWindows: vi.fn(() => false), + recordActivityEvent: recordActivityEventMock, })); vi.mock("node:os", () => ({ diff --git a/packages/plugins/workspace-worktree/src/index.ts b/packages/plugins/workspace-worktree/src/index.ts index 7e0b6f61e..1f5c32840 100644 --- a/packages/plugins/workspace-worktree/src/index.ts +++ b/packages/plugins/workspace-worktree/src/index.ts @@ -16,6 +16,7 @@ import { homedir } from "node:os"; import { getShell, isWindows, + recordActivityEvent, type PluginModule, type Workspace, type WorkspaceCreateConfig, @@ -361,7 +362,21 @@ export function create(config?: Record): Workspace { // Branch already exists. It may be a stale session branch left behind // from an earlier spawn, so compare it with the freshly-resolved base - // before reusing it. + // before reusing it. Surface the collision shape for RCA before the + // recovery path decides whether to reuse or reset the local branch. + recordActivityEvent({ + projectId: cfg.projectId, + sessionId: cfg.sessionId, + source: "workspace", + kind: "workspace.branch_collision", + level: "warn", + summary: `branch "${cfg.branch}" already exists; falling back to worktree recovery`, + data: { + plugin: "workspace-worktree", + branch: cfg.branch, + errorMessage: msg, + }, + }); const baseSha = await git(repoPath, "rev-parse", baseRef); const branchRef = `refs/heads/${cfg.branch}`; const existingBranchSha = (await refExists(repoPath, branchRef)) @@ -453,8 +468,24 @@ export function create(config?: Record): Workspace { // pre-existing local branches unrelated to this workspace (any branch // containing "/" would have been deleted). Stale branches can be // cleaned up separately via `git branch --merged` or similar. - } catch { + } catch (err) { // If git commands fail, try to clean up the directory. + // The worktree metadata may be left stale in `git worktree list` + // because we couldn't run `worktree remove`. Surface so RCA can + // explain why a path was deleted but `git worktree list` still + // references it. + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "workspace", + kind: "workspace.destroy_fell_back", + level: "warn", + summary: "destroy fell back to rmSync; git worktree metadata may be stale", + data: { + plugin: "workspace-worktree", + workspacePath, + errorMessage, + }, + }); // On Windows, retry with backoff for the file-handle drain race // (just-killed pty-host children still hold handles inside the worktree). if (existsSync(workspacePath)) { @@ -661,10 +692,31 @@ export function create(config?: Record): Workspace { if (project.postCreate) { const shell = getShell(); for (const command of project.postCreate) { - await execFileAsync(shell.cmd, shell.args(command), { - cwd: info.path, - windowsHide: true, - }); + try { + await execFileAsync(shell.cmd, shell.args(command), { + cwd: info.path, + windowsHide: true, + }); + } catch (err) { + // Surface which postCreate command failed. Lifecycle records + // a generic spawn_failed but loses the specific command and + // its sanitized error output. + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + projectId: info.projectId, + sessionId: info.sessionId, + source: "workspace", + kind: "workspace.post_create_failed", + level: "error", + summary: `postCreate command failed for session ${info.sessionId}`, + data: { + plugin: "workspace-worktree", + command, + errorMessage, + }, + }); + throw err; + } } } }, From fcedb25031252d44219ef1aa88acc069c0683a09 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Mon, 18 May 2026 17:27:36 +0530 Subject: [PATCH 12/26] feat(core): activity events for recovery, metadata corruption, agent-report (#1692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): activity events for recovery, metadata corruption, agent-report Wires activity events into three forensic-critical paths so RCA can reconstruct what happened after the fact: 1. recovery subsystem - recovery.session_failed (MUST) per failed session in runRecovery - recovery.action_failed (SHOULD) on recoverSession outer catch Adds "recovery" to ActivityEventSource so `ao events list --source recovery` reconstructs an `ao recover` invocation timeline. 2. metadata corruption - metadata.corrupt_detected (MUST) when mutateMetadata renames a corrupt session-metadata file to .corrupt-{ts}. Includes data.renamedTo and a 200-char data.contentSample (B11) for forensic recovery. Previously only console.warn — silent overwrites had no queryable signal. 3. agent-report apply path - api.agent_report.transition_rejected (SHOULD) - api.agent_report.session_not_found (COULD) Per B22, recovery events fire per session, not per probe step. Per B11, metadata.corrupt_detected truncates contentSample to 200 chars (full file would exceed the 16KB sanitizer cap). Closes #1660 * fix(core): align forensic activity event metadata * fix(core): cover single-session recovery failures * fix(core): improve corrupt metadata event attribution * fix(cli): expose activity event source filter --------- Co-authored-by: whoisasx --- .../issue-1660-recovery-metadata-events.md | 6 + .../cli/__tests__/commands/events.test.ts | 87 ++++++ packages/cli/src/commands/events.ts | 21 +- .../core/src/__tests__/agent-report.test.ts | 28 +- packages/core/src/__tests__/metadata.test.ts | 139 ++++++++- .../src/__tests__/recovery-manager.test.ts | 269 ++++++++++++++++++ packages/core/src/activity-events.ts | 11 +- packages/core/src/agent-report.ts | 46 ++- packages/core/src/metadata.ts | 37 ++- packages/core/src/recovery/actions.ts | 17 +- packages/core/src/recovery/manager.ts | 34 ++- 11 files changed, 676 insertions(+), 19 deletions(-) create mode 100644 .changeset/issue-1660-recovery-metadata-events.md create mode 100644 packages/cli/__tests__/commands/events.test.ts create mode 100644 packages/core/src/__tests__/recovery-manager.test.ts diff --git a/.changeset/issue-1660-recovery-metadata-events.md b/.changeset/issue-1660-recovery-metadata-events.md new file mode 100644 index 000000000..4ad89f8b0 --- /dev/null +++ b/.changeset/issue-1660-recovery-metadata-events.md @@ -0,0 +1,6 @@ +--- +"@aoagents/ao-cli": patch +"@aoagents/ao-core": minor +--- + +Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI. diff --git a/packages/cli/__tests__/commands/events.test.ts b/packages/cli/__tests__/commands/events.test.ts new file mode 100644 index 000000000..e96633234 --- /dev/null +++ b/packages/cli/__tests__/commands/events.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; + +const { mockQueryActivityEvents, mockSearchActivityEvents, mockGetActivityEventStats } = vi.hoisted( + () => ({ + mockQueryActivityEvents: vi.fn(), + mockSearchActivityEvents: vi.fn(), + mockGetActivityEventStats: vi.fn(), + }), +); + +vi.mock("@aoagents/ao-core", () => ({ + queryActivityEvents: (...args: unknown[]) => mockQueryActivityEvents(...args), + searchActivityEvents: (...args: unknown[]) => mockSearchActivityEvents(...args), + getActivityEventStats: (...args: unknown[]) => mockGetActivityEventStats(...args), + droppedEventCount: () => 0, + isActivityEventsFtsEnabled: () => true, +})); + +import { registerEvents } from "../../src/commands/events.js"; + +describe("events command", () => { + let program: Command; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + program = new Command(); + program.exitOverride(); + registerEvents(program); + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockQueryActivityEvents.mockReset(); + mockSearchActivityEvents.mockReset(); + mockGetActivityEventStats.mockReset(); + mockQueryActivityEvents.mockReturnValue([]); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + it("filters list output by source and --kind alias", async () => { + await program.parseAsync([ + "node", + "test", + "events", + "list", + "--source", + "recovery", + "--kind", + "metadata.corrupt_detected", + "--limit", + "1", + "--json", + ]); + + expect(mockQueryActivityEvents).toHaveBeenCalledWith( + expect.objectContaining({ + source: "recovery", + kind: "metadata.corrupt_detected", + limit: 1, + }), + ); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('"source": "recovery"')); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('"kind": "metadata.corrupt_detected"'), + ); + }); + + it("keeps --type as the existing event-kind filter", async () => { + await program.parseAsync([ + "node", + "test", + "events", + "list", + "--type", + "recovery.session_failed", + "--json", + ]); + + expect(mockQueryActivityEvents).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "recovery.session_failed", + }), + ); + }); +}); diff --git a/packages/cli/src/commands/events.ts b/packages/cli/src/commands/events.ts index 87430d2a3..bbb72a7bd 100644 --- a/packages/cli/src/commands/events.ts +++ b/packages/cli/src/commands/events.ts @@ -9,6 +9,7 @@ import { type ActivityEvent, type ActivityEventLevel, type ActivityEventKind, + type ActivityEventSource, } from "@aoagents/ao-core"; interface JsonEnvelope { @@ -80,7 +81,12 @@ export function registerEvents(program: Command): void { .description("List recent activity events") .option("-p, --project ", "Filter by project ID") .option("-s, --session ", "Filter by session ID") - .option("-t, --type ", "Filter by event kind (e.g. session.spawned, lifecycle.transition)") + .option( + "-t, --type ", + "Filter by event kind (e.g. session.spawned, lifecycle.transition)", + ) + .option("--kind ", "Alias for --type") + .option("--source ", "Filter by event source (e.g. lifecycle, recovery, api)") .option("--log-level ", "Filter by log level (debug, info, warn, error)") .option("--since ", "Show events from last N minutes/hours/days (e.g. 30m, 2h, 1d)") .option("-n, --limit ", "Max results", "50") @@ -91,15 +97,21 @@ export function registerEvents(program: Command): void { if (sinceRaw) { since = parseSinceDuration(sinceRaw); if (!since) { - console.error(chalk.yellow(`Warning: unrecognised --since format "${sinceRaw}" (use e.g. 30m, 2h, 1d). No time filter applied.`)); + console.error( + chalk.yellow( + `Warning: unrecognised --since format "${sinceRaw}" (use e.g. 30m, 2h, 1d). No time filter applied.`, + ), + ); } } const limit = parseInt(opts["limit"] ?? "50", 10); + const kind = opts["type"] ?? opts["kind"]; const results = queryActivityEvents({ projectId: opts["project"], sessionId: opts["session"], - kind: opts["type"] as ActivityEventKind, + kind: kind as ActivityEventKind, + source: opts["source"] as ActivityEventSource, level: opts["logLevel"] as ActivityEventLevel, since, limit, @@ -111,7 +123,8 @@ export function registerEvents(program: Command): void { query: { projectId: opts["project"] ?? null, sessionId: opts["session"] ?? null, - kind: opts["type"] ?? null, + kind: kind ?? null, + source: opts["source"] ?? null, level: opts["logLevel"] ?? null, since: sinceRaw ?? null, limit, diff --git a/packages/core/src/__tests__/agent-report.test.ts b/packages/core/src/__tests__/agent-report.test.ts index 7e3fddce1..f49483b9d 100644 --- a/packages/core/src/__tests__/agent-report.test.ts +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -18,17 +18,25 @@ import { } from "../agent-report.js"; import { writeMetadata, writeCanonicalLifecycle, readMetadataRaw } from "../metadata.js"; import { createInitialCanonicalLifecycle } from "../lifecycle-state.js"; +import { recordActivityEvent } from "../activity-events.js"; import type { CanonicalSessionLifecycle } from "../types.js"; +vi.mock("../activity-events.js", () => ({ + recordActivityEvent: vi.fn(), +})); + let dataDir: string; +let tempRoot: string; beforeEach(() => { - dataDir = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`); + tempRoot = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`); + dataDir = join(tempRoot, "projects", "project-alpha", "sessions"); mkdirSync(dataDir, { recursive: true }); + vi.mocked(recordActivityEvent).mockClear(); }); afterEach(() => { - rmSync(dataDir, { recursive: true, force: true }); + rmSync(tempRoot, { recursive: true, force: true }); }); function seedWorkerSession( @@ -433,6 +441,13 @@ describe("applyAgentReport", () => { sessionState: "terminated", }, }); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-alpha", + sessionId, + kind: "api.agent_report.transition_rejected", + }), + ); }); it("throws when the session does not exist", () => { @@ -442,6 +457,13 @@ describe("applyAgentReport", () => { now: new Date(), }), ).toThrow(/not found/); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-alpha", + sessionId: "missing-session", + kind: "api.agent_report.session_not_found", + }), + ); }); // 260 atomic-write cycles are slow on Windows (rename + AV scan); bump the diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index 03ac1c16b..47c66b0e0 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync } from "node:fs"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync, renameSync } from "node:fs"; +import type * as NodeFs from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -13,12 +14,29 @@ import { deleteMetadata, listMetadata, } from "../metadata.js"; +import { recordActivityEvent } from "../activity-events.js"; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renameSync: vi.fn((...args: Parameters) => + actual.renameSync(...args), + ), + }; +}); + +vi.mock("../activity-events.js", () => ({ + recordActivityEvent: vi.fn(), +})); let dataDir: string; beforeEach(() => { dataDir = join(tmpdir(), `ao-test-metadata-${randomUUID()}`); mkdirSync(dataDir, { recursive: true }); + vi.mocked(recordActivityEvent).mockClear(); + vi.mocked(renameSync).mockClear(); }); afterEach(() => { @@ -390,6 +408,123 @@ describe("mutateMetadata corrupt-file handling", () => { const corruptCopies = readdirSync(dataDir).filter((f) => f.includes(".corrupt-")); expect(corruptCopies).toHaveLength(0); }); + + it("emits metadata.corrupt_detected when JSON parse fails and file is renamed", () => { + const sessionPath = join(dataDir, "ao-3.json"); + writeFileSync(sessionPath, "{ broken json", "utf-8"); + + const result = mutateMetadata( + dataDir, + "ao-3", + (existing) => ({ ...existing, branch: "feat/x" }), + { createIfMissing: true }, + ); + + expect(result).not.toBeNull(); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "ao-3", + source: "session-manager", + kind: "metadata.corrupt_detected", + level: "error", + summary: expect.stringContaining("renamed to"), + data: expect.objectContaining({ + renamedTo: expect.stringContaining(`${sessionPath}.corrupt-`), + renameSucceeded: true, + contentSample: "{ broken json", + path: sessionPath, + }), + }), + ); + }); + + it("emits a rename-failed summary when corrupt metadata cannot be renamed", () => { + const sessionPath = join(dataDir, "ao-rename-failed.json"); + writeFileSync(sessionPath, "{ broken json", "utf-8"); + vi.mocked(renameSync).mockImplementationOnce(() => { + throw new Error("rename denied"); + }); + + const result = mutateMetadata( + dataDir, + "ao-rename-failed", + (existing) => ({ ...existing, branch: "feat/x" }), + { createIfMissing: true }, + ); + + expect(result).not.toBeNull(); + const call = vi + .mocked(recordActivityEvent) + .mock.calls.find((c) => c[0].kind === "metadata.corrupt_detected"); + expect(call).toBeDefined(); + expect(call![0]).toMatchObject({ + sessionId: "ao-rename-failed", + summary: expect.stringContaining("failed to rename"), + data: expect.objectContaining({ + renamedTo: null, + renameSucceeded: false, + path: sessionPath, + }), + }); + expect(call![0].summary).not.toContain("renamed to"); + }); + + it("uses the provided source for metadata.corrupt_detected", () => { + const sessionPath = join(dataDir, "ao-api-source.json"); + writeFileSync(sessionPath, "{ broken json", "utf-8"); + + mutateMetadata( + dataDir, + "ao-api-source", + (existing) => ({ ...existing, branch: "feat/api" }), + { createIfMissing: true, activityEventSource: "api" }, + ); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "ao-api-source", + source: "api", + kind: "metadata.corrupt_detected", + }), + ); + }); + + it("truncates contentSample to 200 chars in metadata.corrupt_detected", () => { + const sessionPath = join(dataDir, "ao-4.json"); + // 250 char garbage payload — sanitizer cap is 16KB but invariant B11 caps + // forensic sample at 200 chars. + const huge = "x".repeat(250); + writeFileSync(sessionPath, huge, "utf-8"); + + mutateMetadata( + dataDir, + "ao-4", + (existing) => ({ ...existing, branch: "feat/y" }), + { createIfMissing: true }, + ); + + const call = vi + .mocked(recordActivityEvent) + .mock.calls.find((c) => c[0].kind === "metadata.corrupt_detected"); + expect(call).toBeDefined(); + const sample = (call![0].data as Record)["contentSample"] as string; + expect(sample.length).toBe(200); + expect((call![0].data as Record)["contentLength"]).toBe(250); + }); + + it("does not emit metadata.corrupt_detected for healthy JSON", () => { + writeMetadata(dataDir, "ao-5", { + worktree: "/tmp/w", + branch: "main", + status: "working", + }); + mutateMetadata(dataDir, "ao-5", (existing) => ({ ...existing, summary: "hi" })); + + const corruptCalls = vi + .mocked(recordActivityEvent) + .mock.calls.filter((c) => c[0].kind === "metadata.corrupt_detected"); + expect(corruptCalls).toHaveLength(0); + }); }); describe("readCanonicalLifecycle", () => { diff --git a/packages/core/src/__tests__/recovery-manager.test.ts b/packages/core/src/__tests__/recovery-manager.test.ts new file mode 100644 index 000000000..80647dc29 --- /dev/null +++ b/packages/core/src/__tests__/recovery-manager.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { recoverSessionById, runRecovery } from "../recovery/manager.js"; +import { recordActivityEvent } from "../activity-events.js"; +import { getProjectDir, getProjectSessionsDir } from "../paths.js"; +import { + DEFAULT_RECOVERY_CONFIG, + type RecoveryAssessment, + type RecoveryResult, +} from "../recovery/types.js"; +import * as actionsModule from "../recovery/actions.js"; +import * as validatorModule from "../recovery/validator.js"; +import type { OrchestratorConfig, PluginRegistry } from "../types.js"; + +vi.mock("../activity-events.js", () => ({ + recordActivityEvent: vi.fn(), +})); + +const PROJECT_ID = "app"; + +function makeConfig(rootDir: string): OrchestratorConfig { + return { + configPath: join(rootDir, "agent-orchestrator.yaml"), + port: 3000, + readyThresholdMs: 300_000, + power: { preventIdleSleep: false }, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + app: { + name: "app", + repo: "org/repo", + path: join(rootDir, "project"), + defaultBranch: "main", + sessionPrefix: "app", + }, + }, + notifiers: {}, + notificationRouting: { + urgent: ["desktop"], + action: ["desktop"], + warning: ["desktop"], + info: ["desktop"], + }, + reactions: {}, + }; +} + +function makeRegistry(): PluginRegistry { + return { + register: vi.fn(), + get: vi.fn().mockReturnValue(null), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeAssessment(sessionId: string): RecoveryAssessment { + return { + sessionId, + projectId: PROJECT_ID, + classification: "live", + action: "recover", + reason: "needs recovery", + runtimeProbeSucceeded: true, + processProbeSucceeded: true, + signalDisagreement: false, + recoveryRule: "auto", + runtimeAlive: true, + runtimeHandle: null, + workspaceExists: true, + workspacePath: "/tmp/worktree", + agentProcessRunning: true, + agentActivity: "active", + metadataValid: true, + metadataStatus: "working", + rawMetadata: { project: PROJECT_ID, status: "working" }, + }; +} + +describe("runRecovery activity events", () => { + let rootDir: string; + let previousHome: string | undefined; + + beforeEach(() => { + rootDir = join(tmpdir(), `ao-recovery-events-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + mkdirSync(join(rootDir, "project"), { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + previousHome = process.env["HOME"]; + process.env["HOME"] = rootDir; + + const sessionsDir = getProjectSessionsDir(PROJECT_ID); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync( + join(sessionsDir, "app-1.json"), + JSON.stringify({ project: PROJECT_ID, status: "working" }) + "\n", + "utf-8", + ); + writeFileSync( + join(sessionsDir, "app-2.json"), + JSON.stringify({ project: PROJECT_ID, status: "working" }) + "\n", + "utf-8", + ); + + vi.mocked(recordActivityEvent).mockClear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + if (previousHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = previousHome; + } + if (rootDir) { + const projectBaseDir = getProjectDir(PROJECT_ID); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } + rmSync(rootDir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + it("emits recovery.session_failed for each session that recovery couldn't fix", async () => { + vi.spyOn(validatorModule, "validateSession").mockImplementation(async (scanned) => + makeAssessment(scanned.sessionId), + ); + + const successResult: RecoveryResult = { + success: true, + sessionId: "app-1", + action: "recover", + }; + const failedResult: RecoveryResult = { + success: false, + sessionId: "app-2", + action: "recover", + error: "worktree missing", + }; + + vi.spyOn(actionsModule, "executeAction") + .mockResolvedValueOnce(successResult) + .mockResolvedValueOnce(failedResult); + + const config = makeConfig(rootDir); + const registry = makeRegistry(); + + const { report } = await runRecovery({ + config, + registry, + recoveryConfig: { + ...DEFAULT_RECOVERY_CONFIG, + logPath: join(rootDir, "recovery.log"), + }, + }); + + expect(report.errors).toHaveLength(1); + expect(report.errors[0]?.sessionId).toBe("app-2"); + + const emitCalls = vi + .mocked(recordActivityEvent) + .mock.calls.map((c) => c[0]) + .filter((e) => e.kind === "recovery.session_failed"); + + expect(emitCalls).toHaveLength(1); + expect(emitCalls[0]).toEqual( + expect.objectContaining({ + sessionId: "app-2", + projectId: PROJECT_ID, + source: "recovery", + kind: "recovery.session_failed", + level: "error", + data: expect.objectContaining({ + action: "recover", + errorMessage: "worktree missing", + }), + }), + ); + }); + + it("emits recovery.session_failed when single-session recovery fails", async () => { + vi.spyOn(validatorModule, "validateSession").mockImplementation(async (scanned) => + makeAssessment(scanned.sessionId), + ); + + const failedResult: RecoveryResult = { + success: false, + sessionId: "app-2", + action: "recover", + error: "agent process missing", + }; + + vi.spyOn(actionsModule, "executeAction").mockResolvedValueOnce(failedResult); + + const config = makeConfig(rootDir); + const registry = makeRegistry(); + + const result = await recoverSessionById("app-2", { + config, + registry, + recoveryConfig: { + ...DEFAULT_RECOVERY_CONFIG, + logPath: join(rootDir, "recovery.log"), + }, + }); + + expect(result).toEqual(failedResult); + + const emitCalls = vi + .mocked(recordActivityEvent) + .mock.calls.map((c) => c[0]) + .filter((e) => e.kind === "recovery.session_failed"); + + expect(emitCalls).toHaveLength(1); + expect(emitCalls[0]).toEqual( + expect.objectContaining({ + sessionId: "app-2", + projectId: PROJECT_ID, + source: "recovery", + kind: "recovery.session_failed", + level: "error", + data: expect.objectContaining({ + action: "recover", + errorMessage: "agent process missing", + }), + }), + ); + }); + + it("does not emit recovery.session_failed when every session recovers cleanly", async () => { + vi.spyOn(validatorModule, "validateSession").mockImplementation(async (scanned) => + makeAssessment(scanned.sessionId), + ); + + vi.spyOn(actionsModule, "executeAction").mockImplementation(async (assessment) => ({ + success: true, + sessionId: assessment.sessionId, + action: "recover", + })); + + const config = makeConfig(rootDir); + const registry = makeRegistry(); + + await runRecovery({ + config, + registry, + recoveryConfig: { + ...DEFAULT_RECOVERY_CONFIG, + logPath: join(rootDir, "recovery.log"), + }, + }); + + const failedEmits = vi + .mocked(recordActivityEvent) + .mock.calls.map((c) => c[0]) + .filter((e) => e.kind === "recovery.session_failed"); + expect(failedEmits).toHaveLength(0); + }); +}); diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index 0c1072e88..789e2616d 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -24,7 +24,8 @@ export type ActivityEventSource = | "workspace" | "notifier" | "reaction" - | "report-watcher"; + | "report-watcher" + | "recovery"; export type ActivityEventKind = | "session.spawn_started" @@ -69,7 +70,13 @@ export type ActivityEventKind = | "lifecycle.poll_failed" | "detecting.escalated" // Report watcher - | "report_watcher.triggered"; + | "report_watcher.triggered" + // Recovery/forensic instrumentation + | "recovery.session_failed" + | "recovery.action_failed" + | "metadata.corrupt_detected" + | "api.agent_report.session_not_found" + | "api.agent_report.transition_rejected"; export type ActivityEventLevel = "debug" | "info" | "warn" | "error"; diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts index 07e380e30..13b1a187a 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -18,7 +18,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import type { CanonicalSessionLifecycle, CanonicalSessionReason, @@ -26,6 +26,7 @@ import type { SessionId, SessionStatus, } from "./types.js"; +import { recordActivityEvent } from "./activity-events.js"; import { mutateMetadata, readMetadataRaw } from "./metadata.js"; import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus, parseCanonicalLifecycle } from "./lifecycle-state.js"; import { parsePrFromUrl } from "./utils/pr.js"; @@ -254,6 +255,11 @@ function buildAuditDir(dataDir: string): string { return join(dataDir, ".agent-report-audit"); } +function inferProjectIdFromDataDir(dataDir: string): string | undefined { + // dataDir is `.../projects/{projectId}/sessions`; recover projectId for filtering. + return basename(dirname(dataDir)) || undefined; +} + const AGENT_REPORT_AUDIT_MAX_BYTES = 256 * 1024; const AGENT_REPORT_AUDIT_MAX_ENTRIES = 200; @@ -383,8 +389,22 @@ export function applyAgentReport( sessionId: SessionId, input: ApplyAgentReportInput, ): ApplyAgentReportResult { + const projectId = inferProjectIdFromDataDir(dataDir); const raw = readMetadataRaw(dataDir, sessionId); if (!raw) { + recordActivityEvent({ + projectId, + sessionId, + source: "api", + kind: "api.agent_report.session_not_found", + level: "warn", + summary: `applyAgentReport: session not found: ${sessionId}`, + data: { + reportState: input.state, + actor: input.actor, + source: input.source, + }, + }); throw new Error(`Session not found: ${sessionId}`); } @@ -439,6 +459,7 @@ export function applyAgentReport( before = buildAuditSnapshot(current, previousLegacyStatus); const validation = validateAgentReportTransition(current, input.state); if (!validation.ok) { + const rejectionReason = validation.reason ?? "transition rejected"; appendAgentReportAuditEntry(dataDir, sessionId, { timestamp: now, actor, @@ -449,11 +470,28 @@ export function applyAgentReport( prUrl: trimmedPrUrl, prIsDraft, accepted: false, - rejectionReason: validation.reason ?? "transition rejected", + rejectionReason, before, after: before, }); - throw new Error(validation.reason ?? "transition rejected"); + recordActivityEvent({ + projectId, + sessionId, + source: "api", + kind: "api.agent_report.transition_rejected", + level: "warn", + summary: `applyAgentReport rejected ${input.state} for ${sessionId}: ${rejectionReason}`, + data: { + reportState: input.state, + rejectionReason, + actor, + reportSource: source, + fromState: current.session.state, + fromReason: current.session.reason, + legacyStatus: previousLegacyStatus, + }, + }); + throw new Error(rejectionReason); } const mapped = mapAgentReportToLifecycle(input.state); previousState = current.session.state; @@ -512,7 +550,7 @@ export function applyAgentReport( } } return next; - }); + }, { activityEventSource: "api" }); if (!nextMetadata || !before || !previousState || !nextState || !legacyStatus) { throw new Error(`Failed to apply agent report for session ${sessionId}`); diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index a4f3a19c5..451c127b5 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -22,9 +22,10 @@ import { closeSync, constants, } from "node:fs"; -import { join, dirname } from "node:path"; +import { basename, join, dirname } from "node:path"; import type { CanonicalSessionLifecycle, RuntimeHandle, SessionId, SessionMetadata } from "./types.js"; import { atomicWriteFileSync } from "./atomic-write.js"; +import { recordActivityEvent, type ActivityEventSource } from "./activity-events.js"; import { buildLifecycleMetadataPatch, cloneLifecycle, @@ -330,6 +331,11 @@ export function applyMetadataUpdates( return next; } +interface MutateMetadataOptions { + createIfMissing?: boolean; + activityEventSource?: ActivityEventSource; +} + function normalizeMetadataRecord(data: Record): Record { return Object.fromEntries( Object.entries(data).filter(([, value]) => value !== undefined && value !== ""), @@ -340,7 +346,7 @@ export function mutateMetadata( dataDir: string, sessionId: SessionId, updater: (existing: Record) => Record, - options: { createIfMissing?: boolean } = {}, + options: MutateMetadataOptions = {}, ): Record | null { const path = metadataPath(dataDir, sessionId); const lockPath = `${path}.lock`; @@ -368,8 +374,10 @@ export function mutateMetadata( // that anything was wrong — the file just becomes "not // corrupt anymore — and missing fields". const corruptPath = `${path}.corrupt-${Date.now()}`; + let renamed = false; try { renameSync(path, corruptPath); + renamed = true; // eslint-disable-next-line no-console console.warn( `[metadata] corrupt JSON at ${path}; preserved as ${corruptPath} before rewriting`, @@ -377,6 +385,31 @@ export function mutateMetadata( } catch { // best effort — proceed even if the rename fails (e.g. EACCES) } + // Forensic activity event so RCA can find every silent overwrite. + // Truncate the bad-JSON sample to 200 chars (B11 invariant — full file + // could be 16KB+ and would be dropped by the sanitizer cap). + const contentSample = + content.length > 200 ? content.slice(0, 200) : content; + // dataDir is `.../projects/{projectId}/sessions`; recover projectId for filtering. + const inferredProjectId = basename(dirname(dataDir)); + const summary = renamed + ? `Corrupt metadata for session ${sessionId} renamed to ${basename(corruptPath)}` + : `Corrupt metadata detected for session ${sessionId}; failed to rename forensic copy before rewrite`; + recordActivityEvent({ + projectId: inferredProjectId || undefined, + sessionId, + source: options.activityEventSource ?? "session-manager", + kind: "metadata.corrupt_detected", + level: "error", + summary, + data: { + path, + renamedTo: renamed ? corruptPath : null, + renameSucceeded: renamed, + contentSample, + contentLength: content.length, + }, + }); } } } else if (!options.createIfMissing) { diff --git a/packages/core/src/recovery/actions.ts b/packages/core/src/recovery/actions.ts index 43a9eec87..f7b57980a 100644 --- a/packages/core/src/recovery/actions.ts +++ b/packages/core/src/recovery/actions.ts @@ -5,6 +5,7 @@ import type { Runtime, Workspace, } from "../types.js"; +import { recordActivityEvent } from "../activity-events.js"; import { updateMetadata } from "../metadata.js"; import { getProjectSessionsDir } from "../paths.js"; import { validateStatus } from "../utils/validation.js"; @@ -146,11 +147,25 @@ export async function recoverSession( session, }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + recordActivityEvent({ + projectId, + sessionId, + source: "recovery", + kind: "recovery.action_failed", + level: "error", + summary: `recoverSession threw for ${sessionId}: ${errorMessage}`, + data: { + action: "recover", + recoveryCount, + errorMessage, + }, + }); return { success: false, sessionId, action: "recover", - error: error instanceof Error ? error.message : String(error), + error: errorMessage, }; } } diff --git a/packages/core/src/recovery/manager.ts b/packages/core/src/recovery/manager.ts index 1c90e007f..330922e7f 100644 --- a/packages/core/src/recovery/manager.ts +++ b/packages/core/src/recovery/manager.ts @@ -1,4 +1,5 @@ import type { OrchestratorConfig, PluginRegistry, Session } from "../types.js"; +import { recordActivityEvent } from "../activity-events.js"; import { scanAllSessions, getRecoveryLogPath } from "./scanner.js"; import { validateSession } from "./validator.js"; import { executeAction } from "./actions.js"; @@ -106,9 +107,10 @@ export async function runRecovery(options: RecoveryManagerOptions): Promise Date: Mon, 18 May 2026 17:28:10 +0530 Subject: [PATCH 13/26] feat(web): record activity events for API mutation routes (#1695) * feat(web): record activity events for API mutation routes Wire recordActivityEvent calls into all POST/PATCH/DELETE handlers under packages/web/src/app/api/ so RCA can answer "did the user click X?" beyond just success/failure status codes. Source: "api" (added in #1620). Coverage: - session mutations: spawn, kill, send, message, restore, remap (with failure variants for SessionNotRestorable/WorkspaceMissing/etc.) - orchestrator + PR: orchestrator spawn, PR merge (with rejected/failed) - project + config: add, update, remove, repair, reload - issue + verify + labels: issue create, verify, label setup Sanitization rules preserved: - never include request/response bodies - *_message_* events include messageLength only, never the message text - project_updated records changedKeys, never values (config can carry tokens) Tests: regression coverage for the 12 MUST emits across two new test files, plus negative-path sanitization assertions. Closes #1655 * fix(web): refine api activity failure events * fix(web): align project activity event tests * fix(web): avoid orchestrator spawn failure double emit --------- Co-authored-by: whoisasx --- .../activity-events-projects.test.ts | 164 ++++++ .../__tests__/activity-events-routes.test.ts | 498 ++++++++++++++++++ packages/web/src/app/api/issues/route.ts | 24 +- .../web/src/app/api/orchestrators/route.ts | 15 +- .../web/src/app/api/projects/[id]/route.ts | 95 ++-- .../web/src/app/api/projects/reload/route.ts | 20 +- packages/web/src/app/api/projects/route.ts | 23 + .../web/src/app/api/prs/[id]/merge/route.ts | 48 +- .../src/app/api/sessions/[id]/kill/route.ts | 18 +- .../app/api/sessions/[id]/message/route.ts | 19 +- .../src/app/api/sessions/[id]/remap/route.ts | 27 +- .../app/api/sessions/[id]/restore/route.ts | 59 ++- .../src/app/api/sessions/[id]/send/route.ts | 19 +- .../web/src/app/api/setup-labels/route.ts | 10 + packages/web/src/app/api/spawn/route.ts | 20 + packages/web/src/app/api/verify/route.ts | 32 +- 16 files changed, 1028 insertions(+), 63 deletions(-) create mode 100644 packages/web/src/__tests__/activity-events-projects.test.ts create mode 100644 packages/web/src/__tests__/activity-events-routes.test.ts diff --git a/packages/web/src/__tests__/activity-events-projects.test.ts b/packages/web/src/__tests__/activity-events-projects.test.ts new file mode 100644 index 000000000..2aad007da --- /dev/null +++ b/packages/web/src/__tests__/activity-events-projects.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { NextRequest } from "next/server"; +import { recordActivityEvent, registerProjectInGlobalConfig } from "@aoagents/ao-core"; + +vi.mock("@aoagents/ao-core", async () => { + const actual = await vi.importActual("@aoagents/ao-core"); + return { + ...(actual as Record), + recordActivityEvent: vi.fn(), + }; +}); + +const invalidatePortfolioServicesCache = vi.fn(); +const getServices = vi.fn(); + +vi.mock("@/lib/services", () => ({ + invalidatePortfolioServicesCache, + getServices, +})); + +function makeRequest(method: string, url: string, body?: Record): NextRequest { + return new NextRequest(url, { + method, + body: body ? JSON.stringify(body) : undefined, + headers: body ? { "Content-Type": "application/json" } : undefined, + }); +} + +const recorded = vi.mocked(recordActivityEvent); + +describe("Activity events — project mutation routes", () => { + let oldGlobalConfig: string | undefined; + let oldConfigPath: string | undefined; + let oldHome: string | undefined; + let tempRoot: string; + let configPath: string; + + beforeEach(() => { + vi.resetModules(); + recorded.mockClear(); + invalidatePortfolioServicesCache.mockReset(); + getServices.mockReset(); + getServices.mockResolvedValue({ + registry: { get: vi.fn().mockReturnValue(null) }, + sessionManager: { + list: vi.fn().mockResolvedValue([]), + kill: vi.fn().mockResolvedValue(undefined), + }, + }); + oldGlobalConfig = process.env["AO_GLOBAL_CONFIG"]; + oldConfigPath = process.env["AO_CONFIG_PATH"]; + oldHome = process.env["HOME"]; + tempRoot = mkdtempSync(path.join(tmpdir(), "ao-activity-projects-")); + configPath = path.join(tempRoot, "config.yaml"); + process.env["AO_GLOBAL_CONFIG"] = configPath; + process.env["AO_CONFIG_PATH"] = configPath; + process.env["HOME"] = tempRoot; + }); + + afterEach(() => { + if (oldGlobalConfig === undefined) delete process.env["AO_GLOBAL_CONFIG"]; + else process.env["AO_GLOBAL_CONFIG"] = oldGlobalConfig; + if (oldConfigPath === undefined) delete process.env["AO_CONFIG_PATH"]; + else process.env["AO_CONFIG_PATH"] = oldConfigPath; + if (oldHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = oldHome; + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("POST /api/projects emits api.project_added on success", async () => { + const repoDir = path.join(tempRoot, "demo-add"); + mkdirSync(repoDir, { recursive: true }); + execSync("git init -q", { cwd: repoDir }); + + const { POST } = await import("@/app/api/projects/route"); + const res = await POST( + makeRequest("POST", "http://localhost:3000/api/projects", { + path: repoDir, + projectId: "demo-add", + name: "Demo Add", + }), + ); + + expect(res.status).toBe(201); + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.project_added", + }), + ); + }); + + it("PATCH /api/projects/:id emits api.project_updated with changed keys (not values)", async () => { + const repoDir = path.join(tempRoot, "demo-patch"); + mkdirSync(repoDir, { recursive: true }); + const effectiveId = registerProjectInGlobalConfig("demo-patch", "Demo Patch", repoDir); + + const { PATCH } = await import("@/app/api/projects/[id]/route"); + const res = await PATCH( + makeRequest("PATCH", `http://localhost:3000/api/projects/${effectiveId}`, { + agent: "codex", + runtime: "tmux", + someUnknownField: "do-not-record", + }), + { params: Promise.resolve({ id: effectiveId }) }, + ); + + expect(res.status).toBe(200); + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.project_updated", + projectId: effectiveId, + data: expect.objectContaining({ + changedKeys: expect.arrayContaining(["agent", "runtime"]), + }), + }), + ); + + // Ensure no value content (e.g. "codex", "tmux") leaked into the event payload + const calls = recorded.mock.calls.filter( + (c) => (c[0] as { kind: string }).kind === "api.project_updated", + ); + expect(calls[0]?.[0]).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + changedKeys: ["agent", "runtime"], + }), + }), + ); + for (const [event] of calls) { + const json = JSON.stringify(event); + expect(json).not.toContain("codex"); + expect(json).not.toContain('"tmux"'); + expect(json).not.toContain("someUnknownField"); + expect(json).not.toContain("do-not-record"); + } + }); + + it("DELETE /api/projects/:id emits api.project_removed on success", async () => { + const repoDir = path.join(tempRoot, "demo-delete"); + mkdirSync(repoDir, { recursive: true }); + const effectiveId = registerProjectInGlobalConfig("demo-delete", "Demo Delete", repoDir); + + const { DELETE } = await import("@/app/api/projects/[id]/route"); + const res = await DELETE( + makeRequest("DELETE", `http://localhost:3000/api/projects/${effectiveId}`), + { params: Promise.resolve({ id: effectiveId }) }, + ); + + expect(res.status).toBe(200); + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.project_removed", + projectId: effectiveId, + }), + ); + }); +}); diff --git a/packages/web/src/__tests__/activity-events-routes.test.ts b/packages/web/src/__tests__/activity-events-routes.test.ts new file mode 100644 index 000000000..fbd2b474c --- /dev/null +++ b/packages/web/src/__tests__/activity-events-routes.test.ts @@ -0,0 +1,498 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { + SessionNotFoundError, + SessionNotRestorableError, + WorkspaceMissingError, + recordActivityEvent, + createInitialCanonicalLifecycle, + createActivitySignal, + type Session, + type SessionManager, + type OrchestratorConfig, + type PluginRegistry, + type SCM, +} from "@aoagents/ao-core"; + +// Partial mock so we replace recordActivityEvent but keep types/helpers +vi.mock("@aoagents/ao-core", async () => { + const actual = await vi.importActual("@aoagents/ao-core"); + return { + ...(actual as Record), + recordActivityEvent: vi.fn(), + }; +}); + +vi.mock("@/lib/observability", async () => { + const actual = await vi.importActual("@/lib/observability"); + return { + ...(actual as Record), + recordApiObservation: vi.fn(), + }; +}); + +function makeSession(overrides: Partial & { id: string }): Session { + const lifecycle = createInitialCanonicalLifecycle("worker", new Date()); + lifecycle.session.state = "working"; + lifecycle.session.reason = "task_in_progress"; + lifecycle.session.startedAt = lifecycle.session.lastTransitionAt; + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + return { + projectId: "my-app", + status: "working", + activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), + lifecycle, + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + }; +} + +const baseSessions: Session[] = [ + makeSession({ id: "backend-3" }), + makeSession({ + id: "backend-7", + pr: { + number: 432, + url: "https://github.com/acme/my-app/pull/432", + title: "feat: health check", + owner: "acme", + repo: "my-app", + branch: "feat/health-check", + baseBranch: "main", + isDraft: false, + }, + }), + makeSession({ id: "frontend-1", status: "killed", activity: "exited" }), +]; + +const mockSessionManager: SessionManager = { + list: vi.fn(async () => baseSessions), + listCached: vi.fn(async () => baseSessions), + invalidateCache: vi.fn(), + get: vi.fn(async (id: string) => baseSessions.find((s) => s.id === id) ?? null), + spawn: vi.fn(async (cfg) => + makeSession({ + id: `session-${Date.now()}`, + projectId: cfg.projectId, + issueId: cfg.issueId ?? null, + status: "spawning", + }), + ), + kill: vi.fn(async (id: string) => { + if (!baseSessions.find((s) => s.id === id)) { + throw new SessionNotFoundError(id); + } + }), + send: vi.fn(async (id: string) => { + if (!baseSessions.find((s) => s.id === id)) { + throw new SessionNotFoundError(id); + } + }), + cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })), + spawnOrchestrator: vi.fn(async () => + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ), + relaunchOrchestrator: vi.fn(async () => + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ), + ensureOrchestrator: vi.fn(), + remap: vi.fn(async () => "ses_mock"), + restore: vi.fn(async (id: string) => { + const session = baseSessions.find((s) => s.id === id); + if (!session) throw new SessionNotFoundError(id); + return { ...session, status: "spawning" as const, activity: "active" as const }; + }), +}; + +const mockSCM: SCM = { + name: "github", + detectPR: vi.fn(async () => null), + getPRState: vi.fn(async () => "open" as const), + mergePR: vi.fn(async () => {}), + closePR: vi.fn(async () => {}), + getCIChecks: vi.fn(async () => []), + getCISummary: vi.fn(async () => "passing" as const), + getReviews: vi.fn(async () => []), + getReviewDecision: vi.fn(async () => "approved" as const), + getPendingComments: vi.fn(async () => []), + getAutomatedComments: vi.fn(async () => []), + getMergeability: vi.fn(async () => ({ + mergeable: true, + ciPassing: true, + approved: true, + noConflicts: true, + blockers: [], + })), +}; + +const mockRegistry: PluginRegistry = { + register: vi.fn(), + get: vi.fn(() => mockSCM) as PluginRegistry["get"], + list: vi.fn(() => []), + loadBuiltins: vi.fn(async () => {}), + loadFromConfig: vi.fn(async () => {}), +}; + +const mockConfig: OrchestratorConfig = { + configPath: "/tmp/ao-test/agent-orchestrator.yaml", + port: 3000, + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] }, + projects: { + "my-app": { + name: "My App", + repo: "acme/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "my-app", + scm: { plugin: "github" }, + }, + }, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, +}; + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(async () => ({ + config: mockConfig, + registry: mockRegistry, + sessionManager: mockSessionManager, + })), + getVerifyIssues: vi.fn(async () => []), + getSCM: vi.fn(() => mockSCM), + invalidatePortfolioServicesCache: vi.fn(), +})); + +import { getServices } from "@/lib/services"; +import { recordApiObservation } from "@/lib/observability"; +import { POST as spawnPOST } from "@/app/api/spawn/route"; +import { POST as killPOST } from "@/app/api/sessions/[id]/kill/route"; +import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; +import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; +import { POST as restorePOST } from "@/app/api/sessions/[id]/restore/route"; +import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; +import { POST as mergePOST } from "@/app/api/prs/[id]/merge/route"; + +function makeRequest(url: string, init?: RequestInit): NextRequest { + return new NextRequest( + new URL(url, "http://localhost:3000"), + init as ConstructorParameters[1], + ); +} + +const recorded = vi.mocked(recordActivityEvent); + +beforeEach(() => { + recorded.mockClear(); + vi.mocked(recordApiObservation).mockClear(); + vi.mocked(getServices).mockClear(); +}); + +describe("API mutation routes emit activity events (api source)", () => { + describe("MUST emits — session mutations", () => { + it("POST /api/spawn emits api.session_spawn_requested on success", async () => { + const req = makeRequest("/api/spawn", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", issueId: "INT-100" }), + headers: { "Content-Type": "application/json" }, + }); + await spawnPOST(req); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_spawn_requested", + projectId: "my-app", + }), + ); + }); + + it("POST /api/sessions/:id/kill emits api.session_kill_requested on success", async () => { + const req = makeRequest("/api/sessions/backend-3/kill", { method: "POST" }); + await killPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_kill_requested", + sessionId: "backend-3", + }), + ); + }); + + it("POST /api/sessions/:id/send emits api.session_message_sent with messageLength", async () => { + const req = makeRequest("/api/sessions/backend-3/send", { + method: "POST", + body: JSON.stringify({ message: "Fix the tests" }), + headers: { "Content-Type": "application/json" }, + }); + await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_message_sent", + sessionId: "backend-3", + data: expect.objectContaining({ messageLength: "Fix the tests".length }), + }), + ); + }); + + it("POST /api/sessions/:id/send does NOT include the raw message in data", async () => { + const secret = "very-secret-PII content"; + const req = makeRequest("/api/sessions/backend-3/send", { + method: "POST", + body: JSON.stringify({ message: secret }), + headers: { "Content-Type": "application/json" }, + }); + await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + const calls = recorded.mock.calls.filter( + (c) => (c[0] as { kind: string }).kind === "api.session_message_sent", + ); + expect(calls.length).toBeGreaterThan(0); + for (const [event] of calls) { + const json = JSON.stringify(event); + expect(json).not.toContain(secret); + } + }); + + it("POST /api/sessions/:id/message emits api.session_message_sent with messageLength", async () => { + const req = makeRequest("/api/sessions/backend-3/message", { + method: "POST", + body: JSON.stringify({ message: "Hi" }), + headers: { "Content-Type": "application/json" }, + }); + await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_message_sent", + sessionId: "backend-3", + data: expect.objectContaining({ messageLength: 2 }), + }), + ); + }); + + it("POST /api/sessions/:id/restore emits api.session_restore_requested on success", async () => { + const req = makeRequest("/api/sessions/frontend-1/restore", { method: "POST" }); + await restorePOST(req, { params: Promise.resolve({ id: "frontend-1" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_restore_requested", + sessionId: "frontend-1", + }), + ); + }); + }); + + describe("MUST emits — orchestrator + PR mutations", () => { + it("POST /api/orchestrators emits api.orchestrator_spawn_requested on success", async () => { + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app" }), + headers: { "Content-Type": "application/json" }, + }); + await orchestratorsPOST(req); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.orchestrator_spawn_requested", + projectId: "my-app", + }), + ); + }); + + it("POST /api/prs/:id/merge emits api.pr_merge_requested on success", async () => { + const req = makeRequest("/api/prs/432/merge", { method: "POST" }); + await mergePOST(req, { params: Promise.resolve({ id: "432" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.pr_merge_requested", + data: expect.objectContaining({ prNumber: 432 }), + }), + ); + }); + }); + + describe("SHOULD emits — failure paths", () => { + it("POST /api/spawn emits api.session_spawn_rejected for unknown project", async () => { + const req = makeRequest("/api/spawn", { + method: "POST", + body: JSON.stringify({ projectId: "unknown-app" }), + headers: { "Content-Type": "application/json" }, + }); + await spawnPOST(req); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_spawn_rejected", + projectId: "unknown-app", + }), + ); + }); + + it("POST /api/spawn does not emit api.session_spawn_failed when core spawn throws", async () => { + (mockSessionManager.spawn as ReturnType).mockRejectedValueOnce( + new Error("runtime failed"), + ); + const req = makeRequest("/api/spawn", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", issueId: "INT-101" }), + headers: { "Content-Type": "application/json" }, + }); + const res = await spawnPOST(req); + + expect(res.status).toBe(500); + expect( + recorded.mock.calls.some( + ([event]) => (event as { kind: string }).kind === "api.session_spawn_failed", + ), + ).toBe(false); + }); + + it.each([ + ["spawn", false, mockSessionManager.spawnOrchestrator], + ["clean relaunch", true, mockSessionManager.relaunchOrchestrator], + ])( + "POST /api/orchestrators does not emit api.orchestrator_spawn_failed when core %s throws", + async (_name, clean, method) => { + (method as ReturnType).mockRejectedValueOnce(new Error("runtime failed")); + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean }), + headers: { "Content-Type": "application/json" }, + }); + const res = await orchestratorsPOST(req); + + expect(res.status).toBe(500); + expect( + recorded.mock.calls.some( + ([event]) => (event as { kind: string }).kind === "api.orchestrator_spawn_failed", + ), + ).toBe(false); + }, + ); + + it("POST /api/sessions/:id/send emits api.session_message_failed on unexpected error", async () => { + (mockSessionManager.send as ReturnType).mockRejectedValueOnce( + new Error("write failed"), + ); + const req = makeRequest("/api/sessions/backend-3/send", { + method: "POST", + body: JSON.stringify({ message: "hi" }), + headers: { "Content-Type": "application/json" }, + }); + await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_message_failed", + sessionId: "backend-3", + data: expect.objectContaining({ messageLength: 2 }), + }), + ); + }); + + it.each([ + ["non-restorable session", new SessionNotRestorableError("my-app-123", "still working"), 409], + ["missing workspace", new WorkspaceMissingError("/tmp/missing-workspace"), 422], + ["unexpected restore error", new Error("restore failed"), 500], + ])( + "POST /api/sessions/:id/restore emits attributed api.session_restore_failed for %s", + async (_name, error, statusCode) => { + (mockSessionManager.restore as ReturnType).mockRejectedValueOnce(error); + const req = makeRequest("/api/sessions/my-app-123/restore", { method: "POST" }); + const res = await restorePOST(req, { params: Promise.resolve({ id: "my-app-123" }) }); + + expect(res.status).toBe(statusCode); + expect(vi.mocked(getServices)).toHaveBeenCalledTimes(1); + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_restore_failed", + projectId: "my-app", + sessionId: "my-app-123", + data: expect.objectContaining({ statusCode }), + }), + ); + }, + ); + + it("POST /api/prs/:id/merge emits api.pr_merge_rejected for non-mergeable PR", async () => { + (mockSCM.getMergeability as ReturnType).mockResolvedValueOnce({ + mergeable: false, + ciPassing: false, + approved: false, + noConflicts: true, + blockers: ["CI checks failing"], + }); + const req = makeRequest("/api/prs/432/merge", { method: "POST" }); + await mergePOST(req, { params: Promise.resolve({ id: "432" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.pr_merge_rejected", + data: expect.objectContaining({ prNumber: 432 }), + }), + ); + }); + + it("POST /api/prs/:id/merge emits api.pr_merge_failed when mergePR throws", async () => { + (mockSCM.mergePR as ReturnType).mockRejectedValueOnce(new Error("github 500")); + const req = makeRequest("/api/prs/432/merge", { method: "POST" }); + await mergePOST(req, { params: Promise.resolve({ id: "432" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.pr_merge_failed", + projectId: "my-app", + sessionId: "backend-7", + data: expect.objectContaining({ prNumber: 432, reason: "github 500" }), + }), + ); + expect(recordApiObservation).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "failure", + statusCode: 500, + projectId: "my-app", + sessionId: "backend-7", + data: expect.objectContaining({ prNumber: 432 }), + }), + ); + }); + }); +}); diff --git a/packages/web/src/app/api/issues/route.ts b/packages/web/src/app/api/issues/route.ts index 6063a67f1..07b18847a 100644 --- a/packages/web/src/app/api/issues/route.ts +++ b/packages/web/src/app/api/issues/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getServices } from "@/lib/services"; import { validateString, validateConfiguredProject } from "@/lib/validation"; -import type { Tracker } from "@aoagents/ao-core"; +import { recordActivityEvent, type Tracker } from "@aoagents/ao-core"; export const dynamic = "force-dynamic"; @@ -95,11 +95,25 @@ export async function POST(request: NextRequest) { project, ); + recordActivityEvent({ + projectId, + source: "api", + kind: "api.issue_created", + summary: `issue created: ${issue.id}`, + data: { issueId: issue.id, addToBacklog: Boolean(body.addToBacklog) }, + }); + return NextResponse.json({ issue: { projectId, ...issue } }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to create issue" }, - { status: 500 }, - ); + const reason = err instanceof Error ? err.message : "Failed to create issue"; + recordActivityEvent({ + projectId, + source: "api", + kind: "api.issue_create_failed", + level: "error", + summary: `issue create failed: ${reason}`, + data: { reason }, + }); + return NextResponse.json({ error: reason }, { status: 500 }); } } diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts index 54890fa85..40e6c3972 100644 --- a/packages/web/src/app/api/orchestrators/route.ts +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -1,9 +1,12 @@ import { type NextRequest, NextResponse } from "next/server"; -import { generateOrchestratorPrompt } from "@aoagents/ao-core"; +import { generateOrchestratorPrompt, recordActivityEvent } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { validateIdentifier, validateConfiguredProject } from "@/lib/validation"; -function classifySpawnError(projectId: string, error: unknown): { +function classifySpawnError( + projectId: string, + error: unknown, +): { status: number; payload: Record; } { @@ -61,6 +64,14 @@ export async function POST(request: NextRequest) { ? await sessionManager.relaunchOrchestrator({ projectId, systemPrompt }) : await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); + recordActivityEvent({ + projectId, + sessionId: session.id, + source: "api", + kind: "api.orchestrator_spawn_requested", + summary: `orchestrator spawn requested for ${projectId}`, + }); + return NextResponse.json( { orchestrator: { diff --git a/packages/web/src/app/api/projects/[id]/route.ts b/packages/web/src/app/api/projects/[id]/route.ts index c98964402..3ac591fb9 100644 --- a/packages/web/src/app/api/projects/[id]/route.ts +++ b/packages/web/src/app/api/projects/[id]/route.ts @@ -9,6 +9,7 @@ import { loadConfig, loadGlobalConfig, loadLocalProjectConfigDetailed, + recordActivityEvent, repairWrappedLocalProjectConfig, unregisterProject, writeLocalProjectConfig, @@ -23,6 +24,7 @@ import { stopStaleWindowsPtyHosts } from "@/lib/windows-pty-cleanup"; export const dynamic = "force-dynamic"; const IDENTITY_FIELDS = new Set(["projectId", "path", "repo", "defaultBranch"]); +const EDITABLE_CONFIG_FIELDS = new Set(["agent", "runtime", "tracker", "scm", "reactions"]); function sanitizeString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -112,7 +114,10 @@ function getProjectState(projectId: string) { }; } -function degradedPayload(projectId: string, degradedProject: NonNullable["degradedProject"]>) { +function degradedPayload( + projectId: string, + degradedProject: NonNullable["degradedProject"]>, +) { return { error: degradedProject.resolveError, projectId, @@ -126,10 +131,7 @@ function degradedPayload(projectId: string, degradedProject: NonNullable }, -) { +export async function GET(_request: NextRequest, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; const state = getProjectState(id); @@ -172,10 +174,7 @@ export async function GET( } } -export async function PATCH( - request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { +export async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; const body = (await request.json().catch(() => null)) as Record | null; @@ -200,7 +199,10 @@ export async function PATCH( } const projectPath = state.globalEntry?.path; if (!projectPath) { - return NextResponse.json({ error: `Project "${id}" is missing a registry path.` }, { status: 409 }); + return NextResponse.json( + { error: `Project "${id}" is missing a registry path.` }, + { status: 409 }, + ); } const localConfigResult = loadLocalProjectConfigDetailed(projectPath); @@ -211,7 +213,8 @@ export async function PATCH( return NextResponse.json({ error: localConfigResult.error }, { status: 400 }); } - const currentConfig: LocalProjectConfig = localConfigResult.kind === "loaded" ? { ...localConfigResult.config } : {}; + const currentConfig: LocalProjectConfig = + localConfigResult.kind === "loaded" ? { ...localConfigResult.config } : {}; const nextConfig: LocalProjectConfig = { ...currentConfig, }; @@ -231,8 +234,7 @@ export async function PATCH( ...(body["tracker"] as Record), } as LocalProjectConfig["tracker"]) : undefined; - nextConfig.tracker = - nextTracker; + nextConfig.tracker = nextTracker; } if (hasOwn("scm")) { const nextScm = @@ -242,8 +244,7 @@ export async function PATCH( ...(body["scm"] as Record), } as LocalProjectConfig["scm"]) : undefined; - nextConfig.scm = - nextScm; + nextConfig.scm = nextScm; } if (hasOwn("reactions")) { nextConfig.reactions = @@ -261,6 +262,16 @@ export async function PATCH( invalidatePortfolioServicesCache(); revalidateProjectPaths(id); + // Record only changed *keys*, never values — config can contain tokens. + const changedKeys = Object.keys(body).filter((k) => EDITABLE_CONFIG_FIELDS.has(k)); + recordActivityEvent({ + projectId: id, + source: "api", + kind: "api.project_updated", + summary: `project updated: ${id}`, + data: { changedKeys }, + }); + return NextResponse.json({ ok: true }); } catch (error) { return NextResponse.json( @@ -270,19 +281,14 @@ export async function PATCH( } } -export async function PUT( - request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { +export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) { return PATCH(request, context); } -export async function DELETE( - _request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { +export async function DELETE(_request: NextRequest, context: { params: Promise<{ id: string }> }) { + let id: string | undefined; try { - const { id } = await context.params; + ({ id } = await context.params); const state = getProjectState(id); if (!state.globalEntry && !state.project && !state.degradedProject) { return NextResponse.json({ error: `Unknown project: ${id}` }, { status: 404 }); @@ -299,7 +305,8 @@ export async function DELETE( return NextResponse.json({ error: `Invalid project ID: ${id}` }, { status: 400 }); } - const workspacePluginName = state.project?.workspace ?? state.config.defaults.workspace ?? "worktree"; + const workspacePluginName = + state.project?.workspace ?? state.config.defaults.workspace ?? "worktree"; const { registry, sessionManager } = await getServices(); await stopProjectSessions(id, sessionManager); await stopStaleWindowsPtyHosts(projectDir); @@ -310,23 +317,34 @@ export async function DELETE( invalidatePortfolioServicesCache(); revalidateProjectPaths(id); + recordActivityEvent({ + projectId: id, + source: "api", + kind: "api.project_removed", + summary: `project removed: ${id}`, + data: { removedStorageDir: hadStorageDir }, + }); + return NextResponse.json({ ok: true, projectId: id, removedStorageDir: hadStorageDir, }); } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to delete project" }, - { status: 500 }, - ); + const reason = error instanceof Error ? error.message : "Failed to delete project"; + recordActivityEvent({ + projectId: id, + source: "api", + kind: "api.project_remove_failed", + level: "error", + summary: `project remove failed: ${reason}`, + data: { reason }, + }); + return NextResponse.json({ error: reason }, { status: 500 }); } } -export async function POST( - _request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { +export async function POST(_request: NextRequest, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; const state = getProjectState(id); @@ -337,7 +355,9 @@ export async function POST( return NextResponse.json({ error: "Project does not need repair." }, { status: 400 }); } - const isWrappedConfigError = state.degradedProject.resolveError.includes("wrapped projects: format"); + const isWrappedConfigError = state.degradedProject.resolveError.includes( + "wrapped projects: format", + ); if (!isWrappedConfigError) { return NextResponse.json( { error: "Automatic repair is not available for this degraded config." }, @@ -349,6 +369,13 @@ export async function POST( invalidatePortfolioServicesCache(); revalidateProjectPaths(id); + recordActivityEvent({ + projectId: id, + source: "api", + kind: "api.project_repaired", + summary: `project repaired: ${id}`, + }); + return NextResponse.json({ ok: true, repaired: true, projectId: id }); } catch (error) { return NextResponse.json( diff --git a/packages/web/src/app/api/projects/reload/route.ts b/packages/web/src/app/api/projects/reload/route.ts index e1a13f333..ecacc9dde 100644 --- a/packages/web/src/app/api/projects/reload/route.ts +++ b/packages/web/src/app/api/projects/reload/route.ts @@ -1,5 +1,10 @@ import { NextResponse } from "next/server"; -import { ConfigNotFoundError, getGlobalConfigPath, loadConfig } from "@aoagents/ao-core"; +import { + ConfigNotFoundError, + getGlobalConfigPath, + loadConfig, + recordActivityEvent, +} from "@aoagents/ao-core"; import { invalidatePortfolioServicesCache } from "@/lib/services"; export const dynamic = "force-dynamic"; @@ -25,10 +30,19 @@ export async function POST() { invalidatePortfolioServicesCache(); const config = loadReloadConfig(); + const projectCount = Object.keys(config.projects).length; + const degradedCount = Object.keys(config.degradedProjects).length; + recordActivityEvent({ + source: "api", + kind: "api.config_reloaded", + summary: `config reloaded: ${projectCount} projects, ${degradedCount} degraded`, + data: { projectCount, degradedCount }, + }); + return NextResponse.json({ reloaded: true, - projectCount: Object.keys(config.projects).length, - degradedCount: Object.keys(config.degradedProjects).length, + projectCount, + degradedCount, }); } catch (error) { return NextResponse.json( diff --git a/packages/web/src/app/api/projects/route.ts b/packages/web/src/app/api/projects/route.ts index 2bafa5212..ca9c1c9ba 100644 --- a/packages/web/src/app/api/projects/route.ts +++ b/packages/web/src/app/api/projects/route.ts @@ -8,6 +8,7 @@ import { getGlobalConfigPath, loadConfig, migrateToGlobalConfig, + recordActivityEvent, registerProjectInGlobalConfig, } from "@aoagents/ao-core"; import { revalidatePath } from "next/cache"; @@ -108,6 +109,12 @@ export async function POST(request: NextRequest) { ); invalidatePortfolioServicesCache(); revalidatePortfolioPaths(registeredProjectId); + recordActivityEvent({ + projectId: registeredProjectId, + source: "api", + kind: "api.project_added", + summary: `project added: ${registeredProjectId}`, + }); return NextResponse.json({ ok: true, projectId: registeredProjectId }, { status: 201 }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to add project"; @@ -124,6 +131,14 @@ export async function POST(request: NextRequest) { if (pathAlreadyRegistered) { const existingProjectId = pathAlreadyRegistered[1]; const suggestedProjectId = generateExternalId(resolvedPath); + recordActivityEvent({ + projectId, + source: "api", + kind: "api.project_add_rejected", + level: "warn", + summary: `project add rejected: path already registered`, + data: { reason: "path_already_registered", existingProjectId, statusCode: 409 }, + }); return NextResponse.json( { error: message, @@ -138,6 +153,14 @@ export async function POST(request: NextRequest) { if (idAlreadyRegistered) { const existingProjectId = idAlreadyRegistered[1]; const suggestedProjectId = generateExternalId(resolvedPath); + recordActivityEvent({ + projectId, + source: "api", + kind: "api.project_add_rejected", + level: "warn", + summary: `project add rejected: id already registered`, + data: { reason: "id_already_registered", existingProjectId, statusCode: 409 }, + }); return NextResponse.json( { error: message, diff --git a/packages/web/src/app/api/prs/[id]/merge/route.ts b/packages/web/src/app/api/prs/[id]/merge/route.ts index 9ab5aed1a..c996a60f9 100644 --- a/packages/web/src/app/api/prs/[id]/merge/route.ts +++ b/packages/web/src/app/api/prs/[id]/merge/route.ts @@ -1,4 +1,5 @@ import { type NextRequest } from "next/server"; +import { recordActivityEvent, type OrchestratorConfig } from "@aoagents/ao-core"; import { getServices, getSCM } from "@/lib/services"; import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; @@ -11,15 +12,21 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< return jsonWithCorrelation({ error: "Invalid PR number" }, { status: 400 }, correlationId); } const prNumber = Number(id); + let configForObservation: OrchestratorConfig | undefined; + let projectId: string | undefined; + let sessionId: string | undefined; try { const { config, registry, sessionManager } = await getServices(); + configForObservation = config; const sessions = await sessionManager.list(); const session = sessions.find((s) => s.pr?.number === prNumber); if (!session?.pr) { return jsonWithCorrelation({ error: "PR not found" }, { status: 404 }, correlationId); } + projectId = session.projectId; + sessionId = session.id; const project = config.projects[session.projectId]; const scm = getSCM(registry, project); @@ -34,6 +41,15 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< // Validate PR is in a mergeable state const state = await scm.getPRState(session.pr); if (state !== "open") { + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "api", + kind: "api.pr_merge_rejected", + level: "warn", + summary: `PR ${prNumber} merge rejected: state is ${state}`, + data: { prNumber, prState: state, statusCode: 409 }, + }); return jsonWithCorrelation( { error: `PR is ${state}, not open` }, { status: 409 }, @@ -43,6 +59,15 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< const mergeability = await scm.getMergeability(session.pr); if (!mergeability.mergeable) { + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "api", + kind: "api.pr_merge_rejected", + level: "warn", + summary: `PR ${prNumber} merge rejected: not mergeable`, + data: { prNumber, blockers: mergeability.blockers, statusCode: 422 }, + }); return jsonWithCorrelation( { error: "PR is not mergeable", blockers: mergeability.blockers }, { status: 422 }, @@ -63,13 +88,22 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< sessionId: session.id, data: { prNumber }, }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "api", + kind: "api.pr_merge_requested", + summary: `PR ${prNumber} merge requested`, + data: { prNumber, method: "squash" }, + }); return jsonWithCorrelation( { ok: true, prNumber, method: "squash" }, { status: 200 }, correlationId, ); } catch (err) { - const { config } = await getServices().catch(() => ({ config: undefined })); + const config = + configForObservation ?? (await getServices().catch(() => ({ config: undefined }))).config; if (config) { recordApiObservation({ config, @@ -79,10 +113,22 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< startedAt, outcome: "failure", statusCode: 500, + projectId, + sessionId, reason: err instanceof Error ? err.message : "Failed to merge PR", data: { prNumber }, }); } + const reason = err instanceof Error ? err.message : "Failed to merge PR"; + recordActivityEvent({ + projectId, + sessionId, + source: "api", + kind: "api.pr_merge_failed", + level: "error", + summary: `PR ${prNumber} merge failed: ${reason}`, + data: { prNumber, reason }, + }); return jsonWithCorrelation( { error: err instanceof Error ? err.message : "Failed to merge PR" }, { status: 500 }, diff --git a/packages/web/src/app/api/sessions/[id]/kill/route.ts b/packages/web/src/app/api/sessions/[id]/kill/route.ts index cf4a6b117..a71bac6e9 100644 --- a/packages/web/src/app/api/sessions/[id]/kill/route.ts +++ b/packages/web/src/app/api/sessions/[id]/kill/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { validateIdentifier } from "@/lib/validation"; import { getServices } from "@/lib/services"; -import { SessionNotFoundError } from "@aoagents/ao-core"; +import { SessionNotFoundError, recordActivityEvent } from "@aoagents/ao-core"; import { getCorrelationId, jsonWithCorrelation, @@ -34,6 +34,13 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< projectId, sessionId: id, }); + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_kill_requested", + summary: `session kill requested: ${id}`, + }); return jsonWithCorrelation({ ok: true, sessionId: id }, { status: 200 }, correlationId); } catch (err) { if (err instanceof SessionNotFoundError) { @@ -56,6 +63,15 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< }); } const msg = err instanceof Error ? err.message : "Failed to kill session"; + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_kill_failed", + level: "error", + summary: `session kill failed: ${msg}`, + data: { reason: msg }, + }); return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); } } diff --git a/packages/web/src/app/api/sessions/[id]/message/route.ts b/packages/web/src/app/api/sessions/[id]/message/route.ts index 463927af5..1df3e4bae 100644 --- a/packages/web/src/app/api/sessions/[id]/message/route.ts +++ b/packages/web/src/app/api/sessions/[id]/message/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { getServices } from "@/lib/services"; import { stripControlChars, validateIdentifier, validateString } from "@/lib/validation"; -import { SessionNotFoundError } from "@aoagents/ao-core"; +import { SessionNotFoundError, recordActivityEvent } from "@aoagents/ao-core"; import { getCorrelationId, jsonWithCorrelation, @@ -79,6 +79,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ sessionId: id, data: { messageLength: message.length }, }); + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_message_sent", + summary: `message sent to session ${id}`, + data: { messageLength: message.length }, + }); return jsonWithCorrelation({ success: true }, { status: 200 }, correlationId); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); @@ -98,6 +106,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (err instanceof SessionNotFoundError) { return jsonWithCorrelation({ error: err.message }, { status: 404 }, correlationId); } + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_message_failed", + level: "error", + summary: `session message failed: ${errorMsg}`, + data: { messageLength: message.length, reason: errorMsg }, + }); console.error("Failed to send message:", errorMsg); return jsonWithCorrelation( { error: `Failed to send message: ${errorMsg}` }, diff --git a/packages/web/src/app/api/sessions/[id]/remap/route.ts b/packages/web/src/app/api/sessions/[id]/remap/route.ts index d71ea32f9..5c5f0bb83 100644 --- a/packages/web/src/app/api/sessions/[id]/remap/route.ts +++ b/packages/web/src/app/api/sessions/[id]/remap/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { validateIdentifier } from "@/lib/validation"; import { getServices } from "@/lib/services"; -import { SessionNotFoundError } from "@aoagents/ao-core"; +import { SessionNotFoundError, recordActivityEvent } from "@aoagents/ao-core"; import { getCorrelationId, jsonWithCorrelation, @@ -33,6 +33,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ projectId, sessionId: id, }); + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_remap_requested", + summary: `session remap requested: ${id}`, + }); return jsonWithCorrelation( { ok: true, sessionId: id, opencodeSessionId }, { status: 200 }, @@ -74,6 +81,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ reason: msg, }); } + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_remap_failed", + level: "warn", + summary: `session remap failed: ${msg}`, + data: { reason: msg, statusCode: 422 }, + }); return jsonWithCorrelation({ error: msg }, { status: 422 }, correlationId); } if (config) { @@ -90,6 +106,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ reason: msg, }); } + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_remap_failed", + level: "error", + summary: `session remap failed: ${msg}`, + data: { reason: msg, statusCode: 500 }, + }); return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); } } diff --git a/packages/web/src/app/api/sessions/[id]/restore/route.ts b/packages/web/src/app/api/sessions/[id]/restore/route.ts index 5372b9012..7c1bea560 100644 --- a/packages/web/src/app/api/sessions/[id]/restore/route.ts +++ b/packages/web/src/app/api/sessions/[id]/restore/route.ts @@ -6,6 +6,8 @@ import { SessionNotRestorableError, WorkspaceMissingError, SessionNotFoundError, + recordActivityEvent, + type OrchestratorConfig, } from "@aoagents/ao-core"; import { getCorrelationId, @@ -24,9 +26,13 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< return jsonWithCorrelation({ error: idErr }, { status: 400 }, correlationId); } + let configForAttribution: OrchestratorConfig | undefined; + let projectIdForAttribution: string | undefined; + try { const { config, sessionManager } = await getServices(); - const projectId = resolveProjectIdForSessionId(config, id); + configForAttribution = config; + projectIdForAttribution = resolveProjectIdForSessionId(config, id); const restored = await sessionManager.restore(id); recordApiObservation({ @@ -37,9 +43,16 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< startedAt, outcome: "success", statusCode: 200, - projectId: restored.projectId ?? projectId, + projectId: restored.projectId ?? projectIdForAttribution, sessionId: id, }); + recordActivityEvent({ + projectId: restored.projectId ?? projectIdForAttribution, + sessionId: id, + source: "api", + kind: "api.session_restore_requested", + summary: `session restore requested: ${id}`, + }); return jsonWithCorrelation( { @@ -54,29 +67,61 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< if (err instanceof SessionNotFoundError) { return jsonWithCorrelation({ error: err.message }, { status: 404 }, correlationId); } + if (!configForAttribution) { + const serviceContext = await getServices().catch(() => undefined); + configForAttribution = serviceContext?.config; + projectIdForAttribution = configForAttribution + ? resolveProjectIdForSessionId(configForAttribution, id) + : undefined; + } if (err instanceof SessionNotRestorableError) { + recordActivityEvent({ + projectId: projectIdForAttribution, + sessionId: id, + source: "api", + kind: "api.session_restore_failed", + level: "warn", + summary: `session restore failed: ${err.message}`, + data: { reason: err.message, statusCode: 409 }, + }); return jsonWithCorrelation({ error: err.message }, { status: 409 }, correlationId); } if (err instanceof WorkspaceMissingError) { + recordActivityEvent({ + projectId: projectIdForAttribution, + sessionId: id, + source: "api", + kind: "api.session_restore_failed", + level: "warn", + summary: `session restore failed: ${err.message}`, + data: { reason: err.message, statusCode: 422 }, + }); return jsonWithCorrelation({ error: err.message }, { status: 422 }, correlationId); } - const { config } = await getServices().catch(() => ({ config: undefined })); - const projectId = config ? resolveProjectIdForSessionId(config, id) : undefined; - if (config) { + if (configForAttribution) { recordApiObservation({ - config, + config: configForAttribution, method: "POST", path: "/api/sessions/[id]/restore", correlationId, startedAt, outcome: "failure", statusCode: 500, - projectId, + projectId: projectIdForAttribution, sessionId: id, reason: err instanceof Error ? err.message : "Failed to restore session", }); } const msg = err instanceof Error ? err.message : "Failed to restore session"; + recordActivityEvent({ + projectId: projectIdForAttribution, + sessionId: id, + source: "api", + kind: "api.session_restore_failed", + level: "error", + summary: `session restore failed: ${msg}`, + data: { reason: msg, statusCode: 500 }, + }); return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); } } diff --git a/packages/web/src/app/api/sessions/[id]/send/route.ts b/packages/web/src/app/api/sessions/[id]/send/route.ts index 44a8198df..8c0b67cd2 100644 --- a/packages/web/src/app/api/sessions/[id]/send/route.ts +++ b/packages/web/src/app/api/sessions/[id]/send/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { validateIdentifier, validateString, stripControlChars } from "@/lib/validation"; import { getServices } from "@/lib/services"; -import { SessionNotFoundError } from "@aoagents/ao-core"; +import { SessionNotFoundError, recordActivityEvent } from "@aoagents/ao-core"; import { getCorrelationId, jsonWithCorrelation, @@ -55,6 +55,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ sessionId: id, data: { messageLength: message.length }, }); + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_message_sent", + summary: `message sent to session ${id}`, + data: { messageLength: message.length }, + }); return jsonWithCorrelation( { ok: true, sessionId: id, message }, { status: 200 }, @@ -82,6 +90,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }); } const msg = err instanceof Error ? err.message : "Failed to send message"; + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_message_failed", + level: "error", + summary: `session message failed: ${msg}`, + data: { messageLength: message.length, reason: msg }, + }); return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); } } diff --git a/packages/web/src/app/api/setup-labels/route.ts b/packages/web/src/app/api/setup-labels/route.ts index 7b0c32465..75a3b597e 100644 --- a/packages/web/src/app/api/setup-labels/route.ts +++ b/packages/web/src/app/api/setup-labels/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { recordActivityEvent } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -42,6 +43,15 @@ export async function POST() { } } + const created = results.filter((r) => r.status === "created").length; + const exists = results.filter((r) => r.status === "exists").length; + recordActivityEvent({ + source: "api", + kind: "api.labels_setup", + summary: `labels setup complete: ${created} created, ${exists} exists`, + data: { created, exists, total: results.length }, + }); + return NextResponse.json({ results }); } catch (err) { return NextResponse.json( diff --git a/packages/web/src/app/api/spawn/route.ts b/packages/web/src/app/api/spawn/route.ts index 192a4a557..8fcc37fe5 100644 --- a/packages/web/src/app/api/spawn/route.ts +++ b/packages/web/src/app/api/spawn/route.ts @@ -1,4 +1,5 @@ import { type NextRequest } from "next/server"; +import { recordActivityEvent } from "@aoagents/ao-core"; import { validateIdentifier, validateString, validateConfiguredProject } from "@/lib/validation"; import { getServices } from "@/lib/services"; import { sessionToDashboard } from "@/lib/serialize"; @@ -50,6 +51,14 @@ export async function POST(request: NextRequest) { reason: projectErr, data: { issueId: body.issueId }, }); + recordActivityEvent({ + projectId, + source: "api", + kind: "api.session_spawn_rejected", + level: "warn", + summary: `session spawn rejected: ${projectErr}`, + data: { reason: "project_not_configured" }, + }); return jsonWithCorrelation({ error: projectErr }, { status: 404 }, correlationId); } @@ -75,6 +84,17 @@ export async function POST(request: NextRequest) { sessionId: session.id, data: { issueId: session.issueId }, }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "api", + kind: "api.session_spawn_requested", + summary: `session spawn requested for ${session.projectId}`, + data: { + issueId: session.issueId ?? undefined, + hasPrompt: Boolean(prompt), + }, + }); return jsonWithCorrelation( { session: sessionToDashboard(session) }, diff --git a/packages/web/src/app/api/verify/route.ts b/packages/web/src/app/api/verify/route.ts index ef2ab0661..b713aaf52 100644 --- a/packages/web/src/app/api/verify/route.ts +++ b/packages/web/src/app/api/verify/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getVerifyIssues, getServices } from "@/lib/services"; import { validateConfiguredProject } from "@/lib/validation"; -import type { Tracker } from "@aoagents/ao-core"; +import { recordActivityEvent, type Tracker } from "@aoagents/ao-core"; export const dynamic = "force-dynamic"; @@ -25,6 +25,9 @@ export async function GET() { * Body: { issueId: string, projectId: string, action: "verify" | "fail", comment?: string } */ export async function POST(req: NextRequest) { + let issueId: string | undefined; + let projectId: string | undefined; + let action: "verify" | "fail" | undefined; try { const body = (await req.json().catch(() => null)) as | { @@ -37,12 +40,13 @@ export async function POST(req: NextRequest) { if (!body) { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - const { issueId, projectId, action, comment } = body as { + let comment: string | undefined; + ({ issueId, projectId, action, comment } = body as { issueId: string; projectId: string; action: "verify" | "fail"; comment?: string; - }; + }); if (!issueId || !projectId || !action) { return NextResponse.json( @@ -96,11 +100,25 @@ export async function POST(req: NextRequest) { ); } + recordActivityEvent({ + projectId, + source: "api", + kind: "api.issue_verified", + summary: `issue ${issueId} ${action}`, + data: { issueId, action }, + }); + return NextResponse.json({ ok: true }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to update issue" }, - { status: 500 }, - ); + const reason = err instanceof Error ? err.message : "Failed to update issue"; + recordActivityEvent({ + projectId, + source: "api", + kind: "api.issue_verify_failed", + level: "error", + summary: `issue verify failed: ${reason}`, + data: { issueId, action, reason }, + }); + return NextResponse.json({ error: reason }, { status: 500 }); } } From 73bed33c2ebe368d51c5b18a36307dab430f7ab4 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Mon, 18 May 2026 18:54:00 +0530 Subject: [PATCH 14/26] feat(web): activity events for webhooks and mux WebSocket (#1656) (#1693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): activity events for webhooks and mux WebSocket (#1656) Closes #1656 — adds the 10 activity events called out in the issue, covering webhook ingress (4) and the mux WebSocket terminal server (6). Builds on the ActivityEvent infrastructure landed in #1620. Webhook events (api source): - api.webhook_unverified (warn) — 401 signature verification failure - api.webhook_rejected (warn) — 413 payload exceeds maxBodyBytes - api.webhook_received (info|warn) — 202 success, with parse/lifecycle error counts - api.webhook_failed (error) — 500 outer catch / pipeline crash Mux WS events (ui source — Node-side server only): - ui.terminal_connected — one per mux WS connection - ui.terminal_disconnected — one per close - ui.terminal_heartbeat_lost (warn) — once on 3 missed pongs (was console-only) - ui.terminal_pty_lost (warn) — fires only when subscribers are still attached, distinguishing "PTY actually died" from "user closed browser" - ui.terminal_protocol_error (warn) — invalid mux client message - ui.session_broadcast_failed (warn) — emitted on the healthy→failing transition only; re-arms after a successful poll so a long outage yields one event, not 20/min Invariants honored: no raw payloads or signatures in `data`, no client IPs in `summary` (kept in `data` only), no per-keystroke / per-pong fan-out — only on state transitions. `api.webhook_unverified` is the security-audit event; data captures `slug` and `remoteAddr` but never the failed signature. Co-Authored-By: Claude Opus 4.7 * fix(web): harden webhook and PTY activity events * chore(ci): retrigger checks * fix(web): record PTY loss across mux exit paths --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: whoisasx --- .changeset/activity-events-webhooks-mux.md | 18 + packages/core/src/activity-events.ts | 12 + .../server/__tests__/mux-websocket.test.ts | 526 +++++++++++++++++- packages/web/server/mux-websocket.ts | 237 +++++++- .../web/src/__tests__/webhook-route.test.ts | 328 +++++++++++ .../src/app/api/webhooks/[...slug]/route.ts | 108 +++- packages/web/src/lib/scm-webhooks.ts | 2 +- 7 files changed, 1210 insertions(+), 21 deletions(-) create mode 100644 .changeset/activity-events-webhooks-mux.md create mode 100644 packages/web/src/__tests__/webhook-route.test.ts diff --git a/.changeset/activity-events-webhooks-mux.md b/.changeset/activity-events-webhooks-mux.md new file mode 100644 index 000000000..6f4dcc73f --- /dev/null +++ b/.changeset/activity-events-webhooks-mux.md @@ -0,0 +1,18 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-web": minor +--- + +Wire activity events into webhook ingress and the mux WebSocket terminal server (sub-issue of #1511, follows #1620). + +- `api.webhook_unverified` (warn) — signature verification failed; data includes `slug`, `remoteAddr`, `candidateCount` (never the failed signature) +- `api.webhook_rejected` (warn) — payload exceeded `maxBodyBytes`; data includes counts and `maxBodyBytes` (never the body) +- `api.webhook_received` (info|warn) — accepted webhook; data includes `projectIds`, `matchedSessions`, `parseErrorCount`, `lifecycleErrorCount` (never the body) +- `api.webhook_failed` (error) — outer pipeline crash with `errorMessage` +- `ui.terminal_connected` / `ui.terminal_disconnected` — one event per mux WS connection lifecycle +- `ui.terminal_heartbeat_lost` (warn) — fires once on 3 missed pongs (was console-only) +- `ui.terminal_pty_lost` (warn) — fires when PTY exits with subscribers attached (distinguishes "PTY died" from "user closed browser") +- `ui.terminal_protocol_error` (warn) — invalid mux client message +- `ui.session_broadcast_failed` (warn) — emitted on the healthy→failing transition only (re-arms after a successful poll), so a long outage produces one event, not 20/min + +`api.webhook_unverified` is the security-audit event; treat 401s on webhooks as a signal worth retaining for the full 7-day window. diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index 789e2616d..aafcc4f42 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -71,6 +71,18 @@ export type ActivityEventKind = | "detecting.escalated" // Report watcher | "report_watcher.triggered" + // Webhook ingress (api source) + | "api.webhook_unverified" + | "api.webhook_rejected" + | "api.webhook_received" + | "api.webhook_failed" + // WebSocket terminal mux (ui source — Node-side server only) + | "ui.terminal_connected" + | "ui.terminal_disconnected" + | "ui.terminal_heartbeat_lost" + | "ui.terminal_pty_lost" + | "ui.terminal_protocol_error" + | "ui.session_broadcast_failed" // Recovery/forensic instrumentation | "recovery.session_failed" | "recovery.action_failed" diff --git a/packages/web/server/__tests__/mux-websocket.test.ts b/packages/web/server/__tests__/mux-websocket.test.ts index 48f38e96f..3e9c41377 100644 --- a/packages/web/server/__tests__/mux-websocket.test.ts +++ b/packages/web/server/__tests__/mux-websocket.test.ts @@ -1,15 +1,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; +import type { Socket } from "node:net"; +import { WebSocket } from "ws"; import type { SessionBroadcaster as SessionBroadcasterType } from "../mux-websocket"; // vi.mock factories run before module-level statements. Hoist the mock // fns so the factories close over the same instances the tests use. -const { mockSpawn, mockPtySpawn, mockTmuxHasSession } = vi.hoisted(() => ({ +const { mockSpawn, mockPtySpawn, mockTmuxHasSession, recordActivityEvent } = vi.hoisted(() => ({ mockSpawn: vi.fn(), mockPtySpawn: vi.fn(), mockTmuxHasSession: vi.fn(), + recordActivityEvent: vi.fn(), })); +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + recordActivityEvent: (event: unknown) => recordActivityEvent(event), + }; +}); + vi.mock("node:child_process", async (importOriginal) => { const actual = (await importOriginal()) as Record; const spawnFn = (...args: unknown[]) => mockSpawn(...args); @@ -34,21 +45,72 @@ vi.mock("../tmux-utils.js", () => ({ findTmux: () => "/usr/bin/tmux", validateSessionId: () => true, resolveTmuxSession: () => "ao-177", + resolvePipePath: () => null, tmuxHasSession: (...args: unknown[]) => mockTmuxHasSession(...args), })); -const { SessionBroadcaster, TerminalManager } = await import("../mux-websocket"); +const { SessionBroadcaster, TerminalManager, createMuxWebSocket, handleWindowsPipeMessage } = + await import("../mux-websocket"); // Mock global fetch const mockFetch = vi.fn(); global.fetch = mockFetch; +type MockPty = { + dataHandlers: Array<(data: string) => void>; + exitHandlers: Array<(event: { exitCode: number }) => void>; + onData: ReturnType; + onExit: ReturnType; + write: ReturnType; + resize: ReturnType; + kill: ReturnType; + emitData: (data: string) => void; + emitExit: (exitCode: number) => Promise; +}; + +const ptyInstances: MockPty[] = []; + +function createMockPty(): MockPty { + const pty = {} as MockPty; + pty.dataHandlers = []; + pty.exitHandlers = []; + pty.onData = vi.fn((handler: (data: string) => void) => { + pty.dataHandlers.push(handler); + }); + pty.onExit = vi.fn((handler: (event: { exitCode: number }) => void) => { + pty.exitHandlers.push(handler); + }); + pty.write = vi.fn(); + pty.resize = vi.fn(); + pty.kill = vi.fn(); + pty.emitData = (data: string) => { + for (const handler of pty.dataHandlers) handler(data); + }; + pty.emitExit = async (exitCode: number) => { + await Promise.all([...pty.exitHandlers].map((handler) => handler({ exitCode }))); + }; + ptyInstances.push(pty); + return pty; +} + +function resetPtyMock(): void { + ptyInstances.length = 0; + mockSpawn.mockReset(); + mockPtySpawn.mockReset(); + mockTmuxHasSession.mockReset(); + mockTmuxHasSession.mockResolvedValue(true); + mockSpawn.mockImplementation(() => new EventEmitter()); + mockPtySpawn.mockImplementation(createMockPty); +} + describe("SessionBroadcaster", () => { let broadcaster: SessionBroadcasterType; beforeEach(() => { vi.useFakeTimers(); mockFetch.mockReset(); + recordActivityEvent.mockClear(); + resetPtyMock(); broadcaster = new SessionBroadcaster("3000"); }); @@ -262,6 +324,451 @@ describe("SessionBroadcaster", () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); }); + + describe("ui.session_broadcast_failed activity events", () => { + function failedKinds(): string[] { + return recordActivityEvent.mock.calls + .map(([e]) => (e as { kind: string }).kind) + .filter((k) => k === "ui.session_broadcast_failed"); + } + + it("emits exactly once on the healthy→failing transition", async () => { + // First fetch fails — triggers emission + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + // Second fetch (3s later) also fails — should NOT emit again + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(3010); + + expect(failedKinds()).toEqual(["ui.session_broadcast_failed"]); + }); + + it("re-arms after recovery (success → failure emits again)", async () => { + // fail → succeed → fail + mockFetch.mockRejectedValueOnce(new Error("net down")); + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ sessions: [] }) }); + mockFetch.mockRejectedValueOnce(new Error("net down again")); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(3010); // poll #1 → success + await vi.advanceTimersByTimeAsync(3010); // poll #2 → failure + + expect(failedKinds().length).toBe(2); + }); + + it("emits with source=ui, level=warn, and the failure URL in data", async () => { + mockFetch.mockRejectedValueOnce(new Error("ETIMEDOUT")); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "ui", + kind: "ui.session_broadcast_failed", + level: "warn", + }), + ); + const call = recordActivityEvent.mock.calls.find( + ([e]) => (e as { kind: string }).kind === "ui.session_broadcast_failed", + )![0] as { data: Record }; + expect(call.data["url"]).toContain("/api/sessions/patches"); + expect(call.data["errorMessage"]).toContain("ETIMEDOUT"); + }); + + it("includes httpStatus when fetch returns non-OK response", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 503 }); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + + const call = recordActivityEvent.mock.calls.find( + ([e]) => (e as { kind: string }).kind === "ui.session_broadcast_failed", + )![0] as { data: Record }; + expect(call.data["httpStatus"]).toBe(503); + }); + }); +}); + +// ── Connection-level activity events ────────────────────────────────── +// These verify ui.terminal_* events fire at the right WS lifecycle points. +// We exercise the connection handler directly by emitting "connection" on +// the WebSocketServer and feeding a fake ws + IncomingMessage stand-in. + +class FakeWS extends EventEmitter { + readyState: 0 | 1 | 2 | 3 = WebSocket.OPEN; + bufferedAmount = 0; + ping = vi.fn(); + terminate = vi.fn(() => { + this.readyState = WebSocket.CLOSED; + }); + send = vi.fn(); +} + +class FakePipeSocket extends EventEmitter { + write = vi.fn(); + end = vi.fn(() => { + this.emit("close"); + }); + destroy = vi.fn(() => { + this.emit("close"); + }); +} + +function makeFakeRequest(opts?: { remoteAddress?: string; xff?: string }) { + return { + headers: opts?.xff ? { "x-forwarded-for": opts.xff } : {}, + socket: { remoteAddress: opts?.remoteAddress ?? "127.0.0.1" }, + }; +} + +describe("mux WebSocket connection events", () => { + beforeEach(() => { + vi.useFakeTimers(); + recordActivityEvent.mockClear(); + resetPtyMock(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + function emitConnection(opts?: Parameters[0]) { + const wss = createMuxWebSocket(); + if (!wss) { + throw new Error("mux WS server not created — node-pty unavailable"); + } + const ws = new FakeWS(); + wss.emit("connection", ws as unknown as WebSocket, makeFakeRequest(opts)); + return { wss, ws }; + } + + function findEvent(kind: string): { data: Record } | undefined { + const found = recordActivityEvent.mock.calls.find( + ([e]) => (e as { kind: string }).kind === kind, + ); + return found?.[0] as { data: Record } | undefined; + } + + it("emits ui.terminal_connected on a new mux connection (with remoteAddr)", () => { + emitConnection({ xff: "198.51.100.5, 10.0.0.1" }); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ source: "ui", kind: "ui.terminal_connected" }), + ); + const evt = findEvent("ui.terminal_connected")!; + expect(evt.data["remoteAddr"]).toBe("198.51.100.5"); + }); + + it("emits ui.terminal_disconnected exactly once on close", () => { + const { ws } = emitConnection(); + recordActivityEvent.mockClear(); + + ws.emit("close", 1000, Buffer.from("normal")); + + const calls = recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_disconnected", + ); + expect(calls.length).toBe(1); + const evt = findEvent("ui.terminal_disconnected")!; + expect(evt.data["code"]).toBe(1000); + expect(evt.data["reason"]).toBe("normal"); + }); + + it("emits ui.terminal_heartbeat_lost once on 3 missed pongs and terminates", () => { + const { ws } = emitConnection(); + recordActivityEvent.mockClear(); + + // Each 15s interval sends a ping and increments missedPongs by 1. + // After 3 ticks (45s) it should hit MAX_MISSED_PONGS=3 and terminate. + vi.advanceTimersByTime(15_000); + vi.advanceTimersByTime(15_000); + vi.advanceTimersByTime(15_000); + + const calls = recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_heartbeat_lost", + ); + expect(calls.length).toBe(1); + expect(ws.terminate).toHaveBeenCalled(); + + // Issue invariant: at most one emit per state change — extra ticks must not + // produce another event. + vi.advanceTimersByTime(15_000); + expect( + recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_heartbeat_lost", + ).length, + ).toBe(1); + }); + + it("does NOT emit heartbeat_lost when pong arrives before 3 missed pings", () => { + const { ws } = emitConnection(); + recordActivityEvent.mockClear(); + + vi.advanceTimersByTime(15_000); // missedPongs=1 + ws.emit("pong"); // resets to 0 + vi.advanceTimersByTime(15_000); // missedPongs=1 + vi.advanceTimersByTime(15_000); // missedPongs=2 + + expect( + recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_heartbeat_lost", + ).length, + ).toBe(0); + expect(ws.terminate).not.toHaveBeenCalled(); + }); + + it("emits ui.terminal_protocol_error on malformed client message", () => { + const { ws } = emitConnection(); + recordActivityEvent.mockClear(); + + ws.emit("message", Buffer.from("not-json{{{")); + + const calls = recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_protocol_error", + ); + expect(calls.length).toBe(1); + const evt = findEvent("ui.terminal_protocol_error")!; + expect(evt.data["errorMessage"]).toBeTruthy(); + }); +}); + +describe("Windows pipe ui.terminal_pty_lost activity events", () => { + beforeEach(() => { + recordActivityEvent.mockClear(); + }); + + function framedMessage(type: number, payload: unknown): Buffer { + const body = Buffer.from(JSON.stringify(payload), "utf-8"); + const header = Buffer.alloc(5); + header.writeUInt8(type, 0); + header.writeUInt32BE(body.length, 1); + return Buffer.concat([header, body]); + } + + function openPipe() { + const ws = new FakeWS(); + const pipe = new FakePipeSocket(); + const winPipes = new Map(); + const winPipeBuffers = new Map(); + const deps = { + connect: vi.fn(() => pipe as unknown as Socket), + resolvePipePath: vi.fn(() => "\\\\.\\pipe\\ao-pty-app-1"), + }; + + handleWindowsPipeMessage( + { id: "app-1", type: "open", projectId: "proj-1" }, + ws, + winPipes, + winPipeBuffers, + deps, + ); + pipe.emit("connect"); + recordActivityEvent.mockClear(); + ws.send.mockClear(); + + return { ws, pipe, winPipes, winPipeBuffers, deps }; + } + + function ptyLostEvents(): Array<{ data: Record; sessionId?: string }> { + return recordActivityEvent.mock.calls + .map(([e]) => e as { kind: string; data: Record; sessionId?: string }) + .filter((event) => event.kind === "ui.terminal_pty_lost"); + } + + it("emits ui.terminal_pty_lost when the PTY host pipe closes while the socket is open", () => { + const { ws, pipe } = openPipe(); + + pipe.emit("close"); + + expect(ptyLostEvents()).toEqual([ + expect.objectContaining({ + sessionId: "app-1", + data: expect.objectContaining({ + sessionId: "app-1", + transport: "windows_pipe", + reason: "pipe_closed", + }), + }), + ]); + expect(ws.send).toHaveBeenCalledWith( + JSON.stringify({ ch: "terminal", id: "app-1", type: "exited", code: 0, projectId: "proj-1" }), + ); + }); + + it("emits ui.terminal_pty_lost when the PTY host reports not alive", () => { + const { ws, pipe } = openPipe(); + + pipe.emit("data", framedMessage(0x07, { alive: false })); + pipe.emit("close"); + + const events = ptyLostEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + sessionId: "app-1", + data: expect.objectContaining({ + sessionId: "app-1", + transport: "windows_pipe", + reason: "host_not_alive", + }), + }), + ); + expect(ws.send).toHaveBeenCalledWith( + JSON.stringify({ ch: "terminal", id: "app-1", type: "exited", code: 0, projectId: "proj-1" }), + ); + }); + + it("does not emit ui.terminal_pty_lost for an intentional client close", () => { + const { ws, winPipes, winPipeBuffers, deps } = openPipe(); + + handleWindowsPipeMessage( + { id: "app-1", type: "close", projectId: "proj-1" }, + ws, + winPipes, + winPipeBuffers, + deps, + ); + + expect(ptyLostEvents()).toEqual([]); + }); +}); + +describe("TerminalManager ui.terminal_pty_lost activity events", () => { + beforeEach(() => { + recordActivityEvent.mockClear(); + resetPtyMock(); + }); + + function ptyLostEvents(): Array<{ data: Record; sessionId?: string }> { + return recordActivityEvent.mock.calls + .map(([e]) => e as { kind: string; data: Record; sessionId?: string }) + .filter((event) => event.kind === "ui.terminal_pty_lost"); + } + + it("emits ui.terminal_pty_lost when a subscribed PTY exits and reattach fails", async () => { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const pty = ptyInstances[0]; + expect(pty).toBeDefined(); + + manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + mockPtySpawn.mockImplementationOnce(() => { + throw new Error("reattach unavailable"); + }); + + await pty!.emitExit(9); + + const events = ptyLostEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + sessionId: "app-1", + data: expect.objectContaining({ + sessionId: "app-1", + exitCode: 9, + subscriberCount: 1, + reattachError: "reattach unavailable", + }), + }), + ); + }); + + it("emits ui.terminal_pty_lost when a subscribed PTY exits and reattach succeeds", async () => { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const pty = ptyInstances[0]; + expect(pty).toBeDefined(); + + manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + + await pty!.emitExit(9); + + const events = ptyLostEvents(); + expect(mockPtySpawn).toHaveBeenCalledTimes(2); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + sessionId: "app-1", + data: expect.objectContaining({ + sessionId: "app-1", + exitCode: 9, + subscriberCount: 1, + reattachRecovered: true, + reattachExhausted: false, + }), + }), + ); + }); + + it("does not emit ui.terminal_pty_lost when the last subscriber already left", async () => { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const pty = ptyInstances[0]; + expect(pty).toBeDefined(); + + const unsubscribe = manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + unsubscribe(); + + await pty!.emitExit(0); + + expect(ptyLostEvents()).toEqual([]); + }); + + it("emits ui.terminal_pty_lost at most once across reattach cycles", async () => { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const firstPty = ptyInstances[0]; + expect(firstPty).toBeDefined(); + + manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + mockPtySpawn.mockImplementationOnce(() => { + throw new Error("first reattach unavailable"); + }); + await firstPty!.emitExit(7); + expect(ptyLostEvents()).toHaveLength(1); + + // A client may try to re-open the terminal after the first PTY loss. + // A second failed reattach should not produce another activity event for + // the same terminal entry. + manager.open("app-1", "proj-1", "tmux-app-1"); + const secondPty = ptyInstances[1]; + expect(secondPty).toBeDefined(); + mockPtySpawn.mockImplementationOnce(() => { + throw new Error("second reattach unavailable"); + }); + await secondPty!.emitExit(8); + + expect(ptyLostEvents()).toHaveLength(1); + }); + + it("re-arms ui.terminal_pty_lost after a successful reattach survives the grace period", async () => { + vi.useFakeTimers(); + try { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const firstPty = ptyInstances[0]; + expect(firstPty).toBeDefined(); + + manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + await firstPty!.emitExit(7); + expect(ptyLostEvents()).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(5_000); + + const secondPty = ptyInstances[1]; + expect(secondPty).toBeDefined(); + await secondPty!.emitExit(8); + + expect(ptyLostEvents()).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); }); describe("TerminalManager.open — tmux target args (regression for #1714)", () => { @@ -325,6 +832,7 @@ describe("TerminalManager.open — re-attach skipped when tmux session is gone ( mockSpawn.mockReset(); mockPtySpawn.mockReset(); mockTmuxHasSession.mockReset(); + recordActivityEvent.mockClear(); capturedOnExit = undefined; mockSpawn.mockImplementation(() => new EventEmitter()); @@ -355,6 +863,20 @@ describe("TerminalManager.open — re-attach skipped when tmux session is gone ( // Subscribers were notified with the original exit code. expect(exitCb).toHaveBeenCalledTimes(1); expect(exitCb).toHaveBeenCalledWith(0); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + sessionId: "ao-177", + data: expect.objectContaining({ + sessionId: "ao-177", + exitCode: 0, + reattachSkipped: true, + tmuxSessionPresent: false, + }), + }), + ); }); it("still re-attaches when has-session reports the tmux session is alive", async () => { diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index 5515574df..5db3d4630 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -16,7 +16,7 @@ import { tmuxHasSession, validateSessionId, } from "./tmux-utils.js"; -import { getEnvDefaults, isWindows } from "@aoagents/ao-core"; +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 @@ -63,6 +63,9 @@ export class SessionBroadcaster { private errorSubscribers = new Set<(error: string) => void>(); private intervalId: ReturnType | null = null; private polling = false; + // Tracks the last fetch outcome so we only emit ui.session_broadcast_failed on + // the healthy → failing transition (not every 3s during an outage). + private lastFetchOk = true; private readonly baseUrl: string; constructor(nextPort: string) { @@ -158,18 +161,42 @@ export class SessionBroadcaster { if (!res.ok) { const msg = `Session fetch failed: HTTP ${res.status}`; console.warn(`[SessionBroadcaster] ${msg}`); + this.recordFetchFailure(msg, { httpStatus: res.status }); return { sessions: null, error: msg }; } const data = (await res.json()) as { sessions?: SessionPatch[] }; + this.lastFetchOk = true; return { sessions: data.sessions ?? null, error: null }; } catch (err) { clearTimeout(timeoutId); const msg = err instanceof Error ? err.message : String(err); console.warn("[SessionBroadcaster] fetchSnapshot error:", msg); + this.recordFetchFailure(msg); return { sessions: null, error: msg }; } } + /** + * Emit ui.session_broadcast_failed once per healthy→failing transition. + * The broadcaster polls every 3s; emitting on every failure during a long + * outage would flood the events table (~20/min). Recovery resets the flag. + */ + private recordFetchFailure(message: string, extra?: Record): void { + if (!this.lastFetchOk) return; + this.lastFetchOk = false; + recordActivityEvent({ + source: "ui", + kind: "ui.session_broadcast_failed", + level: "warn", + summary: `session broadcaster fetch failed: ${message}`, + data: { + url: `${this.baseUrl}/api/sessions/patches`, + errorMessage: message, + ...extra, + }, + }); + } + private disconnect(): void { if (this.intervalId !== null) { clearInterval(this.intervalId); @@ -199,6 +226,7 @@ interface ManagedTerminal { buffer: string[]; bufferBytes: number; reattachAttempts: number; + ptyLostEmitted: boolean; /** * Pending grace-period timer that resets reattachAttempts when the * currently-attached PTY survives REATTACH_RESET_GRACE_MS. Tracked so @@ -276,6 +304,7 @@ export class TerminalManager { buffer: [], bufferBytes: 0, reattachAttempts: 0, + ptyLostEmitted: false, }; this.terminals.set(key, terminal); } @@ -346,6 +375,7 @@ export class TerminalManager { terminal.resetTimer = undefined; if (terminal.pty === pty) { terminal.reattachAttempts = 0; + terminal.ptyLostEmitted = false; } }, REATTACH_RESET_GRACE_MS); terminal.resetTimer.unref(); @@ -383,6 +413,7 @@ export class TerminalManager { pty.onExit(async ({ exitCode }) => { console.log(`[MuxServer] PTY exited for ${id} with code ${exitCode}`); terminal.pty = null; + let reattachError: string | undefined; // Skip the re-attach loop entirely when the underlying tmux session is // gone (e.g. user pressed Ctrl-C in the pane and the launch command @@ -392,15 +423,33 @@ export class TerminalManager { // clean user-initiated termination — see issue #1756. The // MAX_REATTACH_ATTEMPTS bound from #1640 still covers tmux server // hiccups where the session does still exist. - if ( - terminal.subscribers.size > 0 && - !(await tmuxHasSession(this.TMUX, tmuxSessionId)) - ) { + if (terminal.subscribers.size > 0 && !(await tmuxHasSession(this.TMUX, tmuxSessionId))) { console.log(`[MuxServer] tmux session ${tmuxSessionId} is gone, not re-attaching`); if (terminal.resetTimer) { clearTimeout(terminal.resetTimer); terminal.resetTimer = undefined; } + if (!terminal.ptyLostEmitted) { + terminal.ptyLostEmitted = true; + recordActivityEvent({ + projectId, + sessionId: id, + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + summary: `terminal PTY exited (code ${exitCode}) — tmux session gone`, + data: { + sessionId: id, + exitCode, + reattachAttempts: terminal.reattachAttempts, + maxReattachAttempts: MAX_REATTACH_ATTEMPTS, + reattachExhausted: false, + reattachSkipped: true, + tmuxSessionPresent: false, + subscriberCount: terminal.subscribers.size, + }, + }); + } for (const cb of terminal.exitCallbacks) { cb(exitCode); } @@ -423,14 +472,63 @@ export class TerminalManager { ); try { this.open(id, projectId, tmuxSessionId); + if (!terminal.ptyLostEmitted) { + terminal.ptyLostEmitted = true; + recordActivityEvent({ + projectId, + sessionId: id, + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + summary: `terminal PTY exited (code ${exitCode}) — reattached`, + data: { + sessionId: id, + exitCode, + reattachAttempts: terminal.reattachAttempts, + maxReattachAttempts: MAX_REATTACH_ATTEMPTS, + reattachExhausted: false, + reattachRecovered: true, + subscriberCount: terminal.subscribers.size, + }, + }); + } return; // re-attached — don't notify exit } catch (err) { + reattachError = err instanceof Error ? err.message : String(err); console.error(`[MuxServer] Failed to re-attach ${id}:`, err); } } else if (terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS) { console.error(`[MuxServer] Max re-attach attempts reached for ${id}, giving up`); } + // PTY actually died (vs user closed browser): only emit when subscribers + // are still attached — otherwise the exit is just normal cleanup. + // Keep this event one-shot for the terminal entry. Clients may re-open + // the same terminal after a failed reattach; repeated PTY exits should + // not flood the activity log for the same loss condition. + if (terminal.subscribers.size > 0 && !terminal.ptyLostEmitted) { + terminal.ptyLostEmitted = true; + recordActivityEvent({ + projectId, + sessionId: id, + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + summary: `terminal PTY exited (code ${exitCode})${ + terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS ? " — reattach exhausted" : "" + }`, + data: { + sessionId: id, + exitCode, + reattachAttempts: terminal.reattachAttempts, + maxReattachAttempts: MAX_REATTACH_ATTEMPTS, + reattachExhausted: terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS, + subscriberCount: terminal.subscribers.size, + ...(reattachError ? { reattachError } : {}), + }, + }); + } + // Notify subscribers that the terminal has exited (re-attach failed or no subscribers) for (const cb of terminal.exitCallbacks) { cb(exitCode); @@ -515,6 +613,8 @@ export class TerminalManager { // ── Windows Pipe Relay (extracted for testability) ── +const intentionalWinPipeCloses = new WeakSet(); + /** Minimal WebSocket-like interface for the pipe relay handler */ export interface WsSink { send(data: string): void; @@ -586,8 +686,36 @@ export function handleWindowsPipeMessage( const pipeSocket = deps.connect(pipePath); winPipes.set(pipeKey, pipeSocket); winPipeBuffers.set(pipeKey, Buffer.alloc(0)); + let ptyLostEmitted = false; + const recordWindowsPtyLost = ( + reason: "pipe_closed" | "host_not_alive" | "pipe_error", + extra?: Record, + ): void => { + if (ptyLostEmitted || ws.readyState !== WS_OPEN) return; + ptyLostEmitted = true; + recordActivityEvent({ + projectId, + sessionId: id, + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + summary: + reason === "host_not_alive" + ? `terminal PTY host reported not alive for ${id}` + : reason === "pipe_error" + ? `terminal PTY host pipe errored for ${id}` + : `terminal PTY host pipe closed for ${id}`, + data: { + sessionId: id, + transport: "windows_pipe", + reason, + ...extra, + }, + }); + }; pipeSocket.on("error", (err) => { + recordWindowsPtyLost("pipe_error", { errorMessage: err.message }); winPipes.delete(pipeKey); winPipeBuffers.delete(pipeKey); pipeSocket.destroy(); @@ -637,9 +765,8 @@ export function handleWindowsPipeMessage( try { const status = JSON.parse(payload.toString("utf-8")) as { alive: boolean }; if (!status.alive && ws.readyState === WS_OPEN) { - ws.send( - JSON.stringify({ ch: "terminal", id, type: "exited", code: 0, ...echo }), - ); + recordWindowsPtyLost("host_not_alive"); + ws.send(JSON.stringify({ ch: "terminal", id, type: "exited", code: 0, ...echo })); } } catch { /* ignore parse errors */ @@ -651,7 +778,11 @@ export function handleWindowsPipeMessage( pipeSocket.on("close", () => { winPipes.delete(pipeKey); winPipeBuffers.delete(pipeKey); + const intentionalClose = intentionalWinPipeCloses.delete(pipeSocket); if (ws.readyState === WS_OPEN) { + if (!intentionalClose) { + recordWindowsPtyLost("pipe_closed"); + } ws.send(JSON.stringify({ ch: "terminal", id, type: "exited", code: 0, ...echo })); } }); @@ -678,6 +809,7 @@ export function handleWindowsPipeMessage( } else if (type === "close") { const pipeSocket = winPipes.get(pipeKey); if (pipeSocket) { + intentionalWinPipeCloses.add(pipeSocket); pipeSocket.end(); winPipes.delete(pipeKey); winPipeBuffers.delete(pipeKey); @@ -706,9 +838,26 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | const wss = new WebSocketServer({ noServer: true }); - wss.on("connection", (ws) => { + wss.on("connection", (ws, request) => { console.log("[MuxServer] New mux connection"); + const connectedAt = Date.now(); + // Best-effort remote addr — proxy headers if present, else socket peer. + const xff = request?.headers["x-forwarded-for"]; + const xffStr = Array.isArray(xff) ? xff[0] : xff; + const remoteAddr = + (typeof xffStr === "string" ? xffStr.split(",")[0]?.trim() : undefined) ?? + request?.socket?.remoteAddress ?? + undefined; + + recordActivityEvent({ + source: "ui", + kind: "ui.terminal_connected", + level: "info", + summary: "mux WebSocket connection opened", + data: { remoteAddr }, + }); + const subscriptions = new Map void>(); // Windows: named pipe sockets keyed by session ID const winPipes = new Map>(); @@ -716,6 +865,7 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | const winPipeBuffers = new Map(); let sessionUnsubscribe: (() => void) | null = null; let missedPongs = 0; + let heartbeatLostEmitted = false; const MAX_MISSED_PONGS = 3; // Heartbeat: send native WebSocket ping every 15s. @@ -728,6 +878,22 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | missedPongs += 1; if (missedPongs >= MAX_MISSED_PONGS) { console.log("[MuxServer] Too many missed pongs, terminating connection"); + if (!heartbeatLostEmitted) { + heartbeatLostEmitted = true; + recordActivityEvent({ + source: "ui", + kind: "ui.terminal_heartbeat_lost", + level: "warn", + summary: `mux WebSocket heartbeat lost (${missedPongs} missed pongs)`, + data: { + missedPongs, + maxMissedPongs: MAX_MISSED_PONGS, + connectionAgeMs: Date.now() - connectedAt, + remoteAddr, + subscriberCount: subscriptions.size, + }, + }); + } ws.terminate(); } } @@ -759,7 +925,14 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | if (type === "open") { if (isWindows()) { handleWindowsPipeMessage( - msg as { id: string; type: string; projectId?: string; data?: string; cols?: number; rows?: number }, + msg as { + id: string; + type: string; + projectId?: string; + data?: string; + cols?: number; + rows?: number; + }, ws, winPipes, winPipeBuffers, @@ -841,7 +1014,13 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | } else if (type === "resize" && "cols" in msg && "rows" in msg) { if (isWindows()) { handleWindowsPipeMessage( - msg as { id: string; type: string; projectId?: string; cols: number; rows: number }, + msg as { + id: string; + type: string; + projectId?: string; + cols: number; + rows: number; + }, ws, winPipes, winPipeBuffers, @@ -853,7 +1032,14 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | } else if (type === "close") { if (isWindows()) { handleWindowsPipeMessage( - msg as { id: string; type: string; projectId?: string; data?: string; cols?: number; rows?: number }, + msg as { + id: string; + type: string; + projectId?: string; + data?: string; + cols?: number; + rows?: number; + }, ws, winPipes, winPipeBuffers, @@ -903,6 +1089,17 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | } } catch (err) { console.error("[MuxServer] Failed to parse message:", err); + recordActivityEvent({ + source: "ui", + kind: "ui.terminal_protocol_error", + level: "warn", + summary: "invalid mux client message — parse failed", + data: { + errorMessage: err instanceof Error ? err.message : String(err), + remoteAddr, + subscriberCount: subscriptions.size, + }, + }); const errorMsg: ServerMessage = { ch: "system", type: "error", @@ -917,8 +1114,22 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | /** * Handle connection close */ - ws.on("close", () => { + ws.on("close", (code, reason) => { console.log("[MuxServer] Mux connection closed"); + recordActivityEvent({ + source: "ui", + kind: "ui.terminal_disconnected", + level: "info", + summary: "mux WebSocket connection closed", + data: { + code, + reason: reason?.toString("utf8") || undefined, + connectionAgeMs: Date.now() - connectedAt, + subscriberCount: subscriptions.size, + heartbeatLost: heartbeatLostEmitted, + remoteAddr, + }, + }); clearInterval(heartbeatInterval); sessionUnsubscribe?.(); sessionUnsubscribe = null; diff --git a/packages/web/src/__tests__/webhook-route.test.ts b/packages/web/src/__tests__/webhook-route.test.ts new file mode 100644 index 000000000..0bea074e5 --- /dev/null +++ b/packages/web/src/__tests__/webhook-route.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createInitialCanonicalLifecycle, + createActivitySignal, + type Session, + type SessionManager, + type OrchestratorConfig, + type PluginRegistry, + type SCM, + type LifecycleManager, +} from "@aoagents/ao-core"; + +// Activity event recording is mocked so we can assert what fires without +// touching the real SQLite layer. +const recordActivityEvent = vi.fn(); +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + recordActivityEvent: (event: unknown) => recordActivityEvent(event), + }; +}); + +// ── Mock services + plugin registry ─────────────────────────────────── + +function makeSession(id: string, overrides: Partial = {}): Session { + const lifecycle = createInitialCanonicalLifecycle("worker", new Date()); + lifecycle.session.state = "working"; + lifecycle.session.reason = "task_in_progress"; + lifecycle.session.startedAt = lifecycle.session.lastTransitionAt; + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + return { + id, + projectId: "my-app", + status: "working", + activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), + lifecycle, + branch: "feat/x", + issueId: null, + pr: { + number: 42, + url: "u", + title: "t", + owner: "acme", + repo: "my-app", + branch: "feat/x", + baseBranch: "main", + isDraft: false, + }, + workspacePath: null, + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + }; +} + +const verifyWebhook = vi.fn(); +const parseWebhook = vi.fn(); +const mockSCM: SCM = { + name: "github", + detectPR: vi.fn(), + getPRState: vi.fn(), + mergePR: vi.fn(), + closePR: vi.fn(), + getCIChecks: vi.fn(), + getCISummary: vi.fn(), + getReviews: vi.fn(), + getReviewDecision: vi.fn(), + getPendingComments: vi.fn(), + getAutomatedComments: vi.fn(), + getMergeability: vi.fn(), + verifyWebhook, + parseWebhook, +} as unknown as SCM; + +const mockRegistry: PluginRegistry = { + register: vi.fn(), + get: vi.fn(() => mockSCM) as PluginRegistry["get"], + list: vi.fn(() => []), + loadBuiltins: vi.fn(), + loadFromConfig: vi.fn(), +}; + +const mockConfig: OrchestratorConfig = { + configPath: "/tmp/agent-orchestrator.yaml", + port: 3000, + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] }, + projects: { + "my-app": { + name: "My App", + repo: "acme/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "my-app", + scm: { + plugin: "github", + webhook: { enabled: true, path: "/api/webhooks/github", maxBodyBytes: 1024 }, + }, + }, + }, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, +}; + +const mockSessionManager = { + list: vi.fn(async () => [makeSession("s1")]), +} as unknown as SessionManager; + +const mockLifecycle = { + check: vi.fn(async () => {}), +} as unknown as LifecycleManager; + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(async () => ({ + config: mockConfig, + registry: mockRegistry, + sessionManager: mockSessionManager, + lifecycleManager: mockLifecycle, + })), +})); + +import { POST as webhookPOST } from "@/app/api/webhooks/[...slug]/route"; + +function makeWebhookRequest(opts?: { + body?: string; + contentLength?: number; + headers?: Record; +}): Request { + const body = opts?.body ?? JSON.stringify({ action: "synchronize", number: 42 }); + const headers: Record = { + "content-type": "application/json", + ...opts?.headers, + }; + if (opts?.contentLength !== undefined) { + headers["content-length"] = String(opts.contentLength); + } + return new Request("http://localhost:3000/api/webhooks/github", { + method: "POST", + headers, + body, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + recordActivityEvent.mockClear(); + verifyWebhook.mockResolvedValue({ ok: true }); + parseWebhook.mockResolvedValue({ + provider: "github", + kind: "pull_request", + action: "synchronize", + rawEventType: "pull_request", + repository: { owner: "acme", name: "my-app" }, + prNumber: 42, + branch: "feat/x", + data: {}, + }); +}); + +// ── Tests ───────────────────────────────────────────────────────────── + +describe("POST /api/webhooks/[...slug] — activity events", () => { + it("rejects unverified webhook with 401 and emits api.webhook_unverified", async () => { + verifyWebhook.mockResolvedValueOnce({ ok: false, reason: "bad signature" }); + const req = makeWebhookRequest({ + headers: { "x-hub-signature-256": "sha256=bogus", "x-forwarded-for": "203.0.113.7" }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(401); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_unverified", + level: "warn", + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { + summary: string; + data: Record; + }; + // Critical: signature value must NOT be in data (or anywhere) + expect(JSON.stringify(call)).not.toContain("bogus"); + expect(call.data["slug"]).toBe("github"); + expect(call.data["remoteAddr"]).toBe("203.0.113.7"); + expect(call.data["verificationSupported"]).toBe(true); + expect(call.data["reason"]).toBe("bad signature"); + }); + + it("keeps unsupported webhook verification as 404 while recording audit context", async () => { + vi.mocked(mockRegistry.get).mockReturnValueOnce({ + ...mockSCM, + verifyWebhook: undefined, + } as unknown as SCM); + + const res = await webhookPOST(makeWebhookRequest()); + expect(res.status).toBe(404); + + expect(verifyWebhook).not.toHaveBeenCalled(); + expect(parseWebhook).not.toHaveBeenCalled(); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_unverified", + level: "warn", + summary: expect.stringContaining("verification unsupported"), + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { + data: Record; + }; + expect(call.data["slug"]).toBe("github"); + expect(call.data["verificationSupported"]).toBe(false); + expect(call.data["unsupportedVerificationCount"]).toBe(1); + expect(call.data["reason"]).toBe("verification_unsupported"); + }); + + it("emits api.webhook_rejected when content-length exceeds maxBodyBytes (413)", async () => { + const req = makeWebhookRequest({ contentLength: 2048 }); // > 1024 max + + const res = await webhookPOST(req); + expect(res.status).toBe(413); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_rejected", + level: "warn", + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { data: Record }; + expect(call.data["slug"]).toBe("github"); + expect(call.data["contentLength"]).toBe(2048); + expect(call.data["maxBodyBytes"]).toBe(1024); + // No body content captured + expect(JSON.stringify(call)).not.toContain("synchronize"); + }); + + it("emits api.webhook_received with counts (not body) on 202 success", async () => { + const req = makeWebhookRequest({ + headers: { "x-forwarded-for": "192.0.2.1" }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(202); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_received", + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { data: Record }; + expect(call.data["slug"]).toBe("github"); + expect(call.data["remoteAddr"]).toBe("192.0.2.1"); + expect(call.data["matchedSessions"]).toBe(1); + expect(call.data["parseErrorCount"]).toBe(0); + expect(call.data["lifecycleErrorCount"]).toBe(0); + expect(call.data["projectIds"]).toEqual(["my-app"]); + // Critical: payload body is NOT included + expect(JSON.stringify(call)).not.toContain("synchronize"); + }); + + it("emits api.webhook_received with elevated level when parse/lifecycle errors occurred", async () => { + parseWebhook.mockRejectedValueOnce(new Error("boom")); + // Verification passes so we get into the parse path + verifyWebhook.mockResolvedValueOnce({ ok: true }); + + const res = await webhookPOST(makeWebhookRequest()); + expect(res.status).toBe(202); + + const received = recordActivityEvent.mock.calls.find( + ([e]) => (e as { kind: string }).kind === "api.webhook_received", + ); + expect(received).toBeDefined(); + expect((received![0] as { level: string }).level).toBe("warn"); + expect((received![0] as { data: Record }).data["parseErrorCount"]).toBe(1); + }); + + it("emits api.webhook_failed on 500 outer crash", async () => { + // Force the list() call inside POST to throw, hitting the outer catch. + (mockSessionManager.list as ReturnType).mockRejectedValueOnce( + new Error("session manager exploded"), + ); + + const res = await webhookPOST(makeWebhookRequest()); + expect(res.status).toBe(500); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_failed", + level: "error", + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { data: Record }; + expect(call.data["slug"]).toBe("github"); + expect(call.data["errorMessage"]).toContain("session manager exploded"); + }); + + it("does not emit any event for 404 (unknown path)", async () => { + // Empty config so no candidates match + vi.mocked(mockRegistry.get).mockReturnValueOnce(undefined as unknown as SCM); + const req = new Request("http://localhost:3000/api/webhooks/unknown", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(404); + // No webhook events for 404 — it's a config issue, not an external signal + const kinds = recordActivityEvent.mock.calls.map(([e]) => (e as { kind: string }).kind); + expect(kinds.filter((k) => k.startsWith("api.webhook_"))).toEqual([]); + }); +}); diff --git a/packages/web/src/app/api/webhooks/[...slug]/route.ts b/packages/web/src/app/api/webhooks/[...slug]/route.ts index 9ccf878ad..08660dac8 100644 --- a/packages/web/src/app/api/webhooks/[...slug]/route.ts +++ b/packages/web/src/app/api/webhooks/[...slug]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { recordActivityEvent } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { buildWebhookRequest, @@ -9,11 +10,35 @@ import { export const dynamic = "force-dynamic"; +const WEBHOOK_PATH_PREFIX = "/api/webhooks/"; + +function deriveSlug(pathname: string): string { + return pathname.startsWith(WEBHOOK_PATH_PREFIX) + ? pathname.slice(WEBHOOK_PATH_PREFIX.length) + : pathname; +} + +function deriveRemoteAddr(request: Request): string | undefined { + // Next.js does not expose the socket peer address on Request. The standard + // proxy headers are the only signal — first hop in x-forwarded-for is the + // original client. Sanitizer in recordActivityEvent does not redact IPs; + // they are intentionally retained for security audit (per issue #1656). + const xff = request.headers.get("x-forwarded-for"); + if (xff) { + const first = xff.split(",")[0]?.trim(); + if (first) return first; + } + return request.headers.get("x-real-ip") ?? undefined; +} + export async function POST(request: Request): Promise { + const pathname = new URL(request.url).pathname; + const slug = deriveSlug(pathname); + const remoteAddr = deriveRemoteAddr(request); + try { const services = await getServices(); - const path = new URL(request.url).pathname; - const candidates = findWebhookProjects(services.config, services.registry, path); + const candidates = findWebhookProjects(services.config, services.registry, pathname); if (candidates.length === 0) { return NextResponse.json( @@ -36,6 +61,19 @@ export async function POST(request: Request): Promise { Number.isFinite(contentLength) && contentLength > maxBodyBytes ) { + recordActivityEvent({ + source: "api", + kind: "api.webhook_rejected", + level: "warn", + summary: `webhook payload exceeded ${maxBodyBytes} bytes for ${slug}`, + data: { + slug, + remoteAddr, + contentLength, + maxBodyBytes, + reason: "payload_too_large", + }, + }); return NextResponse.json( { error: "Webhook payload exceeds configured maxBodyBytes" }, { status: 413 }, @@ -50,12 +88,21 @@ export async function POST(request: Request): Promise { const sessionIds = new Set(); const projectIds = new Set(); let verified = false; + let verificationSupported = false; + let unsupportedVerificationCount = 0; const errors: string[] = []; const parseErrors: string[] = []; const lifecycleErrors: string[] = []; for (const candidate of candidates) { - const verification = await candidate.scm.verifyWebhook?.(webhookRequest, candidate.project); + if (!candidate.scm.verifyWebhook) { + unsupportedVerificationCount += 1; + errors.push("Webhook verification not supported by SCM plugin"); + continue; + } + + verificationSupported = true; + const verification = await candidate.scm.verifyWebhook(webhookRequest, candidate.project); if (!verification?.ok) { if (verification?.reason) errors.push(verification.reason); continue; @@ -93,12 +140,52 @@ export async function POST(request: Request): Promise { } if (!verified) { + const unsupportedOnly = !verificationSupported && unsupportedVerificationCount > 0; + recordActivityEvent({ + source: "api", + kind: "api.webhook_unverified", + level: "warn", + summary: unsupportedOnly + ? `webhook verification unsupported for ${slug}` + : `webhook signature verification failed for ${slug}`, + data: { + slug, + remoteAddr, + candidateCount: candidates.length, + verificationSupported, + unsupportedVerificationCount, + reason: unsupportedOnly + ? "verification_unsupported" + : (errors[0] ?? "verification_failed"), + }, + }); return NextResponse.json( - { error: errors[0] ?? "Webhook verification failed", ok: false }, - { status: 401 }, + { + error: unsupportedOnly + ? "No SCM webhook configured for this path" + : (errors[0] ?? "Webhook verification failed"), + ok: false, + verificationSupported, + }, + { status: unsupportedOnly ? 404 : 401 }, ); } + recordActivityEvent({ + source: "api", + kind: "api.webhook_received", + level: parseErrors.length > 0 || lifecycleErrors.length > 0 ? "warn" : "info", + summary: `webhook accepted for ${slug}: ${sessionIds.size} session(s) matched`, + data: { + slug, + remoteAddr, + projectIds: [...projectIds], + matchedSessions: sessionIds.size, + parseErrorCount: parseErrors.length, + lifecycleErrorCount: lifecycleErrors.length, + }, + }); + return NextResponse.json( { ok: true, @@ -111,6 +198,17 @@ export async function POST(request: Request): Promise { { status: 202 }, ); } catch (err) { + recordActivityEvent({ + source: "api", + kind: "api.webhook_failed", + level: "error", + summary: `webhook pipeline crashed for ${slug}`, + data: { + slug, + remoteAddr, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); return NextResponse.json( { error: err instanceof Error ? err.message : "Failed to process SCM webhook" }, { status: 500 }, diff --git a/packages/web/src/lib/scm-webhooks.ts b/packages/web/src/lib/scm-webhooks.ts index 63f8d301a..360a8ff6c 100644 --- a/packages/web/src/lib/scm-webhooks.ts +++ b/packages/web/src/lib/scm-webhooks.ts @@ -40,7 +40,7 @@ export function findWebhookProjects( const webhookPath = getProjectWebhookPath(project); if (!webhookPath || webhookPath !== pathname) return []; const scm = registry.get("scm", project.scm.plugin); - if (!scm?.parseWebhook || !scm.verifyWebhook) return []; + if (!scm?.parseWebhook) return []; return [{ projectId, project, scm }]; }); } From be9afb36a40e0b75b38bd978a6f368314784b905 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Mon, 18 May 2026 19:16:34 +0530 Subject: [PATCH 15/26] feat(core): record activity events for config, plugin-registry, and storage migration (#1696) * feat(core): record activity events for config, plugin-registry, and storage migration Closes #1658. Surfaces failures that previously left no trace in `ao events list`: - Config: project_resolve_failed (degraded project), project_malformed, project_invalid, migrated. Local-config errors carry the project's projectId so `ao events list --project ` finds them. - Plugin registry: load_failed (built-in and external), validation_failed, specifier_failed. External plugin failures are now queryable instead of living only in stderr. - Migration: blocked (active sessions), project_failed (per-project), rename_failed (.migrated rename), completed (one summary with totals), rollback_skipped (post-migration sessions present). - Agent report: api.agent_report.transition_rejected and apply_failed for observability into rejected reports. Migration emits at most one event per milestone (per project failure + single completion summary) so event volume scales with errors, not input size. Config events redact sensitive keys via the existing sanitizer. Adds regression tests for all four MUST emits. Co-Authored-By: Claude Opus 4.7 * fix(core): avoid duplicate config activity events * test(core): cover blocked storage migration event * test(core): satisfy lint in migration event mock * fix(core): emit migration completed for no-op runs --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: whoisasx --- .../activity-events-agent-report.test.ts | 80 +++++++ .../__tests__/activity-events-config.test.ts | 205 ++++++++++++++++ .../activity-events-migration.test.ts | 223 ++++++++++++++++++ .../activity-events-plugin-registry.test.ts | 129 ++++++++++ packages/core/src/activity-events.ts | 40 +++- packages/core/src/agent-report.ts | 210 +++++++++-------- packages/core/src/config.ts | 12 + packages/core/src/global-config.ts | 43 +++- packages/core/src/migration/storage-v2.ts | 86 ++++++- packages/core/src/plugin-registry.ts | 57 +++++ packages/core/src/project-resolver.ts | 2 +- packages/core/src/types.ts | 3 + 12 files changed, 982 insertions(+), 108 deletions(-) create mode 100644 packages/core/src/__tests__/activity-events-agent-report.test.ts create mode 100644 packages/core/src/__tests__/activity-events-config.test.ts create mode 100644 packages/core/src/__tests__/activity-events-migration.test.ts create mode 100644 packages/core/src/__tests__/activity-events-plugin-registry.test.ts diff --git a/packages/core/src/__tests__/activity-events-agent-report.test.ts b/packages/core/src/__tests__/activity-events-agent-report.test.ts new file mode 100644 index 000000000..88c56d71a --- /dev/null +++ b/packages/core/src/__tests__/activity-events-agent-report.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { applyAgentReport } from "../agent-report.js"; +import { writeMetadata, writeCanonicalLifecycle } from "../metadata.js"; +import { createInitialCanonicalLifecycle } from "../lifecycle-state.js"; +import { recordActivityEvent } from "../activity-events.js"; + +vi.mock("../activity-events.js", () => ({ + recordActivityEvent: vi.fn(), +})); + +let dataDir: string; +let sessionId: string; + +beforeEach(() => { + dataDir = join(tmpdir(), `ao-test-agent-report-events-${randomUUID()}`); + mkdirSync(dataDir, { recursive: true }); + sessionId = "ao-1"; + vi.mocked(recordActivityEvent).mockClear(); +}); + +afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("activity events: agent-report", () => { + it("emits api.agent_report.transition_rejected when the transition is invalid", () => { + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "terminated"; + writeMetadata(dataDir, sessionId, { + worktree: "/tmp/worktree", + branch: "feat/x", + status: "terminated", + project: "demo", + }); + writeCanonicalLifecycle(dataDir, sessionId, lifecycle); + + expect(() => + applyAgentReport(dataDir, sessionId, { + state: "working", + now: new Date(), + }), + ).toThrow(); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const rejected = calls.find( + (c) => c.kind === "api.agent_report.transition_rejected", + ); + expect(rejected).toBeDefined(); + expect(rejected?.sessionId).toBe(sessionId); + expect(rejected?.source).toBe("api"); + }); + + it("does not emit transition_rejected on a valid transition", () => { + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "working"; + lifecycle.session.reason = "task_in_progress"; + lifecycle.session.startedAt = "2024-12-01T00:00:00.000Z"; + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + writeMetadata(dataDir, sessionId, { + worktree: "/tmp/worktree", + branch: "feat/x", + status: "working", + project: "demo", + }); + writeCanonicalLifecycle(dataDir, sessionId, lifecycle); + + applyAgentReport(dataDir, sessionId, { + state: "needs_input", + now: new Date(), + }); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + expect(calls.find((c) => c.kind === "api.agent_report.transition_rejected")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/activity-events-config.test.ts b/packages/core/src/__tests__/activity-events-config.test.ts new file mode 100644 index 000000000..29db59c50 --- /dev/null +++ b/packages/core/src/__tests__/activity-events-config.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../config.js"; +import { recordActivityEvent } from "../activity-events.js"; +import { saveGlobalConfig, type GlobalConfig } from "../global-config.js"; + +vi.mock("../activity-events.js", () => ({ + recordActivityEvent: vi.fn(), +})); + +function makeGlobalConfig(projects: GlobalConfig["projects"] = {}): GlobalConfig { + return { + port: 3000, + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] }, + projects, + notifiers: {}, + notificationRouting: {}, + reactions: {}, + }; +} + +describe("activity events: config loading", () => { + let tempRoot: string; + let configPath: string; + let originalHome: string | undefined; + let originalGlobalConfig: string | undefined; + + beforeEach(() => { + tempRoot = join( + tmpdir(), + `ao-config-events-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + configPath = join(tempRoot, ".agent-orchestrator", "config.yaml"); + mkdirSync(tempRoot, { recursive: true }); + originalHome = process.env["HOME"]; + originalGlobalConfig = process.env["AO_GLOBAL_CONFIG"]; + process.env["HOME"] = tempRoot; + process.env["AO_GLOBAL_CONFIG"] = configPath; + vi.mocked(recordActivityEvent).mockClear(); + }); + + afterEach(() => { + process.env["HOME"] = originalHome; + if (originalGlobalConfig === undefined) { + delete process.env["AO_GLOBAL_CONFIG"]; + } else { + process.env["AO_GLOBAL_CONFIG"] = originalGlobalConfig; + } + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("emits config.project_resolve_failed for unresolved projects without a specific config event", () => { + const projectPath = join(tempRoot, "old-format"); + mkdirSync(projectPath, { recursive: true }); + writeFileSync( + join(projectPath, "agent-orchestrator.yaml"), + ["projects:", " old-format:", " path: .", ""].join("\n"), + ); + + saveGlobalConfig( + makeGlobalConfig({ + "old-format": { + projectId: "old-format", + path: projectPath, + displayName: "Old format", + defaultBranch: "main", + sessionPrefix: "old-format", + }, + }), + configPath, + ); + + loadConfig(configPath); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "config", + kind: "config.project_resolve_failed", + projectId: "old-format", + }), + ); + }); + + it("does not emit config.project_resolve_failed for malformed local yaml", () => { + const projectPath = join(tempRoot, "malformed"); + mkdirSync(projectPath, { recursive: true }); + writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "tracker: [\n"); + + saveGlobalConfig( + makeGlobalConfig({ + malformed: { + projectId: "malformed", + path: projectPath, + displayName: "Malformed", + defaultBranch: "main", + sessionPrefix: "malformed", + }, + }), + configPath, + ); + + loadConfig(configPath); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "config", + kind: "config.project_malformed", + projectId: "malformed", + }), + ); + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined(); + }); + + it("does not emit config.project_resolve_failed for healthy projects", () => { + const projectPath = join(tempRoot, "clean"); + mkdirSync(projectPath, { recursive: true }); + writeFileSync( + join(projectPath, "agent-orchestrator.yaml"), + ["agent: codex", "runtime: tmux", "workspace: worktree", ""].join("\n"), + ); + + saveGlobalConfig( + makeGlobalConfig({ + clean: { + projectId: "clean", + path: projectPath, + displayName: "Clean", + defaultBranch: "main", + sessionPrefix: "clean", + }, + }), + configPath, + ); + + loadConfig(configPath); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined(); + }); + + it("emits config.project_malformed for unparseable local yaml", () => { + const projectPath = join(tempRoot, "malformed"); + mkdirSync(projectPath, { recursive: true }); + writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "tracker: [\n"); + + saveGlobalConfig( + makeGlobalConfig({ + malformed: { + projectId: "malformed", + path: projectPath, + displayName: "Malformed", + defaultBranch: "main", + sessionPrefix: "malformed", + }, + }), + configPath, + ); + + loadConfig(configPath); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "config", + kind: "config.project_malformed", + projectId: "malformed", + }), + ); + }); + + it("emits config.project_invalid for schema-invalid local yaml", () => { + const projectPath = join(tempRoot, "invalid"); + mkdirSync(projectPath, { recursive: true }); + // Valid YAML but fails LocalProjectConfigSchema (numeric agent isn't a string) + writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "agent: 123\n"); + + saveGlobalConfig( + makeGlobalConfig({ + invalid: { + projectId: "invalid", + path: projectPath, + displayName: "Invalid", + defaultBranch: "main", + sessionPrefix: "invalid", + }, + }), + configPath, + ); + + loadConfig(configPath); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "config", + kind: "config.project_invalid", + projectId: "invalid", + }), + ); + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/activity-events-migration.test.ts b/packages/core/src/__tests__/activity-events-migration.test.ts new file mode 100644 index 000000000..efaf60a94 --- /dev/null +++ b/packages/core/src/__tests__/activity-events-migration.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { migrateStorage } from "../migration/storage-v2.js"; +import { recordActivityEvent } from "../activity-events.js"; + +vi.mock("../activity-events.js", () => ({ + recordActivityEvent: vi.fn(), +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as Record), + execSync: vi.fn(), + }; +}); + +function createTempDir(): string { + const dir = join( + tmpdir(), + `ao-migration-events-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe("activity events: storage migration", () => { + let testDir: string; + let aoBaseDir: string; + let configPath: string; + + beforeEach(() => { + testDir = createTempDir(); + aoBaseDir = join(testDir, ".agent-orchestrator"); + configPath = join(aoBaseDir, "config.yaml"); + mkdirSync(aoBaseDir, { recursive: true }); + vi.mocked(recordActivityEvent).mockClear(); + vi.mocked(execSync).mockReturnValue(""); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("emits migration.completed when there is nothing to migrate", async () => { + const result = await migrateStorage({ + aoBaseDir, + globalConfigPath: configPath, + force: true, + log: () => {}, + }); + + expect(result.projects).toBe(0); + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const completed = calls.find((c) => c.kind === "migration.completed"); + expect(completed).toBeDefined(); + expect(completed?.source).toBe("migration"); + expect(completed?.level).toBe("info"); + expect(completed?.data).toMatchObject({ + projectsMigrated: 0, + sessions: 0, + worktrees: 0, + projectErrors: 0, + }); + }); + + it("emits migration.completed with totals when migration succeeds", async () => { + const hashDir = join(aoBaseDir, "aaaaaa000000-myproject"); + mkdirSync(join(hashDir, "sessions"), { recursive: true }); + mkdirSync(join(hashDir, "worktrees", "ao-1"), { recursive: true }); + writeFileSync( + join(hashDir, "sessions", "ao-1"), + [ + "project=myproject", + "agent=claude-code", + "status=working", + "createdAt=2026-04-21T12:00:00.000Z", + "branch=session/ao-1", + `worktree=${join(hashDir, "worktrees", "ao-1")}`, + ].join("\n"), + ); + writeFileSync( + configPath, + [ + "projects:", + " myproject:", + " path: /home/user/myproject", + " storageKey: aaaaaa000000", + " defaultBranch: main", + "", + ].join("\n"), + ); + + await migrateStorage({ + aoBaseDir, + globalConfigPath: configPath, + force: true, + log: () => {}, + }); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const completed = calls.find((c) => c.kind === "migration.completed"); + expect(completed).toBeDefined(); + const data = (completed?.data ?? {}) as Record; + expect(data["projectsMigrated"]).toBe(1); + expect(data["projectErrors"]).toBe(0); + }); + + it("emits migration.project_failed plus migration.completed when a project fails", async () => { + // Force migrateProject to throw by pre-creating projects/badproj as a FILE + // — mkdirSync with recursive:true tolerates existing directories but NOT existing + // files at the same path, so it throws EEXIST when migrateProject tries to create + // projects/badproj/sessions. + const badDir = join(aoBaseDir, "bbbbbb000000-badproj"); + mkdirSync(join(badDir, "sessions"), { recursive: true }); + writeFileSync( + join(badDir, "sessions", "ao-99"), + [ + "project=badproj", + "agent=claude-code", + "createdAt=2026-04-21T12:00:00.000Z", + "branch=session/ao-99", + `worktree=${join(badDir, "worktrees", "ao-99")}`, + ].join("\n"), + ); + + // Pre-create projects/badproj as a regular file so mkdirSync inside migrateProject + // raises ENOTDIR / EEXIST when it tries to create a subdirectory under it. + mkdirSync(join(aoBaseDir, "projects"), { recursive: true }); + writeFileSync(join(aoBaseDir, "projects", "badproj"), "blocker"); + + writeFileSync( + configPath, + [ + "projects:", + " badproj:", + " path: /home/user/badproj", + " storageKey: bbbbbb000000", + " defaultBranch: main", + "", + ].join("\n"), + ); + + await migrateStorage({ + aoBaseDir, + globalConfigPath: configPath, + force: true, + log: () => {}, + }); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const projectFailed = calls.filter((c) => c.kind === "migration.project_failed"); + const completed = calls.find((c) => c.kind === "migration.completed"); + + expect(projectFailed.length).toBeGreaterThanOrEqual(1); + expect(projectFailed[0]?.projectId).toBe("badproj"); + expect(completed).toBeDefined(); + const completedData = (completed?.data ?? {}) as Record; + expect(completedData["projectErrors"]).toBeGreaterThanOrEqual(1); + }); + + it("emits migration.blocked when active sessions are detected", async () => { + vi.mocked(execSync).mockReturnValue("ao-1\nabcdef012345-worker-7\nunrelated\n"); + + writeFileSync( + configPath, + "projects:\n myproject:\n path: /tmp/x\n storageKey: aaaaaa000000\n defaultBranch: main\n", + ); + + await expect(migrateStorage({ + aoBaseDir, + globalConfigPath: configPath, + log: () => {}, + })).rejects.toThrow(/Found 2 active AO tmux session/); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const blocked = calls.find((c) => c.kind === "migration.blocked"); + expect(blocked).toBeDefined(); + expect(blocked?.source).toBe("migration"); + expect(blocked?.level).toBe("warn"); + expect(blocked?.summary).toBe("migration blocked by 2 active session(s)"); + expect(blocked?.data).toMatchObject({ + activeSessionCount: 2, + sample: ["ao-1", "abcdef012345-worker-7"], + }); + }); + + it("does not emit migration.blocked when force skips active-session detection", async () => { + vi.mocked(execSync).mockReturnValue("ao-1\n"); + + const hashDir = join(aoBaseDir, "aaaaaa000000-myproject"); + mkdirSync(join(hashDir, "sessions"), { recursive: true }); + writeFileSync( + join(hashDir, "sessions", "ao-1"), + [ + "project=myproject", + "agent=claude-code", + "createdAt=2026-04-21T12:00:00.000Z", + "branch=session/ao-1", + `worktree=${join(hashDir, "worktrees", "ao-1")}`, + ].join("\n"), + ); + mkdirSync(join(hashDir, "worktrees", "ao-1"), { recursive: true }); + writeFileSync( + configPath, + "projects:\n myproject:\n path: /tmp/x\n storageKey: aaaaaa000000\n defaultBranch: main\n", + ); + + // force: true skips the active-session check, so no migration.blocked event. + await migrateStorage({ + aoBaseDir, + globalConfigPath: configPath, + force: true, + log: () => {}, + }); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + expect(calls.find((c) => c.kind === "migration.blocked")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/activity-events-plugin-registry.test.ts b/packages/core/src/__tests__/activity-events-plugin-registry.test.ts new file mode 100644 index 000000000..ab5a3187a --- /dev/null +++ b/packages/core/src/__tests__/activity-events-plugin-registry.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginRegistry } from "../plugin-registry.js"; +import { recordActivityEvent } from "../activity-events.js"; +import type { OrchestratorConfig, PluginManifest, PluginModule } from "../types.js"; + +vi.mock("../activity-events.js", () => ({ + recordActivityEvent: vi.fn(), +})); + +function makePlugin(slot: PluginManifest["slot"], name: string): PluginModule { + return { + manifest: { + name, + slot, + description: `Test ${slot} plugin: ${name}`, + version: "0.0.1", + }, + create: vi.fn(() => ({ name })), + }; +} + +function makeOrchestratorConfig(overrides?: Partial): OrchestratorConfig { + return { + projects: {}, + ...overrides, + } as OrchestratorConfig; +} + +beforeEach(() => { + vi.mocked(recordActivityEvent).mockClear(); +}); + +describe("activity events: plugin-registry", () => { + it("emits plugin-registry.load_failed when a configured external plugin fails to import", async () => { + const registry = createPluginRegistry(); + const config = makeOrchestratorConfig({ + plugins: [ + { + name: "broken-plugin", + source: "npm", + package: "@example/broken", + enabled: true, + }, + ], + }); + + await registry.loadFromConfig(config, async (pkg: string) => { + // Built-ins all silently skip; external import throws + if (pkg === "@example/broken") { + throw new Error("module not found"); + } + throw new Error(`builtin not installed: ${pkg}`); + }); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const loadFailed = calls.find( + (c) => + c.kind === "plugin-registry.load_failed" && + c.source === "plugin-registry" && + (c.data as Record | undefined)?.["builtin"] === false, + ); + expect(loadFailed).toBeDefined(); + }); + + it("emits plugin-registry.specifier_failed when a plugin specifier cannot be resolved", async () => { + const registry = createPluginRegistry(); + const config = makeOrchestratorConfig({ + plugins: [ + { + name: "no-specifier", + source: "local", + // No path — resolvePluginSpecifier returns null + enabled: true, + }, + ], + }); + + await registry.loadFromConfig(config, async (pkg: string) => { + throw new Error(`builtin not installed: ${pkg}`); + }); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const specifierFailed = calls.find( + (c) => c.kind === "plugin-registry.specifier_failed", + ); + expect(specifierFailed).toBeDefined(); + }); + + it("emits plugin-registry.load_failed when a built-in plugin's register() throws", async () => { + const registry = createPluginRegistry(); + // Make a plugin whose create() throws to force registration failure + const fakeRuntime: PluginModule = { + manifest: { + name: "tmux", + slot: "runtime", + description: "throwing test plugin", + version: "0.0.1", + }, + create: () => { + throw new Error("boom"); + }, + }; + + await registry.loadBuiltins(undefined, async (pkg: string) => { + if (pkg === "@aoagents/ao-plugin-runtime-tmux") return fakeRuntime; + throw new Error(`Not found: ${pkg}`); + }); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const loadFailed = calls.find((c) => c.kind === "plugin-registry.load_failed"); + expect(loadFailed).toBeDefined(); + }); + + it("does not emit any failure events when plugins load cleanly", async () => { + const registry = createPluginRegistry(); + const fakePlugin = makePlugin("runtime", "tmux"); + + await registry.loadBuiltins(undefined, async (pkg: string) => { + if (pkg === "@aoagents/ao-plugin-runtime-tmux") return fakePlugin; + throw new Error(`Not found: ${pkg}`); + }); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const failures = calls.filter((c) => + typeof c.kind === "string" && c.kind.startsWith("plugin-registry."), + ); + expect(failures).toEqual([]); + }); +}); diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index aafcc4f42..87b6c9175 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -25,6 +25,9 @@ export type ActivityEventSource = | "notifier" | "reaction" | "report-watcher" + | "config" + | "plugin-registry" + | "migration" | "recovery"; export type ActivityEventKind = @@ -71,6 +74,19 @@ export type ActivityEventKind = | "detecting.escalated" // Report watcher | "report_watcher.triggered" + // Config/plugin-registry/storage migration + | "config.project_resolve_failed" + | "config.project_malformed" + | "config.project_invalid" + | "config.migrated" + | "plugin-registry.load_failed" + | "plugin-registry.validation_failed" + | "plugin-registry.specifier_failed" + | "migration.blocked" + | "migration.project_failed" + | "migration.rename_failed" + | "migration.completed" + | "migration.rollback_skipped" // Webhook ingress (api source) | "api.webhook_unverified" | "api.webhook_rejected" @@ -88,7 +104,8 @@ export type ActivityEventKind = | "recovery.action_failed" | "metadata.corrupt_detected" | "api.agent_report.session_not_found" - | "api.agent_report.transition_rejected"; + | "api.agent_report.transition_rejected" + | "api.agent_report.apply_failed"; export type ActivityEventLevel = "debug" | "info" | "warn" | "error"; @@ -128,14 +145,12 @@ export function droppedEventCount(): number { } function pruneOldEvents(db: ReturnType, cutoff: number): void { - db - ?.prepare( - `DELETE FROM activity_events + db?.prepare( + `DELETE FROM activity_events WHERE rowid IN ( SELECT rowid FROM activity_events WHERE ts_epoch < ? LIMIT ? )`, - ) - .run(cutoff, PRUNE_BATCH_SIZE); + ).run(cutoff, PRUNE_BATCH_SIZE); } // Patterns that indicate sensitive field names @@ -152,7 +167,10 @@ function redactCredentialUrls(input: string): string { const proto = result.indexOf("://", offset); if (proto === -1) break; // Only match http:// or https:// (case-insensitive, matching old /gi flag) - if (proto < 4) { offset = proto + 3; continue; } + if (proto < 4) { + offset = proto + 3; + continue; + } const schemeEnd = result.slice(Math.max(0, proto - 5), proto).toLowerCase(); if (!schemeEnd.endsWith("http") && !schemeEnd.endsWith("https")) { offset = proto + 3; @@ -163,7 +181,7 @@ function redactCredentialUrls(input: string): string { while (cursor < result.length) { const ch = result.charCodeAt(cursor); // Space/control chars or '/' mean no '@' is coming in userinfo - if (ch <= 0x20 || ch === 0x2F) break; + if (ch <= 0x20 || ch === 0x2f) break; if (ch === 0x40) { // '@' found — redact everything between :// and @ // Lowercase the scheme to match the old /gi regex behavior @@ -176,7 +194,11 @@ function redactCredentialUrls(input: string): string { cursor++; } // No '@' found — not a credential URL, move past this :// - if (cursor >= result.length || result.charCodeAt(cursor) <= 0x20 || result.charCodeAt(cursor) === 0x2F) { + if ( + cursor >= result.length || + result.charCodeAt(cursor) <= 0x20 || + result.charCodeAt(cursor) === 0x2f + ) { offset = proto + 3; } } diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts index 13b1a187a..091f56a3d 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -28,7 +28,12 @@ import type { } from "./types.js"; import { recordActivityEvent } from "./activity-events.js"; import { mutateMetadata, readMetadataRaw } from "./metadata.js"; -import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus, parseCanonicalLifecycle } from "./lifecycle-state.js"; +import { + buildLifecycleMetadataPatch, + cloneLifecycle, + deriveLegacyStatus, + parseCanonicalLifecycle, +} from "./lifecycle-state.js"; import { parsePrFromUrl } from "./utils/pr.js"; import { assertValidSessionIdComponent } from "./utils/session-id.js"; import { validateStatus } from "./utils/validation.js"; @@ -448,111 +453,124 @@ export function applyAgentReport( let legacyStatus: SessionStatus | null = null; let previousLegacyStatus: SessionStatus | null = null; - const nextMetadata = mutateMetadata(dataDir, sessionId, (existing) => { - const current = cloneLifecycle( - parseCanonicalLifecycle(existing, { - sessionId, - status: validateStatus(existing["status"]), - }), - ); - previousLegacyStatus = deriveLegacyStatus(current); - before = buildAuditSnapshot(current, previousLegacyStatus); - const validation = validateAgentReportTransition(current, input.state); - if (!validation.ok) { - const rejectionReason = validation.reason ?? "transition rejected"; - appendAgentReportAuditEntry(dataDir, sessionId, { - timestamp: now, - actor, - source, - reportState: input.state, - note: trimmedNote, - prNumber, - prUrl: trimmedPrUrl, - prIsDraft, - accepted: false, - rejectionReason, - before, - after: before, - }); - recordActivityEvent({ - projectId, - sessionId, - source: "api", - kind: "api.agent_report.transition_rejected", - level: "warn", - summary: `applyAgentReport rejected ${input.state} for ${sessionId}: ${rejectionReason}`, - data: { - reportState: input.state, - rejectionReason, + const nextMetadata = mutateMetadata( + dataDir, + sessionId, + (existing) => { + const current = cloneLifecycle( + parseCanonicalLifecycle(existing, { + sessionId, + status: validateStatus(existing["status"]), + }), + ); + previousLegacyStatus = deriveLegacyStatus(current); + before = buildAuditSnapshot(current, previousLegacyStatus); + const validation = validateAgentReportTransition(current, input.state); + if (!validation.ok) { + const rejectionReason = validation.reason ?? "transition rejected"; + appendAgentReportAuditEntry(dataDir, sessionId, { + timestamp: now, actor, - reportSource: source, - fromState: current.session.state, - fromReason: current.session.reason, - legacyStatus: previousLegacyStatus, - }, - }); - throw new Error(rejectionReason); - } - const mapped = mapAgentReportToLifecycle(input.state); - previousState = current.session.state; - nextState = mapped.sessionState; - current.session.state = mapped.sessionState; - current.session.reason = mapped.sessionReason; - current.session.lastTransitionAt = now; - if (isPRWorkflowReport(input.state)) { - const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl; - const effectivePrNumber = - prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number; - const canAdvancePrState = - effectivePrUrl !== undefined || - effectivePrNumber !== undefined || - current.pr.state !== "none"; - if (canAdvancePrState) { - current.pr.state = "open"; - current.pr.reason = - input.state === "ready_for_review" ? "review_pending" : "in_progress"; - current.pr.lastObservedAt = now; + source, + reportState: input.state, + note: trimmedNote, + prNumber, + prUrl: trimmedPrUrl, + prIsDraft, + accepted: false, + rejectionReason, + before, + after: before, + }); + recordActivityEvent({ + projectId, + sessionId, + source: "api", + kind: "api.agent_report.transition_rejected", + level: "warn", + summary: `applyAgentReport rejected ${input.state} for ${sessionId}: ${rejectionReason}`, + data: { + reportState: input.state, + rejectionReason, + actor, + reportSource: source, + fromState: current.session.state, + fromReason: current.session.reason, + legacyStatus: previousLegacyStatus, + }, + }); + throw new Error(rejectionReason); } - if (effectivePrUrl) { - current.pr.url = effectivePrUrl; + const mapped = mapAgentReportToLifecycle(input.state); + previousState = current.session.state; + nextState = mapped.sessionState; + current.session.state = mapped.sessionState; + current.session.reason = mapped.sessionReason; + current.session.lastTransitionAt = now; + if (isPRWorkflowReport(input.state)) { + const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl; + const effectivePrNumber = + prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number; + const canAdvancePrState = + effectivePrUrl !== undefined || + effectivePrNumber !== undefined || + current.pr.state !== "none"; + if (canAdvancePrState) { + current.pr.state = "open"; + current.pr.reason = input.state === "ready_for_review" ? "review_pending" : "in_progress"; + current.pr.lastObservedAt = now; + } + if (effectivePrUrl) { + current.pr.url = effectivePrUrl; + } + if (effectivePrNumber !== undefined) { + current.pr.number = effectivePrNumber; + } } - if (effectivePrNumber !== undefined) { - current.pr.number = effectivePrNumber; + if (mapped.sessionState === "working" && current.session.startedAt === null) { + current.session.startedAt = now; } - } - if (mapped.sessionState === "working" && current.session.startedAt === null) { - current.session.startedAt = now; - } - legacyStatus = deriveLegacyStatus(current); - const next = { ...existing }; - Object.assign( - next, - buildLifecycleMetadataPatch(current), - { + legacyStatus = deriveLegacyStatus(current); + const next = { ...existing }; + Object.assign(next, buildLifecycleMetadataPatch(current), { [AGENT_REPORT_METADATA_KEYS.STATE]: input.state, [AGENT_REPORT_METADATA_KEYS.AT]: now, - }, - ); - if (trimmedNote) { - next[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote; - } else { - next[AGENT_REPORT_METADATA_KEYS.NOTE] = ""; - } - if (isPRWorkflowReport(input.state)) { - if (trimmedPrUrl) { - next[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl; + }); + if (trimmedNote) { + next[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote; + } else { + next[AGENT_REPORT_METADATA_KEYS.NOTE] = ""; } - if (prNumber !== undefined) { - next[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber); + if (isPRWorkflowReport(input.state)) { + if (trimmedPrUrl) { + next[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl; + } + if (prNumber !== undefined) { + next[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber); + } + if (prIsDraft !== undefined) { + next[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false"; + } } - if (prIsDraft !== undefined) { - next[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false"; - } - } - return next; - }, { activityEventSource: "api" }); + return next; + }, + { activityEventSource: "api" }, + ); if (!nextMetadata || !before || !previousState || !nextState || !legacyStatus) { + recordActivityEvent({ + projectId, + sessionId, + source: "api", + kind: "api.agent_report.apply_failed", + level: "error", + summary: `failed to apply agent report ${input.state} for ${sessionId}`, + data: { + reportState: input.state, + actor, + source, + }, + }); throw new Error(`Failed to apply agent report for session ${sessionId}`); } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 37c59d3ce..0bf552192 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -32,6 +32,7 @@ import { loadGlobalConfig, } from "./global-config.js"; import { loadEffectiveProjectConfig } from "./project-resolver.js"; +import { recordActivityEvent } from "./activity-events.js"; function inferScmPlugin(project: { repo?: string; @@ -876,6 +877,17 @@ function buildEffectiveConfigFromGlobalConfigPath(configPath: string): LoadedCon path: entry.path, resolveError: error.message, }; + if (error.reasonKind === "malformed" || error.reasonKind === "invalid") { + continue; + } + recordActivityEvent({ + projectId, + source: "config", + kind: "config.project_resolve_failed", + level: "error", + summary: `project ${projectId} failed to resolve`, + data: { path: entry.path, error: error.message }, + }); } } diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts index cda4ca625..6ad925953 100644 --- a/packages/core/src/global-config.ts +++ b/packages/core/src/global-config.ts @@ -7,10 +7,11 @@ import { z } from "zod"; import { atomicWriteFileSync } from "./atomic-write.js"; import { detectScmPlatform } from "./config-generator.js"; import { withFileLockSync } from "./file-lock.js"; -import { ProjectResolveError } from "./types.js"; +import { ProjectResolveError, type ProjectResolveErrorKind } from "./types.js"; import { generateSessionPrefix } from "./paths.js"; import { normalizeOriginUrl } from "./storage-key.js"; import { getDefaultRuntime } from "./platform.js"; +import { recordActivityEvent } from "./activity-events.js"; function globalConfigLockPath(configPath: string): string { return `${configPath}.lock`; @@ -347,6 +348,12 @@ export function loadGlobalConfig( if (migrationSummary) { // eslint-disable-next-line no-console -- required migration visibility for stale shadow stripping console.info(migrationSummary); + recordActivityEvent({ + source: "config", + kind: "config.migrated", + summary: "global config migrated", + data: { migrationSummary }, + }); } const config = GlobalConfigSchema.parse(parsed); @@ -836,6 +843,7 @@ export function resolveProjectIdentity( defaultBranch: string; sessionPrefix: string; resolveError?: string; + resolveErrorKind?: ProjectResolveErrorKind; }) | null { const entry = globalConfig.projects[projectId] as @@ -914,15 +922,48 @@ export function resolveProjectIdentity( }; } + if (localConfigResult.kind === "malformed") { + recordActivityEvent({ + projectId, + source: "config", + kind: "config.project_malformed", + level: "error", + summary: `local config for ${projectId} could not be parsed`, + data: { + path: localConfigResult.path, + error: localConfigResult.error, + }, + }); + } else if (localConfigResult.kind === "invalid") { + recordActivityEvent({ + projectId, + source: "config", + kind: "config.project_invalid", + level: "error", + summary: `local config for ${projectId} failed validation`, + data: { + path: localConfigResult.path, + error: localConfigResult.error, + }, + }); + } + const resolveError = localConfigResult.kind !== "missing" ? (localConfigResult.error ?? "Failed to load local config") : undefined; + const resolveErrorKind: ProjectResolveErrorKind | undefined = + localConfigResult.kind === "malformed" || + localConfigResult.kind === "invalid" || + localConfigResult.kind === "old-format" + ? localConfigResult.kind + : undefined; return { ...(resolveError ? {} : applyBehaviorDefaults({})), ...identityFields, ...(resolveError ? { resolveError } : {}), + ...(resolveErrorKind ? { resolveErrorKind } : {}), }; } diff --git a/packages/core/src/migration/storage-v2.ts b/packages/core/src/migration/storage-v2.ts index 3572e578a..d8df92aac 100644 --- a/packages/core/src/migration/storage-v2.ts +++ b/packages/core/src/migration/storage-v2.ts @@ -30,6 +30,7 @@ import { parseKeyValueContent } from "../key-value.js"; import { generateSessionPrefix } from "../paths.js"; import { atomicWriteFileSync } from "../atomic-write.js"; import { withFileLockSync } from "../file-lock.js"; +import { recordActivityEvent } from "../activity-events.js"; // --------------------------------------------------------------------------- // Constants @@ -1209,6 +1210,16 @@ export async function migrateStorage(options: MigrationOptions = {}): Promise 0) { + recordActivityEvent({ + source: "migration", + kind: "migration.blocked", + level: "warn", + summary: `migration blocked by ${activeSessions.length} active session(s)`, + data: { + activeSessionCount: activeSessions.length, + sample: activeSessions.slice(0, 5), + }, + }); throw new Error( `Found ${activeSessions.length} active AO tmux session(s): ${activeSessions.slice(0, 5).join(", ")}${activeSessions.length > 5 ? "..." : ""}. ` + `Kill active sessions first (ao session kill --all) or use --force to migrate anyway.`, @@ -1228,7 +1239,7 @@ export async function migrateStorage(options: MigrationOptions = {}): Promise 0 ? "warn" : "info", + summary: + projectErrors.length > 0 + ? `migration finished with ${projectErrors.length} error(s)` + : `migration completed: ${totals.projects} project(s), ${totals.sessions} session(s)`, + data: { + dryRun, + projectsMigrated: totals.projects, + sessions: totals.sessions, + worktrees: totals.worktrees, + strayWorktreesMoved: totals.strayWorktreesMoved, + claudeSessionsRelinked: totals.claudeSessionsRelinked, + codexSessionsRewritten: totals.codexSessionsRewritten, + emptyDirsDeleted: totals.emptyDirsDeleted, + projectErrors: projectErrors.length, + }, + }); + return totals; } @@ -1587,6 +1661,16 @@ export async function rollbackStorage(options: RollbackOptions = {}): Promise 0) { log(` Warning: projects/${projectId} has ${postMigrationSessions} session(s) created after migration — skipping deletion.`); log(` These sessions exist only in projects/${projectId}/ and would be lost. Remove manually after verifying.`); + recordActivityEvent({ + projectId, + source: "migration", + kind: "migration.rollback_skipped", + level: "warn", + summary: `rollback skipped projects/${projectId} — ${postMigrationSessions} post-migration session(s)`, + data: { + postMigrationSessions, + }, + }); } else { safeToDeleteProjects.add(projectId); } diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index d67a0d32d..a00d1e401 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -19,6 +19,7 @@ import type { PluginRegistry, OrchestratorConfig, } from "./types.js"; +import { recordActivityEvent } from "./activity-events.js"; /** Map from "slot:name" → plugin instance */ type PluginMap = Map; @@ -461,9 +462,23 @@ export function createPluginRegistry(): PluginRegistry { this.register(mod); } } catch (error) { + const message = error instanceof Error ? error.message : String(error); process.stderr.write( `[plugin-registry] Failed to load built-in plugin "${builtin.name}": ${error}\n`, ); + recordActivityEvent({ + source: "plugin-registry", + kind: "plugin-registry.load_failed", + level: "error", + summary: `built-in plugin ${builtin.name} failed to load`, + data: { + plugin: builtin.name, + slot: builtin.slot, + pkg: builtin.pkg, + builtin: true, + error: message, + }, + }); } } } @@ -486,6 +501,16 @@ 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`); + recordActivityEvent({ + source: "plugin-registry", + kind: "plugin-registry.specifier_failed", + level: "error", + summary: `could not resolve specifier for plugin ${plugin.name}`, + data: { + plugin: plugin.name, + source: plugin.source ?? null, + }, + }); continue; } @@ -508,9 +533,27 @@ export function createPluginRegistry(): PluginRegistry { // Log validation errors but don't abort - other projects can still use the plugin. // The misconfigured project will fail later when it tries to use the plugin // with the wrong name, giving a clearer error at point of use. + const message = + validationError instanceof Error + ? validationError.message + : String(validationError); process.stderr.write( `[plugin-registry] Config validation failed for ${externalEntry.source}: ${validationError}\n`, ); + recordActivityEvent({ + source: "plugin-registry", + kind: "plugin-registry.validation_failed", + level: "error", + summary: `plugin manifest validation failed for ${plugin.name}`, + data: { + plugin: plugin.name, + externalSource: externalEntry.source, + specifier, + manifestName: mod.manifest.name, + manifestSlot: mod.manifest.slot, + error: message, + }, + }); } } @@ -520,7 +563,21 @@ export function createPluginRegistry(): PluginRegistry { this.register(mod); } } catch (error) { + const message = error instanceof Error ? error.message : String(error); process.stderr.write(`[plugin-registry] Failed to load plugin "${specifier}": ${error}\n`); + recordActivityEvent({ + source: "plugin-registry", + kind: "plugin-registry.load_failed", + level: "error", + summary: `external plugin ${plugin.name} failed to load`, + data: { + plugin: plugin.name, + specifier, + source: plugin.source ?? null, + builtin: false, + error: message, + }, + }); } } }, diff --git a/packages/core/src/project-resolver.ts b/packages/core/src/project-resolver.ts index bd98d9db3..fd8311f25 100644 --- a/packages/core/src/project-resolver.ts +++ b/packages/core/src/project-resolver.ts @@ -16,7 +16,7 @@ export function loadEffectiveProjectConfig( throw new ProjectResolveError(projectId, `Unknown project: ${projectId}`); } if (typeof resolved.resolveError === "string" && resolved.resolveError.length > 0) { - throw new ProjectResolveError(projectId, resolved.resolveError); + throw new ProjectResolveError(projectId, resolved.resolveError, resolved.resolveErrorKind); } return resolved; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1d5af5b3e..b68deaf2a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2005,11 +2005,14 @@ export class ConfigNotFoundError extends Error { } } +export type ProjectResolveErrorKind = "malformed" | "invalid" | "old-format"; + /** Thrown when a project cannot be resolved into an effective runtime config. */ export class ProjectResolveError extends Error { constructor( public readonly projectId: string, message: string, + public readonly reasonKind?: ProjectResolveErrorKind, ) { super(message); this.name = "ProjectResolveError"; From 73ffd4ab13904d7c18c64003dc20274bd036a94a Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Mon, 18 May 2026 19:19:39 +0530 Subject: [PATCH 16/26] fix(cli): fall back to local config when global config is missing in project supervisor (#1809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(npm): suppress prebuild-install deprecation warning The prebuild-install package is no longer maintained, but it's a transitive dependency of better-sqlite3 (optional) and node-pty (optional). Both packages continue to use it reliably for downloading prebuilt binaries across platforms. Suppressing this warning avoids noise in npm install output while we await upstream changes. See https://github.com/ComposioHQ/agent-orchestrator/issues/1752 Co-Authored-By: Claude Haiku 4.5 * fix(cli): warn users when spawning in permissionless mode When agentConfig.permissions is unset, the schema defaults to "permissionless", which passes --dangerously-skip-permissions to Claude Code. Claude then shows a one-time confirmation prompt inside the tmux session; if dismissed, the session exits silently with no user-facing error — making it very hard to debug. Add a post-spawn warning in ao spawn output explaining the mode and how to either accept the prompt or switch to interactive mode. Also improve the config-instruction docs to describe each permissions value and the silent-exit risk. Fixes #1754 Co-Authored-By: Claude Haiku 4.5 * fix(cli): direct users to dashboard terminal for permissionless prompt Co-Authored-By: Claude Haiku 4.5 * removed unwanted changes * fix(cli): fall back to local config when global config is missing in project supervisor There are two separate things reading config: - Dashboard (Next.js web server): loadDashboardConfig() — tries global config, falls back to local. - Project supervisor (CLI process): loadConfig(getGlobalConfigPath()) only — no fallback. When a user runs ao start with a local agent-orchestrator.yaml that has never been registered in ~/.agent-orchestrator/config.yaml, the supervisor silently returned with 0 lifecycle workers, leaving sessions frozen in their last known state and PR status never updating. loadSupervisorConfig() mirrors the dashboard fallback: try global config first, fall back to loadConfig() (local discovery) on ENOENT. ConfigNotFoundError (no config anywhere) is handled by isMissingGlobalConfigError so the supervisor still exits cleanly when AO has never been configured. Co-Authored-By: Claude Sonnet 4.6 * fix(cli): address review feedback and fix failing supervisor tests Review feedback from greptile: - Remove unreachable ConfigNotFoundError branch from loadSupervisorConfig's catch — loadConfig(globalConfigPath) cannot throw it with a non-null path. The error still propagates from the fallback loadConfig() (no args) and is caught by isMissingConfigError at the outer scope. - Rename isMissingGlobalConfigError → isMissingConfigError. After extending it to catch ConfigNotFoundError (the "no config anywhere" case), the old name was misleading. Test fix (CI was failing): - Add ConfigNotFoundError to the vi.mock factory so tests that exercise the fallback path compile. - Add coverage for the new fallback paths: - ENOENT on global config → falls back to loadConfig() (local discovery) - Non-missing-config errors (e.g. invalid yaml) propagate up - No config anywhere → supervisor exits cleanly, no workers attached Co-Authored-By: Claude Sonnet 4.6 * chore(cli): drop unused config-instruction module The getConfigInstruction() helper and its config-help subcommand were the module's only consumers, so remove both. The schema URL referenced via CONFIG_SCHEMA_URL still lives in core and is reachable from the yaml's $schema field for editor-side completion. Co-Authored-By: Claude Sonnet 4.6 * chore(cli): tighten loadSupervisorConfig comment Replace the rationale paragraph with a one-line description of what the function does. The "why" lives in the PR description and commit history. Co-Authored-By: Claude Sonnet 4.6 * fix(cli): scope loadSupervisorConfig ENOENT guard to the global config path Review feedback from greptile: the ENOENT catch was broader than the matching guard in isMissingConfigError. If the global config exists but references a missing nested file, the supervisor would silently fall back to the local config instead of surfacing the configuration error. Mirror the path check from isMissingConfigError so only ENOENT for the global config path itself triggers the fallback. Add a test covering the nested-file case. Co-Authored-By: Claude Sonnet 4.6 * chore: restore trailing newline in .npmrc Review feedback from greptile. The trailing newline was accidentally dropped by an earlier "removed unwanted changes" commit on this branch and is unrelated to the supervisor fix. Co-Authored-By: Claude Sonnet 4.6 * Revert "chore(cli): drop unused config-instruction module" This reverts commit cb58e15d0289718854acdbce865fc00d4a494a18. * revert the unwanted comments * Addressing comments to make the resolved path as a fallback for supervisor * test(cli): assert local fallback configPath propagates distinct from global Reviewer nit: the ENOENT-fallback test was returning makeConfig with the default `/tmp/global-config.yaml` path, so the assertion on ensureLifecycleWorker could pass even if the supervisor accidentally propagated the global path. Use a distinct local path in the fallback fixture so the regression coverage is honest. Co-Authored-By: Claude Haiku 4.5 --------- Co-authored-by: Claude Haiku 4.5 --- .../__tests__/lib/project-supervisor.test.ts | 240 +++++++++++++++++- packages/cli/src/commands/start.ts | 2 +- packages/cli/src/lib/project-supervisor.ts | 104 ++++++-- 3 files changed, 316 insertions(+), 30 deletions(-) diff --git a/packages/cli/__tests__/lib/project-supervisor.test.ts b/packages/cli/__tests__/lib/project-supervisor.test.ts index 5e8cce679..6f9ce0319 100644 --- a/packages/cli/__tests__/lib/project-supervisor.test.ts +++ b/packages/cli/__tests__/lib/project-supervisor.test.ts @@ -9,6 +9,12 @@ const mockSetHealth = vi.fn(); const activeWorkers = new Set(); vi.mock("@aoagents/ao-core", () => ({ + ConfigNotFoundError: class ConfigNotFoundError extends Error { + constructor(message = "No agent-orchestrator.yaml found.") { + super(message); + this.name = "ConfigNotFoundError"; + } + }, createCorrelationId: () => "correlation-id", createProjectObserver: () => ({ setHealth: (...args: unknown[]) => mockSetHealth(...args) }), getGlobalConfigPath: () => "/tmp/global-config.yaml", @@ -67,9 +73,9 @@ import { stopProjectSupervisor, } from "../../src/lib/project-supervisor.js"; -function makeConfig(projectIds: string[]) { +function makeConfig(projectIds: string[], configPath = "/tmp/global-config.yaml") { return { - configPath: "/tmp/global-config.yaml", + configPath, projects: Object.fromEntries(projectIds.map((id) => [id, { name: id, path: `/tmp/${id}` }])), }; } @@ -223,7 +229,7 @@ describe("project-supervisor", () => { }, }); - const startPromise = startProjectSupervisor(1_000); + const startPromise = startProjectSupervisor({ intervalMs: 1_000 }); await vi.waitFor(() => expect(releaseList).toBeDefined()); stopProjectSupervisor(); @@ -242,7 +248,7 @@ describe("project-supervisor", () => { throw new Error("bad config"); }); - await expect(startProjectSupervisor(1_000)).rejects.toThrow("bad config"); + await expect(startProjectSupervisor({ intervalMs: 1_000 })).rejects.toThrow("bad config"); }); it("allows startup when the global config does not exist yet", async () => { @@ -257,7 +263,7 @@ describe("project-supervisor", () => { throw error; }); - const handle = await startProjectSupervisor(1_000); + const handle = await startProjectSupervisor({ intervalMs: 1_000 }); expect(handle).toEqual({ stop: expect.any(Function), @@ -266,10 +272,230 @@ describe("project-supervisor", () => { handle.stop(); }); + it("falls back to local config when the global config is missing (ENOENT)", async () => { + // The local fallback uses a DIFFERENT configPath than the global — + // a real bare `loadConfig()` discovers the local file and sets + // `config.configPath` to that path. Asserting on the local path here + // catches any bug that would propagate the global path through the + // fallback (e.g. accidentally returning the global config object). + const localConfigPath = "/tmp/cwd/agent-orchestrator.yaml"; + sessionsByProject.set("app", [makeSession("app")]); + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/tmp/global-config.yaml") { + throw Object.assign(new Error("ENOENT"), { + code: "ENOENT", + path: "/tmp/global-config.yaml", + }); + } + return makeConfig(["app"], localConfigPath); + }); + + await reconcileProjectSupervisor(); + + expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml"); + expect(mockLoadConfig).toHaveBeenCalledWith(); + expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith( + expect.objectContaining({ configPath: localConfigPath }), + "app", + undefined, + ); + expect(activeWorkers.has("app")).toBe(true); + }); + + it("uses the caller-provided configPath as the local fallback when global is missing", async () => { + sessionsByProject.set("app", [makeSession("app")]); + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/tmp/global-config.yaml") { + throw Object.assign(new Error("ENOENT"), { + code: "ENOENT", + path: "/tmp/global-config.yaml", + }); + } + if (path === "/some/repo/agent-orchestrator.yaml") { + return makeConfig(["app"]); + } + throw new Error(`unexpected loadConfig path: ${path}`); + }); + + await reconcileProjectSupervisor({ configPath: "/some/repo/agent-orchestrator.yaml" }); + + expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml"); + expect(mockLoadConfig).toHaveBeenCalledWith("/some/repo/agent-orchestrator.yaml"); + // No bare cwd-walk when the caller resolved a path for us. + expect(mockLoadConfig).not.toHaveBeenCalledWith(); + expect(activeWorkers.has("app")).toBe(true); + }); + + it("ignores the caller-provided configPath when the global config is healthy", async () => { + sessionsByProject.set("app", [makeSession("app")]); + // Both paths would return a valid config — assert we only ever consult + // the global path. The configPath is the fallback, not an override. + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/tmp/global-config.yaml") return makeConfig(["app"]); + if (path === "/repo/agent-orchestrator.yaml") { + throw new Error("supervisor should not consult configPath when global is healthy"); + } + throw new Error(`unexpected loadConfig path: ${path}`); + }); + + await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" }); + + expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml"); + expect(mockLoadConfig).not.toHaveBeenCalledWith("/repo/agent-orchestrator.yaml"); + expect(activeWorkers.has("app")).toBe(true); + }); + + it("preserves workers across a global→fallback transition (multi-tick)", async () => { + // Tick 1: global exists with {alpha, beta, gamma} — supervisor attaches + // all three. Tick 2: global has been deleted; the local fallback config + // (passed-in configPath) lists only {alpha}. Without the source-aware + // detach skip, the second tick would kill beta and gamma even though + // they're still running real sessions. + sessionsByProject.set("alpha", [makeSession("alpha")]); + sessionsByProject.set("beta", [makeSession("beta")]); + sessionsByProject.set("gamma", [makeSession("gamma")]); + + // Tick 1: global is the source. + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/tmp/global-config.yaml") return makeConfig(["alpha", "beta", "gamma"]); + throw new Error(`unexpected path on tick 1: ${path}`); + }); + await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" }); + expect(activeWorkers.has("alpha")).toBe(true); + expect(activeWorkers.has("beta")).toBe(true); + expect(activeWorkers.has("gamma")).toBe(true); + + // Tick 2: global deleted, fallback has a narrower view. + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/tmp/global-config.yaml") { + throw Object.assign(new Error("ENOENT"), { + code: "ENOENT", + path: "/tmp/global-config.yaml", + }); + } + if (path === "/repo/agent-orchestrator.yaml") return makeConfig(["alpha"]); + throw new Error(`unexpected path on tick 2: ${path}`); + }); + await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" }); + + // All three workers must survive — fallback isn't authoritative for removal. + expect(activeWorkers.has("alpha")).toBe(true); + expect(activeWorkers.has("beta")).toBe(true); + expect(activeWorkers.has("gamma")).toBe(true); + expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("beta"); + expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("gamma"); + }); + + it("does detach when global is restored after a fallback period (symmetric flip)", async () => { + // Documents intentional current behavior: when source flips back to + // "global", the detach pass treats the global config as authoritative, + // so projects not listed there ARE detached — including any that were + // attached during a prior fallback window. The reviewer's guidance was + // scoped to the fallback direction; protecting the symmetric flip would + // require per-worker source tracking and is out of scope here. + sessionsByProject.set("local-only", [makeSession("local-only")]); + sessionsByProject.set("from-global", [makeSession("from-global")]); + + // Tick 1: no global, fallback attaches local-only. + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/tmp/global-config.yaml") { + throw Object.assign(new Error("ENOENT"), { + code: "ENOENT", + path: "/tmp/global-config.yaml", + }); + } + if (path === "/repo/agent-orchestrator.yaml") return makeConfig(["local-only"]); + throw new Error(`unexpected path on tick 1: ${path}`); + }); + await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" }); + expect(activeWorkers.has("local-only")).toBe(true); + + // Tick 2: global appears (e.g. another `ao start ` wrote it), + // listing only "from-global". Source = "global" → detach pass runs. + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/tmp/global-config.yaml") return makeConfig(["from-global"]); + throw new Error(`unexpected path on tick 2: ${path}`); + }); + await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" }); + + expect(activeWorkers.has("from-global")).toBe(true); + expect(activeWorkers.has("local-only")).toBe(false); + expect(mockRemoveProjectFromRunning).toHaveBeenCalledWith("local-only"); + }); + + it("does not detach unrelated active workers when operating from local fallback", async () => { + // Simulates: daemon already supervising "other-project" (registered via + // a prior reconcile against a global config that has since been deleted). + // The current reconcile sees only "cwd-project" in the local fallback — + // it must NOT treat "other-project" as removed. + activeWorkers.add("other-project"); + sessionsByProject.set("cwd-project", [makeSession("cwd-project")]); + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/tmp/global-config.yaml") { + throw Object.assign(new Error("ENOENT"), { + code: "ENOENT", + path: "/tmp/global-config.yaml", + }); + } + return makeConfig(["cwd-project"]); + }); + + await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" }); + + expect(activeWorkers.has("other-project")).toBe(true); + expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("other-project"); + // Attach pass still runs for the configured cwd project. + expect(activeWorkers.has("cwd-project")).toBe(true); + }); + + it("rethrows ENOENT from a nested file referenced by the global config", async () => { + mockLoadConfig.mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { + code: "ENOENT", + path: "/tmp/some-referenced-file.yaml", + }); + }); + + await expect(reconcileProjectSupervisor()).rejects.toThrow("ENOENT"); + expect(mockLoadConfig).toHaveBeenCalledTimes(1); + }); + + it("rethrows non-missing-config errors from the global config load", async () => { + mockLoadConfig.mockImplementation(() => { + throw new Error("invalid yaml"); + }); + + await expect(reconcileProjectSupervisor()).rejects.toThrow("invalid yaml"); + expect(mockLoadConfig).toHaveBeenCalledTimes(1); + }); + + it("exits cleanly when neither global nor local config exists", async () => { + const { ConfigNotFoundError } = await import("@aoagents/ao-core"); + mockLoadConfig + .mockImplementationOnce(() => { + throw Object.assign(new Error("ENOENT"), { + code: "ENOENT", + path: "/tmp/global-config.yaml", + }); + }) + .mockImplementationOnce(() => { + throw new ConfigNotFoundError(); + }); + + const handle = await startProjectSupervisor({ intervalMs: 1_000 }); + + expect(handle).toEqual({ + stop: expect.any(Function), + reconcileNow: expect.any(Function), + }); + expect(mockEnsureLifecycleWorker).not.toHaveBeenCalled(); + handle.stop(); + }); + it("forwards the supervisor interval to lifecycle workers it starts", async () => { sessionsByProject.set("app", [makeSession("app")]); - const handle = await startProjectSupervisor(1_234); + const handle = await startProjectSupervisor({ intervalMs: 1_234 }); expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith( expect.objectContaining({ configPath: "/tmp/global-config.yaml" }), @@ -280,7 +506,7 @@ describe("project-supervisor", () => { }); it("reconcileNow waits for a queued reconcile when one is already running", async () => { - const handle = await startProjectSupervisor(1_000); + const handle = await startProjectSupervisor({ intervalMs: 1_000 }); let firstRelease: (() => void) | undefined; let secondRelease: (() => void) | undefined; let listCalls = 0; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 1b5841a30..7f657c399 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -953,7 +953,7 @@ async function runStartup( if (shouldStartLifecycle) { try { spinner.start("Starting project supervisor"); - await startProjectSupervisor(); + await startProjectSupervisor({ configPath: config.configPath }); spinner.succeed("Lifecycle project supervisor started"); } catch (err) { spinner.fail("Project supervisor failed to start"); diff --git a/packages/cli/src/lib/project-supervisor.ts b/packages/cli/src/lib/project-supervisor.ts index fdfa70a13..2a7747a7e 100644 --- a/packages/cli/src/lib/project-supervisor.ts +++ b/packages/cli/src/lib/project-supervisor.ts @@ -4,6 +4,7 @@ import { isTerminalSession, createCorrelationId, createProjectObserver, + ConfigNotFoundError, type OrchestratorConfig, type ProjectObserver, } from "@aoagents/ao-core"; @@ -24,11 +25,36 @@ interface SupervisorHandle { let activeSupervisor: SupervisorHandle | null = null; -export interface ReconcileProjectSupervisorOptions { - intervalMs?: number; +type SupervisorConfigSource = "global" | "local-fallback"; + +interface LoadedSupervisorConfig { + config: OrchestratorConfig; + source: SupervisorConfigSource; } -function isMissingGlobalConfigError(error: unknown): boolean { +export interface ReconcileProjectSupervisorOptions { + intervalMs?: number; + /** + * Resolved config path from the caller (typically `ao start`). When the + * global config is missing, this is used as the explicit local-fallback + * source. Without it the supervisor would fall back to a cwd-walk via + * bare `loadConfig()`, which misses configs in `ao start ` / + * `ao start ` first-run flows — there the resolved config can + * live under the clone/target path while the daemon's cwd is somewhere + * else. A bare cwd-walk in that case throws ConfigNotFoundError, which + * `run()` silently swallows, leaving `running.projects` empty. + */ + configPath?: string; +} + +export interface StartProjectSupervisorOptions { + intervalMs?: number; + /** See {@link ReconcileProjectSupervisorOptions.configPath}. */ + configPath?: string; +} + +function isMissingConfigError(error: unknown): boolean { + if (error instanceof ConfigNotFoundError) return true; return ( error instanceof Error && "code" in error && @@ -38,6 +64,29 @@ function isMissingGlobalConfigError(error: unknown): boolean { ); } +/** Load the supervisor config: prefer the global registry, fall back to the + * caller-resolved local config path (or cwd discovery when none provided). + * Returns the source so callers can gate authoritative actions (like the + * detach pass) on whether we're looking at the real registry. */ +function loadSupervisorConfig(configPath?: string): LoadedSupervisorConfig { + const globalConfigPath = getGlobalConfigPath(); + try { + return { config: loadConfig(globalConfigPath), source: "global" }; + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" && + "path" in error && + error.path === globalConfigPath + ) { + const config = configPath ? loadConfig(configPath) : loadConfig(); + return { config, source: "local-fallback" }; + } + throw error; + } +} + function reportProjectSupervisorError( observer: ProjectObserver, projectId: string, @@ -69,23 +118,31 @@ async function projectHasNonTerminalSession( export async function reconcileProjectSupervisor( options: ReconcileProjectSupervisorOptions = {}, ): Promise { - const config = loadConfig(getGlobalConfigPath()); + const { config, source } = loadSupervisorConfig(options.configPath); const observer = createProjectObserver(config, "project-supervisor"); const configuredProjectIds = new Set(Object.keys(config.projects)); - const activeProjectIds = new Set(listLifecycleWorkers()); - for (const projectId of activeProjectIds) { - if (!configuredProjectIds.has(projectId)) { - try { - stopLifecycleWorker(projectId); - await removeProjectFromRunning(projectId); - } catch (error) { - reportProjectSupervisorError( - observer, - projectId, - "Failed to detach lifecycle worker for removed project", - error, - ); + // Only the authoritative global registry can declare a project "removed". + // On a local fallback (e.g. global config was deleted while the daemon is + // already supervising multiple projects) the loaded config likely doesn't + // enumerate every supervised project — running the detach pass would kill + // unrelated lifecycle workers. Pre-fallback behavior was a no-op on + // missing global; preserve that property for the detach pass specifically. + if (source === "global") { + const activeProjectIds = new Set(listLifecycleWorkers()); + for (const projectId of activeProjectIds) { + if (!configuredProjectIds.has(projectId)) { + try { + stopLifecycleWorker(projectId); + await removeProjectFromRunning(projectId); + } catch (error) { + reportProjectSupervisorError( + observer, + projectId, + "Failed to detach lifecycle worker for removed project", + error, + ); + } } } } @@ -117,16 +174,19 @@ export async function reconcileProjectSupervisor( } export async function startProjectSupervisor( - intervalMs: number = DEFAULT_SUPERVISOR_INTERVAL_MS, + options: StartProjectSupervisorOptions = {}, ): Promise { if (activeSupervisor) return activeSupervisor; + const intervalMs = options.intervalMs ?? DEFAULT_SUPERVISOR_INTERVAL_MS; + const configPath = options.configPath; + let reconciling = false; let pending = false; let stopped = false; let waiters: Array<() => void> = []; - const run = async (options: { swallowErrors?: boolean } = {}): Promise => { + const run = async (runOptions: { swallowErrors?: boolean } = {}): Promise => { if (stopped) return; if (reconciling) { pending = true; @@ -140,10 +200,10 @@ export async function startProjectSupervisor( do { pending = false; try { - await reconcileProjectSupervisor({ intervalMs }); + await reconcileProjectSupervisor({ intervalMs, configPath }); } catch (error) { - if (isMissingGlobalConfigError(error)) return; - if (!options.swallowErrors) throw error; + if (isMissingConfigError(error)) return; + if (!runOptions.swallowErrors) throw error; // Best-effort background loop: transient config/state errors should not crash ao start. } } while (pending && !stopped); From c1e43f61dcb612fd84ec8e1481a1af8328ca3212 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Mon, 18 May 2026 19:53:55 +0530 Subject: [PATCH 17/26] feat(core): round out session-manager activity events (#1657) (#1697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): round out session-manager activity event instrumentation (#1657) PR #1620 wired activity events for the worker-spawn happy path (spawn_started/spawned/spawn_failed/killed). This extends the same pattern across the rest of session-manager's failure surfaces so RCA can answer questions today's logs can't: - Did the orchestrator spawn fail? (orchestrator path had no AE envelope at all; now wrapped like worker spawn) - Did the agent's startup prompt deliver, or did all 3 retries fail? - Did `runtime.destroy` / `workspace.destroy` succeed during kill, or silently leak? - Did `runtime_lost` reconciliation fire for this session? - Did `ao send` actually deliver? New event kinds (added to ActivityEventKind union): session.kill_started, session.prompt_delivery_failed, session.send_failed, session.restore_failed, session.restore_fallback, session.rollback_started, session.rollback_step_failed, session.workspace_hooks_failed, session.cleanup_error, session.orchestrator_conflict, runtime.lost_detected, runtime.lost_persist_failed, runtime.destroy_failed, workspace.destroy_failed, agent.opencode_purge_failed, tracker.issue_fetch_failed, tracker.generate_prompt_failed, metadata.corrupt_detected Coverage (per #1657 spec): - All 8 MUST emits implemented and have a regression test in session-manager-instrumentation.test.ts - 13 SHOULD emits implemented (cleanup loop, orchestrator subpaths, kill silent catches, send/restore failure paths, etc.) - 4 COULD emits implemented - Out-of-scope per-list enrichment paths intentionally NOT instrumented Invariants preserved (extends #1620's B1-B16): - B1: state mutation BEFORE event emission (runtime_lost persist happens before the AE fires; verified by test) - B2: never wrap recordActivityEvent in try/catch - B11: prompt content excluded from prompt_delivery_failed data (verified by test that asserts the prompt string is absent) - B16: failure-only — emit on final retry exhaustion only, not per attempt - B25 (new): cleanup-stack rollbacks emit per-step via the onError callback hook, not in aggregate Co-Authored-By: Claude Opus 4.7 * fix: address session activity event review * fix: address session activity event review * fix(core): avoid duplicate restore failure events * chore(ci): retrigger checks * Fix orchestrator activity event instrumentation --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: whoisasx --- .../session-manager-instrumentation.test.ts | 635 ++++++++++++++++++ packages/core/src/activity-events.ts | 19 +- packages/core/src/metadata.ts | 236 ++++--- packages/core/src/session-manager.ts | 497 ++++++++++++-- 4 files changed, 1241 insertions(+), 146 deletions(-) create mode 100644 packages/core/src/__tests__/session-manager-instrumentation.test.ts diff --git a/packages/core/src/__tests__/session-manager-instrumentation.test.ts b/packages/core/src/__tests__/session-manager-instrumentation.test.ts new file mode 100644 index 000000000..69b82e44c --- /dev/null +++ b/packages/core/src/__tests__/session-manager-instrumentation.test.ts @@ -0,0 +1,635 @@ +/** + * Regression tests for session-manager activity event instrumentation + * (issue #1657 — extends PR #1620 to cover the rest of the failure paths + * inside spawn / kill / restore / send / list). + * + * One test per MUST-class emit. Pattern follows the lifecycle-manager- + * instrumentation tests: mock `recordActivityEvent`, drive the manager into + * the failure path, then assert the right kind/level/data was logged. + * + * Invariants asserted by these tests (PR #1620 B1/B2 plus #1657 B25): + * - state mutation happens BEFORE event emission + * - failure-only emits — no event on a successful send/spawn + * - cleanup-stack rollbacks emit per failed step (not in aggregate) + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { createSessionManager } from "../session-manager.js"; +import { writeMetadata, readMetadataRaw } from "../metadata.js"; +import { recordActivityEvent } from "../activity-events.js"; +import { getProjectWorktreesDir } from "../paths.js"; +import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js"; +import { + setupTestContext, + teardownTestContext, + makeHandle, + type TestContext, +} from "./test-utils.js"; + +vi.mock("../activity-events.js", () => ({ + recordActivityEvent: vi.fn(), +})); + +let ctx: TestContext; +let sessionsDir: string; +let mockRegistry: PluginRegistry; +let config: OrchestratorConfig; + +beforeEach(() => { + ctx = setupTestContext(); + ({ sessionsDir, mockRegistry, config } = ctx); + vi.mocked(recordActivityEvent).mockClear(); +}); + +afterEach(() => { + teardownTestContext(ctx); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +function findEvent(kind: string) { + return vi + .mocked(recordActivityEvent) + .mock.calls.map((c) => c[0]) + .find((e) => e.kind === kind); +} + +function findAllEvents(kind: string) { + return vi + .mocked(recordActivityEvent) + .mock.calls.map((c) => c[0]) + .filter((e) => e.kind === kind); +} + +function writeTerminatedSession( + sessionId: string, + options: { worktree: string; branch?: string }, +): void { + const metadata: Record = { + worktree: options.worktree, + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + lifecycle: { + version: 2, + session: { + kind: "worker", + state: "terminated", + reason: "manually_killed", + startedAt: "2025-01-01T00:00:00.000Z", + completedAt: null, + terminatedAt: "2025-01-01T00:00:00.000Z", + lastTransitionAt: "2025-01-01T00:00:00.000Z", + }, + pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null }, + runtime: { + state: "missing", + reason: "process_missing", + lastObservedAt: "2025-01-01T00:00:00.000Z", + handle: null, + tmuxName: null, + }, + }, + }; + if (options.branch !== undefined) { + metadata["branch"] = options.branch; + } + writeMetadata(sessionsDir, sessionId, metadata as unknown as Parameters[2]); +} + +describe("session.kill_started (MUST)", () => { + it("emits before runtime.destroy is attempted", async () => { + let destroyCalled = false; + let killStartedEmittedBeforeDestroy = false; + + vi.mocked(ctx.mockRuntime.destroy).mockImplementation(async () => { + destroyCalled = true; + killStartedEmittedBeforeDestroy = !!findEvent("session.kill_started"); + }); + + writeMetadata(sessionsDir, "app-killed", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + runtimeHandle: makeHandle("rt-1"), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-killed"); + + expect(destroyCalled).toBe(true); + expect(killStartedEmittedBeforeDestroy).toBe(true); + + const start = findEvent("session.kill_started"); + expect(start).toMatchObject({ + projectId: "my-app", + sessionId: "app-killed", + source: "session-manager", + kind: "session.kill_started", + }); + }); + + it("does not emit kill_started when session is already terminated (idempotent)", async () => { + writeMetadata(sessionsDir, "app-already-killed", { + worktree: "/tmp/ws", + branch: "main", + status: "killed", + project: "my-app", + lifecycle: { + version: 2, + session: { + kind: "worker", + state: "terminated", + reason: "manually_killed", + startedAt: "2025-01-01T00:00:00.000Z", + completedAt: null, + terminatedAt: "2025-01-01T00:00:00.000Z", + lastTransitionAt: "2025-01-01T00:00:00.000Z", + }, + pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null }, + runtime: { + state: "missing", + reason: "manual_kill_requested", + lastObservedAt: "2025-01-01T00:00:00.000Z", + handle: null, + tmuxName: null, + }, + }, + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-already-killed"); + + expect(findEvent("session.kill_started")).toBeUndefined(); + }); +}); + +describe("session.spawn_failed — orchestrator path (MUST)", () => { + it("emits session.spawned after a successful orchestrator spawn", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + const session = await sm.spawnOrchestrator({ + projectId: "my-app", + systemPrompt: "be helpful", + }); + + const events = findAllEvents("session.spawned"); + const orchestratorSpawned = events.find( + (e) => e.data && (e.data as Record)["role"] === "orchestrator", + ); + + expect(session.id).toBe("app-orchestrator"); + expect(orchestratorSpawned).toMatchObject({ + projectId: "my-app", + sessionId: "app-orchestrator", + source: "session-manager", + kind: "session.spawned", + summary: "spawned: app-orchestrator", + data: { + agent: "mock-agent", + branch: "orchestrator/app-orchestrator", + role: "orchestrator", + }, + }); + }); + + it("does not emit terminal spawn_failed when ensure recovers a fixed reservation conflict", async () => { + let releaseWorkspace: () => void = () => {}; + const blockingWorkspace = new Promise((resolve) => { + releaseWorkspace = resolve; + }); + vi.mocked(ctx.mockWorkspace.create).mockImplementationOnce(async (cfg) => { + await blockingWorkspace; + return { + path: join(ctx.tmpDir, "ws-orchestrator"), + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }); + + const firstManager = createSessionManager({ config, registry: mockRegistry }); + const secondManager = createSessionManager({ config, registry: mockRegistry }); + + const firstEnsure = firstManager.ensureOrchestrator({ + projectId: "my-app", + systemPrompt: "be helpful", + }); + await vi.waitFor(() => { + expect(ctx.mockWorkspace.create).toHaveBeenCalledTimes(1); + }); + + const secondEnsure = secondManager.ensureOrchestrator({ + projectId: "my-app", + systemPrompt: "be helpful", + }); + await vi.waitFor(() => { + expect(findEvent("session.orchestrator_conflict")).toBeDefined(); + }); + + releaseWorkspace(); + const [created, recovered] = await Promise.all([firstEnsure, secondEnsure]); + + expect(created.id).toBe("app-orchestrator"); + expect(recovered.id).toBe("app-orchestrator"); + expect(findAllEvents("session.orchestrator_conflict")).toHaveLength(1); + expect( + findAllEvents("session.spawn_failed").filter( + (e) => e.data && (e.data as Record)["role"] === "orchestrator", + ), + ).toHaveLength(0); + }); + + it("emits one terminal failure plus one stage failure when workspace.create throws", async () => { + vi.mocked(ctx.mockWorkspace.create).mockRejectedValue(new Error("disk full")); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect( + sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }), + ).rejects.toThrow("disk full"); + + const events = findAllEvents("session.spawn_failed"); + expect(events).toHaveLength(1); + const orchestratorFailure = events.find( + (e) => e.data && (e.data as Record)["role"] === "orchestrator", + ); + expect(orchestratorFailure).toBeDefined(); + expect(orchestratorFailure!.level).toBe("error"); + expect(orchestratorFailure!.projectId).toBe("my-app"); + + const stepEvents = findAllEvents("session.spawn_step_failed"); + expect(stepEvents).toHaveLength(1); + expect(stepEvents[0]!.sessionId).toBe("app-orchestrator"); + expect(stepEvents[0]!.data).toMatchObject({ + role: "orchestrator", + stage: "workspace_create", + }); + }); + + it("emits one terminal failure plus one stage failure when runtime.create throws", async () => { + vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("tmux not found")); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect( + sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }), + ).rejects.toThrow("tmux not found"); + + const events = findAllEvents("session.spawn_failed"); + expect(events).toHaveLength(1); + const orchestratorFailure = events.find( + (e) => e.data && (e.data as Record)["role"] === "orchestrator", + ); + expect(orchestratorFailure).toBeDefined(); + expect(orchestratorFailure!.level).toBe("error"); + + const stepEvents = findAllEvents("session.spawn_step_failed"); + expect(stepEvents).toHaveLength(1); + expect(stepEvents[0]!.sessionId).toBe("app-orchestrator"); + expect(stepEvents[0]!.data).toMatchObject({ + role: "orchestrator", + stage: "runtime_create", + }); + }); +}); + +describe("session.rollback_started/session.rollback_step_failed (MUST)", () => { + it("includes reserved sessionId when worker spawn rolls back after reservation", async () => { + const workspacePath = join(getProjectWorktreesDir("my-app"), "app-1"); + vi.mocked(ctx.mockWorkspace.create).mockResolvedValue({ + path: workspacePath, + branch: "feat/test", + sessionId: "app-1", + projectId: "my-app", + }); + vi.mocked(ctx.mockWorkspace.destroy).mockRejectedValue(new Error("destroy failed")); + vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("runtime failed")); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow("runtime failed"); + + const rollbackStarted = findEvent("session.rollback_started"); + expect(rollbackStarted).toBeDefined(); + expect(rollbackStarted!.sessionId).toBe("app-1"); + + const rollbackStepFailed = findEvent("session.rollback_step_failed"); + expect(rollbackStepFailed).toBeDefined(); + expect(rollbackStepFailed!.sessionId).toBe("app-1"); + expect(rollbackStepFailed!.data).toMatchObject({ reason: "destroy failed" }); + }); +}); + +describe("session.workspace_hooks_failed (MUST)", () => { + it("emits when setupWorkspaceHooks throws during orchestrator spawn", async () => { + const hookFailingAgent: Agent = { + ...ctx.mockAgent, + name: "hook-failing-agent", + setupWorkspaceHooks: vi.fn().mockRejectedValue(new Error("settings.json EACCES")), + }; + const originalGet = mockRegistry.get; + mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => { + if (slot === "agent") return hookFailingAgent; + return (originalGet as any)(slot, name); + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect( + sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }), + ).rejects.toThrow("settings.json EACCES"); + + const event = findEvent("session.workspace_hooks_failed"); + expect(event).toBeDefined(); + expect(event!.level).toBe("error"); + expect(event!.projectId).toBe("my-app"); + }); +}); + +describe("runtime.lost_detected (MUST)", () => { + it("emits when sm.list() persists runtime_lost for a dead runtime", async () => { + writeMetadata(sessionsDir, "app-dead", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + runtimeHandle: makeHandle("rt-dead"), + }); + + // Runtime claims dead, agent process gone + vi.mocked(ctx.mockRuntime.isAlive).mockResolvedValue(false); + vi.mocked(ctx.mockAgent.isProcessRunning).mockResolvedValue(false); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.list(); + + const event = findEvent("runtime.lost_detected"); + expect(event).toBeDefined(); + expect(event!.projectId).toBe("my-app"); + expect(event!.sessionId).toBe("app-dead"); + expect(event!.level).toBe("warn"); + + // B1: state mutation BEFORE event emission — verify metadata was persisted + const persisted = readMetadataRaw(sessionsDir, "app-dead"); + expect(persisted).not.toBeNull(); + const lifecycleStr = persisted!["lifecycle"]; + expect(lifecycleStr).toBeDefined(); + const lc = JSON.parse(lifecycleStr!) as { session: { state: string; reason: string } }; + expect(lc.session.state).toBe("detecting"); + expect(lc.session.reason).toBe("runtime_lost"); + }); +}); + +describe("session.send_failed (MUST)", () => { + it("emits after send retry-with-restore exhausts", async () => { + writeMetadata(sessionsDir, "app-send", { + worktree: "/tmp/ws", + branch: "main", + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-1"), + lifecycle: { + version: 2, + session: { + kind: "worker", + state: "terminated", + reason: "manually_killed", + startedAt: "2025-01-01T00:00:00.000Z", + completedAt: null, + terminatedAt: "2025-01-01T00:00:00.000Z", + lastTransitionAt: "2025-01-01T00:00:00.000Z", + }, + pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null }, + runtime: { + state: "missing", + reason: "process_missing", + lastObservedAt: "2025-01-01T00:00:00.000Z", + handle: null, + tmuxName: null, + }, + }, + }); + + vi.mocked(ctx.mockRuntime.sendMessage).mockRejectedValue(new Error("send broke")); + // restore() throws so retry-with-restore exhausts + vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("restore failed")); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.send("app-send", "hi")).rejects.toThrow(); + + const event = findEvent("session.send_failed"); + expect(event).toBeDefined(); + expect(event!.level).toBe("error"); + expect(event!.sessionId).toBe("app-send"); + // B11: data must not contain message content + expect(JSON.stringify(event!.data ?? {})).not.toContain("hi"); + }); + + it("does not double-emit session.restore_failed when restore fails during send", async () => { + const wsPath = join(ctx.tmpDir, "missing-send-ws"); + writeTerminatedSession("app-send-restore", { worktree: wsPath, branch: "feat/send" }); + + ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false); + ctx.mockWorkspace.restore = vi.fn().mockRejectedValue(new Error("restore failed")); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.send("app-send-restore", "hi")).rejects.toThrow(); + + const restoreFailed = findAllEvents("session.restore_failed"); + expect(restoreFailed).toHaveLength(1); + expect(restoreFailed[0]!.data).toMatchObject({ stage: "workspace_restore" }); + expect(findEvent("session.send_failed")).toBeDefined(); + }); + + it("tags restore-for-delivery timeout restore_failed with ready_timeout stage", async () => { + vi.useFakeTimers(); + try { + const wsPath = join(ctx.tmpDir, "send-restore-ready-timeout"); + mkdirSync(wsPath, { recursive: true }); + writeTerminatedSession("app-send-timeout", { worktree: wsPath, branch: "feat/send" }); + + vi.mocked(ctx.mockRuntime.isAlive).mockImplementation(async (handle) => { + return handle.id !== "rt-restored"; + }); + vi.mocked(ctx.mockAgent.isProcessRunning).mockImplementation(async (handle) => { + return handle.id !== "rt-restored"; + }); + vi.mocked(ctx.mockRuntime.create).mockResolvedValue(makeHandle("rt-restored")); + vi.mocked(ctx.mockRuntime.getOutput).mockResolvedValue(""); + vi.mocked(ctx.mockAgent.detectActivity).mockReturnValue("idle"); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sendPromise = sm.send("app-send-timeout", "hi"); + const rejection = expect(sendPromise).rejects.toThrow( + "restored session did not become ready for delivery", + ); + + await vi.runAllTimersAsync(); + await rejection; + + const restoreFailed = findAllEvents("session.restore_failed"); + expect(restoreFailed).toHaveLength(1); + expect(restoreFailed[0]!.data).toMatchObject({ + stage: "ready_timeout", + reason: "restored session did not become ready for delivery", + trigger: "send", + }); + expect(findEvent("session.send_failed")).toBeDefined(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("session.restore_failed (MUST)", () => { + it("emits when restore's workspace restore throws", async () => { + const wsPath = join(ctx.tmpDir, "missing-ws"); + writeMetadata(sessionsDir, "app-rest", { + worktree: wsPath, + branch: "feat/x", + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + lifecycle: { + version: 2, + session: { + kind: "worker", + state: "terminated", + reason: "manually_killed", + startedAt: "2025-01-01T00:00:00.000Z", + completedAt: null, + terminatedAt: "2025-01-01T00:00:00.000Z", + lastTransitionAt: "2025-01-01T00:00:00.000Z", + }, + pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null }, + runtime: { + state: "missing", + reason: "process_missing", + lastObservedAt: "2025-01-01T00:00:00.000Z", + handle: null, + tmuxName: null, + }, + }, + }); + + // workspace doesn't exist + restore throws + vi.mocked(ctx.mockWorkspace.exists ?? (() => false)).mockResolvedValue?.(false); + ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false); + ctx.mockWorkspace.restore = vi.fn().mockRejectedValue(new Error("clone failed")); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.restore("app-rest")).rejects.toThrow(); + + const event = findEvent("session.restore_failed"); + expect(event).toBeDefined(); + expect(event!.level).toBe("error"); + expect(event!.sessionId).toBe("app-rest"); + }); + + it("emits SessionNotRestorableError when session is not restorable", async () => { + writeMetadata(sessionsDir, "app-not-rest", { + worktree: "/tmp/ws", + branch: "feat/x", + status: "working", + project: "my-app", + runtimeHandle: makeHandle("rt-1"), + lifecycle: { + version: 2, + session: { + kind: "worker", + state: "working", + reason: "task_in_progress", + startedAt: "2025-01-01T00:00:00.000Z", + completedAt: null, + terminatedAt: null, + lastTransitionAt: "2025-01-01T00:00:00.000Z", + }, + pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null }, + runtime: { + state: "alive", + reason: "process_running", + lastObservedAt: "2025-01-01T00:00:00.000Z", + handle: makeHandle("rt-1"), + tmuxName: null, + }, + }, + }); + + // active session — not restorable + vi.mocked(ctx.mockRuntime.isAlive).mockResolvedValue(true); + vi.mocked(ctx.mockAgent.isProcessRunning).mockResolvedValue(true); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.restore("app-not-rest")).rejects.toThrow(); + + const event = findEvent("session.restore_failed"); + expect(event).toBeDefined(); + }); + + it("emits when workspace is missing and the workspace plugin cannot restore", async () => { + const wsPath = join(ctx.tmpDir, "missing-no-restore"); + writeTerminatedSession("app-no-restore", { worktree: wsPath, branch: "feat/no-restore" }); + + ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false); + ctx.mockWorkspace.restore = undefined; + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.restore("app-no-restore")).rejects.toThrow(); + + const event = findEvent("session.restore_failed"); + expect(event).toBeDefined(); + expect(event!.sessionId).toBe("app-no-restore"); + expect(event!.data).toMatchObject({ + stage: "workspace_restore", + workspacePath: wsPath, + reason: "workspace plugin does not support restore", + }); + }); + + it("emits when workspace is missing and branch metadata is absent", async () => { + const wsPath = join(ctx.tmpDir, "missing-no-branch"); + writeTerminatedSession("app-no-branch", { worktree: wsPath }); + + ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false); + ctx.mockWorkspace.restore = vi.fn().mockResolvedValue({ + path: wsPath, + branch: "unused", + sessionId: "app-no-branch", + projectId: "my-app", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.restore("app-no-branch")).rejects.toThrow(); + + const event = findEvent("session.restore_failed"); + expect(event).toBeDefined(); + expect(event!.sessionId).toBe("app-no-branch"); + expect(event!.data).toMatchObject({ + stage: "workspace_restore", + workspacePath: wsPath, + reason: "branch metadata is missing", + }); + }); +}); + +describe("metadata.corrupt_detected (MUST)", () => { + it("emits when mutateMetadata side-renames a corrupt file", async () => { + // Simulate a corrupt metadata file in the sessions dir + const sessionPath = join(sessionsDir, "app-corrupt.json"); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(sessionPath, "{ this is not json", "utf-8"); + + const { mutateMetadata } = await import("../metadata.js"); + mutateMetadata(sessionsDir, "app-corrupt", () => ({ branch: "feat/x", project: "my-app" }), { + createIfMissing: true, + activityEventSource: "api", + }); + + const event = findEvent("metadata.corrupt_detected"); + expect(event).toBeDefined(); + expect(event!.level).toBe("error"); + expect(event!.source).toBe("api"); + expect(event!.sessionId).toBe("app-corrupt"); + const data = event!.data as Record; + expect(data["renameSucceeded"]).toBe(true); + expect(data["renamedTo"]).toMatch(/\.corrupt-\d+$/); + }); +}); diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index 87b6c9175..ed9d79841 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -34,7 +34,25 @@ export type ActivityEventKind = | "session.spawn_started" | "session.spawned" | "session.spawn_failed" + | "session.spawn_step_failed" | "session.killed" + | "session.kill_started" + | "session.send_failed" + | "session.restore_failed" + | "session.restore_fallback" + | "session.rollback_started" + | "session.rollback_step_failed" + | "session.workspace_hooks_failed" + | "session.cleanup_error" + | "session.orchestrator_conflict" + | "runtime.lost_detected" + | "runtime.lost_persist_failed" + | "runtime.destroy_failed" + | "workspace.destroy_failed" + | "agent.opencode_purge_failed" + | "tracker.issue_fetch_failed" + | "tracker.generate_prompt_failed" + | "metadata.corrupt_detected" | "activity.transition" | "lifecycle.transition" | "ci.failing" @@ -102,7 +120,6 @@ export type ActivityEventKind = // Recovery/forensic instrumentation | "recovery.session_failed" | "recovery.action_failed" - | "metadata.corrupt_detected" | "api.agent_report.session_not_found" | "api.agent_report.transition_rejected" | "api.agent_report.apply_failed"; diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 451c127b5..c93164f5c 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -23,7 +23,12 @@ import { constants, } from "node:fs"; import { basename, join, dirname } from "node:path"; -import type { CanonicalSessionLifecycle, RuntimeHandle, SessionId, SessionMetadata } from "./types.js"; +import type { + CanonicalSessionLifecycle, + RuntimeHandle, + SessionId, + SessionMetadata, +} from "./types.js"; import { atomicWriteFileSync } from "./atomic-write.js"; import { recordActivityEvent, type ActivityEventSource } from "./activity-events.js"; import { @@ -95,7 +100,9 @@ function parseRuntimeHandleField(value: unknown): RuntimeHandle | undefined { if (typeof parsed["id"] === "string" && typeof parsed["runtimeName"] === "string") { return parsed as unknown as RuntimeHandle; } - } catch { /* not valid JSON */ } + } catch { + /* not valid JSON */ + } } return undefined; } @@ -107,13 +114,16 @@ function parseDashboardField(raw: Record): SessionMetadata["das return { port: typeof d["port"] === "number" ? d["port"] : undefined, terminalWsPort: typeof d["terminalWsPort"] === "number" ? d["terminalWsPort"] : undefined, - directTerminalWsPort: typeof d["directTerminalWsPort"] === "number" ? d["directTerminalWsPort"] : undefined, + directTerminalWsPort: + typeof d["directTerminalWsPort"] === "number" ? d["directTerminalWsPort"] : undefined, }; } // Legacy format: flat fields const port = typeof raw["dashboardPort"] === "number" ? raw["dashboardPort"] : undefined; - const terminalWsPort = typeof raw["terminalWsPort"] === "number" ? raw["terminalWsPort"] : undefined; - const directTerminalWsPort = typeof raw["directTerminalWsPort"] === "number" ? raw["directTerminalWsPort"] : undefined; + const terminalWsPort = + typeof raw["terminalWsPort"] === "number" ? raw["terminalWsPort"] : undefined; + const directTerminalWsPort = + typeof raw["directTerminalWsPort"] === "number" ? raw["directTerminalWsPort"] : undefined; if (port !== undefined || terminalWsPort !== undefined || directTerminalWsPort !== undefined) { return { port, terminalWsPort, directTerminalWsPort }; } @@ -160,8 +170,15 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta issueTitle: raw["issueTitle"] as string | undefined, pr: raw["pr"] as string | undefined, prAutoDetect: - raw["prAutoDetect"] === "off" || raw["prAutoDetect"] === "false" || raw["prAutoDetect"] === false ? false : - raw["prAutoDetect"] === "on" || raw["prAutoDetect"] === "true" || raw["prAutoDetect"] === true ? true : undefined, + raw["prAutoDetect"] === "off" || + raw["prAutoDetect"] === "false" || + raw["prAutoDetect"] === false + ? false + : raw["prAutoDetect"] === "on" || + raw["prAutoDetect"] === "true" || + raw["prAutoDetect"] === true + ? true + : undefined, summary: raw["summary"] as string | undefined, project: raw["project"] as string | undefined, agent: raw["agent"] as string | undefined, @@ -221,8 +238,12 @@ export function readMetadataRaw( /** Fields that are stored as JSON objects and should be parsed when unflattening. */ const jsonFields = new Set([ - "runtimeHandle", "lifecycle", "statePayload", "dashboard", - "agentReport", "reportWatcher", + "runtimeHandle", + "lifecycle", + "statePayload", + "dashboard", + "agentReport", + "reportWatcher", ]); /** Unflatten a Record to proper types for JSON storage. */ @@ -234,7 +255,12 @@ function unflattenFromStringRecord(data: Record): Record>, ): void { - mutateMetadata(dataDir, sessionId, (existing) => { - return applyMetadataUpdates(existing, updates); - }, { createIfMissing: true }); + mutateMetadata( + dataDir, + sessionId, + (existing) => { + return applyMetadataUpdates(existing, updates); + }, + { createIfMissing: true }, + ); } export function applyMetadataUpdates( @@ -331,17 +362,17 @@ export function applyMetadataUpdates( return next; } -interface MutateMetadataOptions { - createIfMissing?: boolean; - activityEventSource?: ActivityEventSource; -} - function normalizeMetadataRecord(data: Record): Record { return Object.fromEntries( Object.entries(data).filter(([, value]) => value !== undefined && value !== ""), ); } +export interface MutateMetadataOptions { + createIfMissing?: boolean; + activityEventSource?: ActivityEventSource; +} + export function mutateMetadata( dataDir: string, sessionId: SessionId, @@ -351,77 +382,80 @@ export function mutateMetadata( const path = metadataPath(dataDir, sessionId); const lockPath = `${path}.lock`; - return withFileLockSync(lockPath, () => { - let existing: Record = {}; + return withFileLockSync( + lockPath, + () => { + let existing: Record = {}; - let content: string | undefined; - try { - content = readFileSync(path, "utf-8").trim(); - } catch { - // File doesn't exist - } - - if (content !== undefined) { - if (content) { - const raw = parseMetadataContent(content); - if (raw) { - existing = flattenToStringRecord(raw); - } else { - // Corrupt JSON. Preserve forensic evidence by side-renaming - // the file before we overwrite it with the merged update. - // Without this, the very next mutateMetadata call destroys - // the corrupt bytes permanently and the user has no signal - // that anything was wrong — the file just becomes "not - // corrupt anymore — and missing fields". - const corruptPath = `${path}.corrupt-${Date.now()}`; - let renamed = false; - try { - renameSync(path, corruptPath); - renamed = true; - // eslint-disable-next-line no-console - console.warn( - `[metadata] corrupt JSON at ${path}; preserved as ${corruptPath} before rewriting`, - ); - } catch { - // best effort — proceed even if the rename fails (e.g. EACCES) - } - // Forensic activity event so RCA can find every silent overwrite. - // Truncate the bad-JSON sample to 200 chars (B11 invariant — full file - // could be 16KB+ and would be dropped by the sanitizer cap). - const contentSample = - content.length > 200 ? content.slice(0, 200) : content; - // dataDir is `.../projects/{projectId}/sessions`; recover projectId for filtering. - const inferredProjectId = basename(dirname(dataDir)); - const summary = renamed - ? `Corrupt metadata for session ${sessionId} renamed to ${basename(corruptPath)}` - : `Corrupt metadata detected for session ${sessionId}; failed to rename forensic copy before rewrite`; - recordActivityEvent({ - projectId: inferredProjectId || undefined, - sessionId, - source: options.activityEventSource ?? "session-manager", - kind: "metadata.corrupt_detected", - level: "error", - summary, - data: { - path, - renamedTo: renamed ? corruptPath : null, - renameSucceeded: renamed, - contentSample, - contentLength: content.length, - }, - }); - } + let content: string | undefined; + try { + content = readFileSync(path, "utf-8").trim(); + } catch { + // File doesn't exist } - } else if (!options.createIfMissing) { - return null; - } - const next = normalizeMetadataRecord(updater({ ...existing })); + if (content !== undefined) { + if (content) { + const raw = parseMetadataContent(content); + if (raw) { + existing = flattenToStringRecord(raw); + } else { + // Corrupt JSON. Preserve forensic evidence by side-renaming + // the file before we overwrite it with the merged update. + // Without this, the very next mutateMetadata call destroys + // the corrupt bytes permanently and the user has no signal + // that anything was wrong — the file just becomes "not + // corrupt anymore — and missing fields". + const corruptPath = `${path}.corrupt-${Date.now()}`; + let renamed = false; + try { + renameSync(path, corruptPath); + renamed = true; + // eslint-disable-next-line no-console + console.warn( + `[metadata] corrupt JSON at ${path}; preserved as ${corruptPath} before rewriting`, + ); + } catch { + // best effort — proceed even if the rename fails (e.g. EACCES) + } + // Forensic activity event so RCA can find every silent overwrite. + // Truncate the bad-JSON sample to 200 chars (B11 invariant — full file + // could be 16KB+ and would be dropped by the sanitizer cap). + const contentSample = content.length > 200 ? content.slice(0, 200) : content; + // dataDir is `.../projects/{projectId}/sessions`; recover projectId for filtering. + const inferredProjectId = basename(dirname(dataDir)); + const summary = renamed + ? `Corrupt metadata for session ${sessionId} renamed to ${basename(corruptPath)}` + : `Corrupt metadata detected for session ${sessionId}; failed to rename forensic copy before rewrite`; + recordActivityEvent({ + projectId: inferredProjectId || undefined, + sessionId, + source: options.activityEventSource ?? "session-manager", + kind: "metadata.corrupt_detected", + level: "error", + summary, + data: { + path, + renamedTo: renamed ? corruptPath : null, + renameSucceeded: renamed, + contentSample, + contentLength: content.length, + }, + }); + } + } + } else if (!options.createIfMissing) { + return null; + } - mkdirSync(dirname(path), { recursive: true }); - atomicWriteFileSync(path, serializeMetadata(unflattenFromStringRecord(next))); - return next; - }, { timeoutMs: 5_000, staleMs: 30_000 }); + const next = normalizeMetadataRecord(updater({ ...existing })); + + mkdirSync(dirname(path), { recursive: true }); + atomicWriteFileSync(path, serializeMetadata(unflattenFromStringRecord(next))); + return next; + }, + { timeoutMs: 5_000, staleMs: 30_000 }, + ); } export function readCanonicalLifecycle( @@ -438,11 +472,7 @@ export function writeCanonicalLifecycle( sessionId: SessionId, lifecycle: CanonicalSessionLifecycle, ): void { - updateMetadata( - dataDir, - sessionId, - buildLifecycleMetadataPatch(cloneLifecycle(lifecycle)), - ); + updateMetadata(dataDir, sessionId, buildLifecycleMetadataPatch(cloneLifecycle(lifecycle))); } export function updateCanonicalLifecycle( @@ -483,18 +513,20 @@ export function listMetadata(dataDir: string): SessionId[] { const dir = dataDir; if (!existsSync(dir)) return []; - return readdirSync(dir).filter((name) => { - // Must be a .json file - if (!name.endsWith(JSON_EXTENSION)) return false; - const baseName = name.slice(0, -JSON_EXTENSION.length); - if (!baseName || baseName.startsWith(".")) return false; - if (!SESSION_ID_COMPONENT_PATTERN.test(baseName)) return false; - try { - return statSync(join(dir, name)).isFile(); - } catch { - return false; - } - }).map((name) => name.slice(0, -JSON_EXTENSION.length)); + return readdirSync(dir) + .filter((name) => { + // Must be a .json file + if (!name.endsWith(JSON_EXTENSION)) return false; + const baseName = name.slice(0, -JSON_EXTENSION.length); + if (!baseName || baseName.startsWith(".")) return false; + if (!SESSION_ID_COMPONENT_PATTERN.test(baseName)) return false; + try { + return statSync(join(dir, name)).isFile(); + } catch { + return false; + } + }) + .map((name) => name.slice(0, -JSON_EXTENSION.length)); } /** diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 6f51650bc..30e7dc12e 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1221,6 +1221,18 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // Branch will be generated as feat/{issueId} (line 329-331) } else { // Other error (auth, network, etc) - fail fast + recordActivityEvent({ + projectId: spawnConfig.projectId, + source: "session-manager", + kind: "tracker.issue_fetch_failed", + level: "error", + summary: `tracker getIssue failed for ${spawnConfig.issueId}`, + data: { + issueId: spawnConfig.issueId, + tracker: plugins.tracker.name, + reason: err instanceof Error ? err.message : String(err), + }, + }); throw new Error(`Failed to fetch issue ${spawnConfig.issueId}: ${err}`, { cause: err }); } } @@ -1235,10 +1247,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // step now requires pushing one cleanup, with no risk of forgetting prior // ones. const cleanupStack = new CleanupStack(); + let sessionId: string | undefined; try { // Determine session ID — atomically reserve to prevent concurrent collisions - const { sessionId, tmuxName } = await reserveNextSessionIdentity(project, sessionsDir); - cleanupStack.push(() => deleteMetadata(sessionsDir, sessionId)); + let tmuxName: string | undefined; + ({ sessionId, tmuxName } = await reserveNextSessionIdentity(project, sessionsDir)); + const reservedSessionId = sessionId; + cleanupStack.push(() => deleteMetadata(sessionsDir, reservedSessionId)); // Determine branch name — explicit branch always takes priority let branch: string; @@ -1295,9 +1310,23 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (spawnConfig.issueId && plugins.tracker && resolvedIssue) { try { issueContext = await plugins.tracker.generatePrompt(spawnConfig.issueId, project); - } catch { - // Non-fatal: continue without detailed issue context - // Silently ignore errors - caller can check if issueContext is undefined + } catch (err) { + // Non-fatal: continue without detailed issue context. Surface the + // failure via AE so RCA can answer "did the agent get an enriched + // prompt or just the bare issue ID?" + recordActivityEvent({ + projectId: spawnConfig.projectId, + sessionId, + source: "session-manager", + kind: "tracker.generate_prompt_failed", + level: "warn", + summary: `tracker generatePrompt failed for ${spawnConfig.issueId}`, + data: { + issueId: spawnConfig.issueId, + tracker: plugins.tracker.name, + reason: err instanceof Error ? err.message : String(err), + }, + }); } } @@ -1514,14 +1543,81 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // Log cleanup failures so they don't disappear silently. The original // code used /* best effort */ swallows; the stack preserves that // behavior (cleanup errors don't propagate) but surfaces them for debug. + recordActivityEvent({ + projectId: spawnConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.rollback_started", + level: "warn", + summary: "spawn rollback started", + data: { reason: err instanceof Error ? err.message : String(err) }, + }); await cleanupStack.runAll((cleanupErr) => { console.error("[session-manager] spawn rollback step failed:", cleanupErr); + // B25: emit per-step rollback failure so each leaked resource is queryable. + recordActivityEvent({ + projectId: spawnConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.rollback_step_failed", + level: "error", + summary: "spawn rollback step failed", + data: { + reason: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + }, + }); }); throw err; } } - async function spawnOrchestrator(orchestratorConfig: OrchestratorSpawnConfig): Promise { + function recordOrchestratorSpawnFailed( + orchestratorConfig: OrchestratorSpawnConfig, + err: unknown, + sessionId?: string, + ): void { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + ...(sessionId ? { sessionId } : {}), + source: "session-manager", + kind: "session.spawn_failed", + level: "error", + summary: "orchestrator spawn failed", + data: { + role: "orchestrator", + reason: err instanceof Error ? err.message : String(err), + }, + }); + } + + async function spawnOrchestrator( + orchestratorConfig: OrchestratorSpawnConfig, + options?: { suppressFixedReservationFailure?: boolean }, + ): Promise { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + source: "session-manager", + kind: "session.spawn_started", + summary: "orchestrator spawn started", + data: { agent: orchestratorConfig.agent ?? undefined, role: "orchestrator" }, + }); + try { + return await _spawnOrchestratorInner(orchestratorConfig); + } catch (err) { + const project = config.projects[orchestratorConfig.projectId]; + const sessionId = project ? getOrchestratorSessionId(project) : undefined; + const shouldSuppressRecoverableConflict = + options?.suppressFixedReservationFailure === true && + sessionId !== undefined && + isFixedOrchestratorReservationError(err, sessionId); + if (!shouldSuppressRecoverableConflict) { + recordOrchestratorSpawnFailed(orchestratorConfig, err, sessionId); + } + throw err; + } + } + + async function _spawnOrchestratorInner(orchestratorConfig: OrchestratorSpawnConfig): Promise { const project = config.projects[orchestratorConfig.projectId]; if (!project) { throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); @@ -1582,6 +1678,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM workspacePath = wsInfo.path; adoptedManagedWorkspace = adoptedInfo !== undefined && adoptedInfo !== null; } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator workspace.create failed", + data: { + role: "orchestrator", + stage: "workspace_create", + reason: err instanceof Error ? err.message : String(err), + }, + }); try { deleteMetadata(sessionsDir, sessionId); } catch { @@ -1627,6 +1736,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM await setupPathWrapperWorkspace(workspacePath); } } catch (err) { + // PR tracking and CI fetch hooks are wired here — emit a dedicated AE + // before rolling back so RCA can answer "did the orchestrator launch + // succeed but lose its hook integration?". + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.workspace_hooks_failed", + level: "error", + summary: "orchestrator workspace hooks installation failed", + data: { + agent: plugins.agent.name, + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(); throw err; } @@ -1642,6 +1766,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM systemPromptFile = join(projectDir, `orchestrator-prompt-${sessionId}.md`); writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator systemPrompt write failed", + data: { + role: "orchestrator", + stage: "system_prompt_write", + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1651,6 +1788,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM try { writeWorkspaceOpenCodeAgentsMd(workspacePath, systemPromptFile); } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator AGENTS.md write failed", + data: { + role: "orchestrator", + stage: "agents_md_write", + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1675,6 +1825,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM }); } } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator opencode session resolution failed", + data: { + role: "orchestrator", + stage: "opencode_session_reuse", + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1732,6 +1895,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM }, }); } catch (err) { + // Outer envelope catches and emits session.spawn_failed; this step emit + // tags the runtime.create failure path specifically so RCA can answer + // "did the orchestrator runtime fail to start at all?". + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator runtime.create failed", + data: { + role: "orchestrator", + stage: "runtime_create", + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1819,6 +1998,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } invalidateCache(); } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator post-launch metadata write failed", + data: { + role: "orchestrator", + stage: "post_launch_metadata", + reason: err instanceof Error ? err.message : String(err), + }, + }); // Clean up runtime on post-launch failure try { await plugins.runtime.destroy(handle); @@ -1829,6 +2021,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw err; } + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawned", + summary: `spawned: ${sessionId}`, + data: { + agent: plugins.agent.name, + branch: session.branch ?? undefined, + role: "orchestrator", + }, + }); + return session; } @@ -1897,14 +2102,27 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } try { - return await spawnOrchestrator(orchestratorConfig); + return await spawnOrchestrator(orchestratorConfig, { + suppressFixedReservationFailure: true, + }); } catch (err) { if (!isFixedOrchestratorReservationError(err, sessionId)) { throw err; } + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.orchestrator_conflict", + level: "warn", + summary: "concurrent orchestrator reservation conflict", + data: { reason: err instanceof Error ? err.message : String(err) }, + }); + const concurrent = await waitForConcurrentOrchestrator(sessionId); if (concurrent) return concurrent; + recordOrchestratorSpawnFailed(orchestratorConfig, err, sessionId); throw err; } } @@ -2068,20 +2286,44 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM onDiskLifecycle.session.state !== "done" && onDiskLifecycle.session.state !== "detecting" ) { + const runtimeStateBefore = session.lifecycle.runtime.state; + const runtimeReasonBefore = session.lifecycle.runtime.reason; try { const persisted = buildUpdatedLifecycle(sessionName, raw, (next) => { next.session.state = "detecting"; next.session.reason = "runtime_lost"; next.session.lastTransitionAt = new Date().toISOString(); - next.runtime.state = session.lifecycle!.runtime.state; - next.runtime.reason = session.lifecycle!.runtime.reason; + next.runtime.state = runtimeStateBefore; + next.runtime.reason = runtimeReasonBefore; next.runtime.lastObservedAt = new Date().toISOString(); }); + // B1: persist BEFORE emitting the event updateMetadata(sessionsDir, sessionName, lifecycleMetadataUpdates(raw, persisted)); session.lifecycle = persisted; session.status = deriveLegacyStatus(persisted); - } catch { + recordActivityEvent({ + projectId: sessionProjectId, + sessionId: sessionName, + source: "session-manager", + kind: "runtime.lost_detected", + level: "warn", + summary: `runtime lost reconciled: ${sessionName}`, + data: { + runtimeState: runtimeStateBefore, + runtimeReason: runtimeReasonBefore, + }, + }); + } catch (err) { // Persist failed — in-memory state is still correct for this request + recordActivityEvent({ + projectId: sessionProjectId, + sessionId: sessionName, + source: "session-manager", + kind: "runtime.lost_persist_failed", + level: "error", + summary: `runtime_lost persist failed: ${sessionName}`, + data: { reason: err instanceof Error ? err.message : String(err) }, + }); } } @@ -2192,6 +2434,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const killReason: LifecycleKillReason = options?.reason ?? "manually_killed"; const cleanupAgent = resolveSelectionForSession(project, sessionId, raw).agentName; + // Emit kill_started up-front — this is the only signal that the kill + // intent reached the manager (the destroys below are silent on failure). + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.kill_started", + summary: `kill started: ${sessionId}`, + data: { reason: killReason }, + }); + // Destroy runtime — prefer handle.runtimeName to find the correct plugin if (raw["runtimeHandle"]) { const handle = safeJsonParse(raw["runtimeHandle"]); @@ -2204,8 +2457,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (runtimePlugin) { try { await runtimePlugin.destroy(handle); - } catch { - // Runtime might already be gone + } catch (err) { + // Runtime might already be gone — surface as AE so leaks are queryable. + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "runtime.destroy_failed", + level: "warn", + summary: `runtime.destroy failed during kill: ${sessionId}`, + data: { + runtime: handle.runtimeName ?? null, + reason: err instanceof Error ? err.message : String(err), + }, + }); } } } @@ -2219,8 +2484,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (workspacePlugin) { try { await workspacePlugin.destroy(worktree); - } catch { - // Workspace might already be gone + } catch (err) { + // Workspace might already be gone — emit AE so abandoned worktrees + // surface for cleanup tooling. + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "workspace.destroy_failed", + level: "warn", + summary: `workspace.destroy failed during kill: ${sessionId}`, + data: { + workspace: workspacePlugin.name, + reason: err instanceof Error ? err.message : String(err), + }, + }); } } } @@ -2238,8 +2516,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM try { await deleteOpenCodeSession(mappedOpenCodeSessionId); didPurgeOpenCodeSession = true; - } catch { - void 0; + } catch (err) { + // Dangling opencode session is a real leak — surface for RCA. + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "agent.opencode_purge_failed", + level: "warn", + summary: `opencode session purge failed: ${sessionId}`, + data: { + opencodeSessionId: mappedOpenCodeSessionId, + reason: err instanceof Error ? err.message : String(err), + }, + }); } } } @@ -2372,9 +2662,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM pushSkipped(session.projectId, session.id); } } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); result.errors.push({ sessionId: session.id, - error: err instanceof Error ? err.message : String(err), + error: errorMessage, + }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "session-manager", + kind: "session.cleanup_error", + level: "warn", + summary: `cleanup error: ${session.id}`, + data: { reason: errorMessage }, }); } } @@ -2416,11 +2716,24 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ...existing, opencodeSessionId: "", opencodeCleanedAt: new Date().toISOString(), - })); + }), { activityEventSource: "session-manager" }); } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); result.errors.push({ sessionId: terminatedId, - error: `Failed to delete OpenCode session ${mappedOpenCodeSessionId}: ${err instanceof Error ? err.message : String(err)}`, + error: `Failed to delete OpenCode session ${mappedOpenCodeSessionId}: ${errorMessage}`, + }); + recordActivityEvent({ + projectId: projectKey, + sessionId: terminatedId, + source: "session-manager", + kind: "agent.opencode_purge_failed", + level: "warn", + summary: `opencode session purge failed during cleanup: ${terminatedId}`, + data: { + opencodeSessionId: mappedOpenCodeSessionId, + reason: errorMessage, + }, }); continue; } @@ -2451,7 +2764,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } async function send(sessionId: SessionId, message: string): Promise { - const { raw, sessionsDir, project } = requireSessionRecord(sessionId); + const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId); const selection = resolveSelectionForSession(project, sessionId, raw); const selectedAgent = selection.agentName; @@ -2603,19 +2916,31 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Cannot send to session ${sessionId}: ${reason}`); } + let restored: Session; try { - const restored = await restore(sessionId); - const ready = await waitForRestoredSession(restored); - if (!ready) { - throw new Error("restored session did not become ready for delivery"); - } - return restored; + restored = await restore(sessionId); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`, { cause: err, }); } + + const ready = await waitForRestoredSession(restored); + if (!ready) { + const detail = "restored session did not become ready for delivery"; + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `restore for delivery failed: ${sessionId}`, + data: { stage: "ready_timeout", reason: detail, trigger: "send" }, + }); + throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`); + } + return restored; }; const prepareSession = async (forceRestore = false): Promise => { @@ -2707,29 +3032,52 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return; }; - let prepared = await prepareSession(); - + // Top-level try/catch: any final send failure (initial preparation, + // retry-with-restore, etc.) emits a single `session.send_failed` event + // (B16 — failure-only). Stage tag distinguishes which branch failed. + let stage: "prepare" | "initial" | "restore_retry" = "prepare"; try { - await sendWithConfirmation(prepared); - } catch (err) { - const shouldRetryWithRestore = prepared.restoredAt === undefined && isRestorable(prepared); + let prepared = await prepareSession(); - if (!shouldRetryWithRestore) { - if (err instanceof Error) { - throw err; - } - throw new Error(String(err), { cause: err }); - } - - prepared = await prepareSession(true); try { + stage = "initial"; await sendWithConfirmation(prepared); - } catch (retryErr) { - if (retryErr instanceof Error) { - throw retryErr; + } catch (err) { + const shouldRetryWithRestore = + prepared.restoredAt === undefined && isRestorable(prepared); + + if (!shouldRetryWithRestore) { + if (err instanceof Error) { + throw err; + } + throw new Error(String(err), { cause: err }); + } + + stage = "restore_retry"; + prepared = await prepareSession(true); + try { + await sendWithConfirmation(prepared); + } catch (retryErr) { + if (retryErr instanceof Error) { + throw retryErr; + } + throw new Error(String(retryErr), { cause: retryErr }); } - throw new Error(String(retryErr), { cause: retryErr }); } + } catch (err) { + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.send_failed", + level: "error", + summary: `send failed: ${sessionId}`, + data: { + stage, + reason: err instanceof Error ? err.message : String(err), + }, + }); + throw err; } } @@ -2930,6 +3278,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const reason = NON_RESTORABLE_STATUSES.has(session.status) ? `status "${session.status}" is not restorable` : `session is not in a terminal state (status: "${session.status}", activity: "${session.activity}")`; + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `restore not allowed: ${sessionId}`, + data: { + stage: "validation", + status: session.status, + activity: session.activity, + reason, + }, + }); throw new SessionNotRestorableError(sessionId, reason); } @@ -2950,9 +3312,35 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (!workspaceExists) { // Try to restore workspace if plugin supports it if (!plugins.workspace?.restore) { + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `restore workspace failed: ${sessionId}`, + data: { + stage: "workspace_restore", + workspacePath, + reason: "workspace plugin does not support restore", + }, + }); throw new WorkspaceMissingError(workspacePath, "workspace plugin does not support restore"); } if (!session.branch) { + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `restore workspace failed: ${sessionId}`, + data: { + stage: "workspace_restore", + workspacePath, + reason: "branch metadata is missing", + }, + }); throw new WorkspaceMissingError(workspacePath, "branch metadata is missing"); } try { @@ -2972,6 +3360,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM await plugins.workspace.postCreate(wsInfo, project); } } catch (err) { + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `workspace restore failed: ${sessionId}`, + data: { + stage: "workspace_restore", + workspacePath, + reason: err instanceof Error ? err.message : String(err), + }, + }); throw new WorkspaceMissingError( workspacePath, `restore failed: ${err instanceof Error ? err.message : String(err)}`, @@ -3063,6 +3464,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: reason, }); + // Surface that AO fell back to a fresh launch instead of native restore. + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_fallback", + level: "warn", + summary: `using fresh launch instead of native restore: ${sessionId}`, + data: { agent: plugins.agent.name, reason }, + }); launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); } } else { From 6d48022c876bd6e82b9aad1f0c60f8c5c7e2eaae Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Mon, 18 May 2026 21:04:25 +0530 Subject: [PATCH 18/26] feat(cli): wire activity events into CLI commands and supervisor lifecycle (#1698) * feat(cli): wire activity events into CLI commands and supervisor lifecycle (#1654) Adds "cli" to ActivityEventSource and emits ~30 activity events across the CLI surface so `ao events list --source cli` can answer RCA questions like: - Did AO start cleanly? When? (cli.start_invoked / cli.start_failed) - Was AO shut down gracefully or did it crash? (cli.shutdown_signal / cli.shutdown_completed / cli.shutdown_force_exit / cli.stale_running_pruned) - Did ao spawn / ao update / ao stop / ao migrate-storage fail and why? - Did the auto-restore prompt actually restore sessions? Instrumented files: - packages/core/src/activity-events.ts (cli source) - packages/cli/src/lib/shutdown.ts (signal/completed/failed/force_exit/session_kill_failed) - packages/cli/src/commands/start.ts (start_invoked/start_failed/restore_*/stop_*/daemon_*/last_stop_* /config_migrated) - packages/cli/src/commands/spawn.ts (spawn_invoked/spawn_failed) - packages/cli/src/commands/update.ts (update_invoked/update_failed) - packages/cli/src/commands/setup.ts (setup_failed) - packages/cli/src/commands/migrate-storage.ts (migration_completed/migration_failed) - packages/cli/src/commands/project.ts (project_register_failed) - packages/cli/src/lib/resolve-project.ts (project_resolve_failed/config_recovered/config_recovery_failed) - packages/cli/src/lib/running-state.ts (lock_timeout/stale_running_pruned) - packages/cli/src/lib/credential-resolver.ts (credential_load_failed) All emits are sync, never wrapped in try/catch (recordActivityEvent never throws), and put raw error text in data.errorMessage (not summary, which is FTS-indexed and not credential-sanitized). Tests: - 16 new instrumentation tests across shutdown, migrate-storage, update, resolve-project, and start/stop action paths covering MUST emits. Co-Authored-By: Claude Opus 4.7 * fix: correct CLI activity event semantics * fix(cli): emit migrate-storage invocation event * fix(cli): record last-stop write failures * fix(linear): retry transient API failures * fix(cli): stabilize update instrumentation test * fix(cli): stub process probes in stop instrumentation tests * chore(ci): retrigger checks * Fix CLI failure event review issues * fix: bound linear integration cleanup --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: whoisasx Co-authored-by: Adil Shaikh <106678504+whoisasx@users.noreply.github.com> --- .changeset/cli-activity-events.md | 6 + .changeset/linear-transient-retry.md | 5 + .../commands/migrate-storage.test.ts | 114 ++++ .../cli/__tests__/commands/project.test.ts | 1 + packages/cli/__tests__/commands/setup.test.ts | 44 ++ packages/cli/__tests__/commands/spawn.test.ts | 74 ++- .../start-stop-instrumentation.test.ts | 571 ++++++++++++++++++ packages/cli/__tests__/commands/start.test.ts | 70 ++- .../commands/update-instrumentation.test.ts | 233 +++++++ .../resolve-project-instrumentation.test.ts | 159 +++++ packages/cli/__tests__/lib/shutdown.test.ts | 259 ++++++++ packages/cli/src/commands/migrate-storage.ts | 43 +- packages/cli/src/commands/project.ts | 31 + packages/cli/src/commands/setup.ts | 25 +- packages/cli/src/commands/spawn.ts | 79 ++- packages/cli/src/commands/start.ts | 235 ++++++- packages/cli/src/commands/update.ts | 67 +- packages/cli/src/lib/credential-resolver.ts | 15 +- packages/cli/src/lib/resolve-project.ts | 54 +- packages/cli/src/lib/running-state.ts | 26 +- packages/cli/src/lib/shutdown.ts | 81 ++- packages/core/src/activity-events.ts | 1 + .../src/tracker-linear.integration.test.ts | 37 +- packages/plugins/tracker-linear/src/index.ts | 191 +++--- .../plugins/tracker-linear/test/index.test.ts | 68 ++- 25 files changed, 2345 insertions(+), 144 deletions(-) create mode 100644 .changeset/cli-activity-events.md create mode 100644 .changeset/linear-transient-retry.md create mode 100644 packages/cli/__tests__/commands/migrate-storage.test.ts create mode 100644 packages/cli/__tests__/commands/start-stop-instrumentation.test.ts create mode 100644 packages/cli/__tests__/commands/update-instrumentation.test.ts create mode 100644 packages/cli/__tests__/lib/resolve-project-instrumentation.test.ts create mode 100644 packages/cli/__tests__/lib/shutdown.test.ts diff --git a/.changeset/cli-activity-events.md b/.changeset/cli-activity-events.md new file mode 100644 index 000000000..7528033c4 --- /dev/null +++ b/.changeset/cli-activity-events.md @@ -0,0 +1,6 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-cli": minor +--- + +Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths. diff --git a/.changeset/linear-transient-retry.md b/.changeset/linear-transient-retry.md new file mode 100644 index 000000000..cb1ab2764 --- /dev/null +++ b/.changeset/linear-transient-retry.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-plugin-tracker-linear": patch +--- + +Retry transient Linear API HTTP failures in the direct transport to reduce flakes from brief 5xx/429 responses. diff --git a/packages/cli/__tests__/commands/migrate-storage.test.ts b/packages/cli/__tests__/commands/migrate-storage.test.ts new file mode 100644 index 000000000..cf071ea30 --- /dev/null +++ b/packages/cli/__tests__/commands/migrate-storage.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for migrate-storage activity-event instrumentation (issue #1654). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; +import * as AoCore from "@aoagents/ao-core"; + +const { mockMigrateStorage, mockRollbackStorage } = vi.hoisted(() => ({ + mockMigrateStorage: vi.fn(), + mockRollbackStorage: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + migrateStorage: (...args: unknown[]) => mockMigrateStorage(...args), + rollbackStorage: (...args: unknown[]) => mockRollbackStorage(...args), + recordActivityEvent: vi.fn(), + }; +}); + +import { registerMigrateStorage } from "../../src/commands/migrate-storage.js"; + +const recordedEvents = (): Array> => + vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +describe("ao migrate-storage — activity events", () => { + let program: Command; + let exitSpy: ReturnType; + let consoleErrSpy: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + vi.mocked(AoCore.recordActivityEvent).mockClear(); + mockMigrateStorage.mockReset(); + mockRollbackStorage.mockReset(); + + program = new Command(); + program.exitOverride(); + registerMigrateStorage(program); + + exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + consoleErrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleErrSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + it("emits cli.migration_invoked before migration work starts", async () => { + mockMigrateStorage.mockImplementation(async () => { + expect(recordedEvents()).toContainEqual( + expect.objectContaining({ + kind: "cli.migration_invoked", + source: "cli", + level: "info", + data: expect.objectContaining({ + rollback: false, + dryRun: true, + force: true, + }), + }), + ); + return { projects: 1 }; + }); + + await program.parseAsync(["node", "ao", "migrate-storage", "--dry-run", "--force"]); + + expect(mockMigrateStorage).toHaveBeenCalledOnce(); + }); + + it("emits cli.migration_failed when migrateStorage throws", async () => { + mockMigrateStorage.mockRejectedValue(new Error("disk full")); + + await program.parseAsync(["node", "ao", "migrate-storage"]); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.migration_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + rollback: false, + errorMessage: "disk full", + }), + }), + ); + }); + + it("emits cli.migration_failed when rollbackStorage throws", async () => { + mockRollbackStorage.mockRejectedValue(new Error("rollback boom")); + + await program.parseAsync(["node", "ao", "migrate-storage", "--rollback"]); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.migration_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + rollback: true, + errorMessage: "rollback boom", + }), + }), + ); + }); +}); diff --git a/packages/cli/__tests__/commands/project.test.ts b/packages/cli/__tests__/commands/project.test.ts index 264b64f03..1a322a070 100644 --- a/packages/cli/__tests__/commands/project.test.ts +++ b/packages/cli/__tests__/commands/project.test.ts @@ -23,6 +23,7 @@ vi.mock("@aoagents/ao-core", () => ({ isPortfolioEnabled: () => true, getPortfolio: mockGetPortfolio, getPortfolioSessionCounts: mockGetPortfolioSessionCounts, + recordActivityEvent: vi.fn(), registerProject: mockRegisterProject, unregisterProject: mockUnregisterProject, loadPreferences: mockLoadPreferences, diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index 7914a00ac..3b043e88e 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -31,6 +31,7 @@ vi.mock("@aoagents/ao-core", () => ({ findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), isCanonicalGlobalConfigPath: (configPath: string | undefined) => configPath === join(homedir(), ".agent-orchestrator", "config.yaml"), + recordActivityEvent: vi.fn(), })); vi.mock("node:fs", async (importOriginal) => { @@ -52,6 +53,7 @@ vi.mock("../../src/lib/openclaw-probe.js", () => ({ HOOKS_PATH: "/hooks/agent", })); +import { recordActivityEvent } from "@aoagents/ao-core"; import { registerSetup } from "../../src/commands/setup.js"; // --------------------------------------------------------------------------- @@ -90,6 +92,9 @@ function createProgram(): Command { return program; } +const recordedEvents = (): Array> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -99,6 +104,7 @@ describe("setup openclaw command", () => { beforeEach(() => { vi.restoreAllMocks(); + vi.mocked(recordActivityEvent).mockClear(); mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); mockWriteFileSync.mockImplementation(() => {}); @@ -484,6 +490,44 @@ 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", () => { diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 60ffeb6c7..84e076ce9 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -2,25 +2,28 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { type Session, type SessionManager, getProjectBaseDir } from "@aoagents/ao-core"; +import { + recordActivityEvent, + type Session, + type SessionManager, + getProjectBaseDir, +} from "@aoagents/ao-core"; -const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted( - () => ({ - mockExec: vi.fn(), - mockConfigRef: { current: null as Record | null }, - mockSessionManager: { - list: vi.fn(), - kill: vi.fn(), - cleanup: vi.fn(), - get: vi.fn(), - spawn: vi.fn(), - spawnOrchestrator: vi.fn(), - send: vi.fn(), - claimPR: vi.fn(), - }, - mockGetRunning: vi.fn(), - }), -); +const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(() => ({ + mockExec: vi.fn(), + mockConfigRef: { current: null as Record | null }, + mockSessionManager: { + list: vi.fn(), + kill: vi.fn(), + cleanup: vi.fn(), + get: vi.fn(), + spawn: vi.fn(), + spawnOrchestrator: vi.fn(), + send: vi.fn(), + claimPR: vi.fn(), + }, + mockGetRunning: vi.fn(), +})); vi.mock("../../src/lib/shell.js", () => ({ tmux: vi.fn(), @@ -49,6 +52,7 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => { return { ...actual, loadConfig: () => mockConfigRef.current, + recordActivityEvent: vi.fn(), }; }); @@ -86,6 +90,9 @@ import { registerSpawn, registerBatchSpawn } from "../../src/commands/spawn.js"; let program: Command; let consoleSpy: ReturnType; +const recordedEvents = (): Array> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "ao-spawn-test-")); configPath = join(tmpDir, "agent-orchestrator.yaml"); @@ -134,6 +141,7 @@ beforeEach(() => { mockSessionManager.claimPR.mockReset(); mockExec.mockReset(); mockGetRunning.mockReset(); + vi.mocked(recordActivityEvent).mockClear(); mockRegistryGet.mockReset().mockReturnValue(null); mockGetRunning.mockResolvedValue({ pid: 1234, port: 3000, startedAt: "", projects: ["my-app"] }); }); @@ -534,9 +542,7 @@ describe("spawn command", () => { it("reports error when spawn fails", async () => { mockSessionManager.spawn.mockRejectedValue(new Error("worktree creation failed")); - await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow( - "process.exit(1)", - ); + await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)"); }); it("claims a PR for the spawned session when --claim-pr is provided", async () => { @@ -628,14 +634,7 @@ describe("spawn command", () => { takenOverFrom: ["app-9"], }); - await program.parseAsync([ - "node", - "test", - "spawn", - "--claim-pr", - "123", - "--assign-on-github", - ]); + await program.parseAsync(["node", "test", "spawn", "--claim-pr", "123", "--assign-on-github"]); expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", { assignOnGithub: true, @@ -736,6 +735,19 @@ describe("spawn pre-flight checks", () => { .mock.calls.map((c) => String(c[0])) .join("\n"); expect(errors).toContain("tmux is not installed"); + expect(recordedEvents()).toContainEqual( + expect.objectContaining({ + kind: "cli.spawn_failed", + source: "cli", + projectId: "my-app", + level: "error", + data: expect.objectContaining({ + issueId: null, + agent: null, + errorMessage: "tmux is not installed. Install it: brew install tmux", + }), + }), + ); expect(mockSessionManager.spawn).not.toHaveBeenCalled(); }); @@ -860,7 +872,9 @@ describe("batch-spawn command", () => { return cmd; } - function makeFakeSession(overrides: Partial & Pick): Session { + function makeFakeSession( + overrides: Partial & Pick, + ): Session { return { status: "spawning", activity: null, diff --git a/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts b/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts new file mode 100644 index 000000000..ed0dfd3e1 --- /dev/null +++ b/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts @@ -0,0 +1,571 @@ +/** + * Tests for start.ts activity-event instrumentation (issue #1654). + * + * Covers MUST emits in registerStop and the start action that don't + * require running the full startup pipeline: + * - cli.stop_invoked (start of ao stop action) + * - cli.stop_failed (outer catch of ao stop action) + * - cli.stop_session_failed (per-session kill failure during ao stop) + * - cli.last_stop_write_failed (last-stop persistence failure during ao stop) + * - cli.daemon_killed (SIGTERM sent to parent ao start) + * - cli.start_invoked (true start action entry) + * - cli.start_failed (outer) (outer catch of ao start action) + * - cli.restore_session_failed (per-session restore failure) + * + * cli.start_failed (orchestrator_setup / supervisor_start) is exercised by + * the existing start.test.ts infrastructure; this file + * focuses on emits that are reachable with a small deps surface. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const { + mockSessionManager, + mockGetRunning, + mockUnregister, + mockWriteLastStop, + mockReadLastStop, + mockClearLastStop, + mockAcquireStartupLock, + mockIsAlreadyRunning, + mockFindPidByPort, + mockKillProcessTree, + mockIsWindows, +} = vi.hoisted(() => ({ + mockSessionManager: { + list: vi.fn(), + kill: vi.fn(), + restore: vi.fn(), + ensureOrchestrator: vi.fn(), + get: vi.fn(), + }, + mockGetRunning: vi.fn(), + mockUnregister: vi.fn(), + mockWriteLastStop: vi.fn(), + mockReadLastStop: vi.fn(), + mockClearLastStop: vi.fn(), + mockAcquireStartupLock: vi.fn(), + mockIsAlreadyRunning: vi.fn(), + mockFindPidByPort: vi.fn(), + mockKillProcessTree: vi.fn(), + mockIsWindows: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args), + isWindows: (...args: unknown[]) => mockIsWindows(...args), + killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args), + recordActivityEvent: vi.fn(), + }; +}); + +vi.mock("../../src/lib/create-session-manager.js", () => ({ + getSessionManager: async () => mockSessionManager, + getPluginRegistry: async () => ({ register: vi.fn(), get: () => null }), +})); + +vi.mock("../../src/lib/running-state.js", () => ({ + acquireStartupLock: (...args: unknown[]) => mockAcquireStartupLock(...args), + isAlreadyRunning: (...args: unknown[]) => mockIsAlreadyRunning(...args), + getRunning: (...args: unknown[]) => mockGetRunning(...args), + register: vi.fn(), + unregister: (...args: unknown[]) => mockUnregister(...args), + removeProjectFromRunning: vi.fn(), + addProjectToRunning: vi.fn(), + writeLastStop: (...args: unknown[]) => mockWriteLastStop(...args), + readLastStop: (...args: unknown[]) => mockReadLastStop(...args), + clearLastStop: (...args: unknown[]) => mockClearLastStop(...args), +})); + +vi.mock("../../src/lib/lifecycle-service.js", () => ({ + stopAllLifecycleWorkers: vi.fn(), + listLifecycleWorkers: () => [], +})); + +vi.mock("../../src/lib/project-supervisor.js", () => ({ + startProjectSupervisor: vi.fn(), + stopProjectSupervisor: vi.fn(), +})); + +vi.mock("../../src/lib/preflight.js", () => ({ + preflight: { checkPort: vi.fn(), checkBuilt: vi.fn() }, +})); + +vi.mock("../../src/lib/web-dir.js", () => ({ + findWebDir: vi.fn().mockReturnValue("/fake/web"), + buildDashboardEnv: vi.fn().mockResolvedValue({}), + waitForPortAndOpen: vi.fn(), + openUrl: vi.fn(), + isPortAvailable: vi.fn().mockResolvedValue(true), + findFreePort: vi.fn().mockResolvedValue(3000), + MAX_PORT_SCAN: 100, +})); + +vi.mock("../../src/lib/dashboard-rebuild.js", () => ({ + clearStaleCacheIfNeeded: vi.fn(), + rebuildDashboardProductionArtifacts: vi.fn(), +})); + +vi.mock("../../src/lib/shell.js", () => ({ + exec: vi.fn().mockResolvedValue({ stdout: "" }), + execSilent: vi.fn().mockResolvedValue({ stdout: "" }), + git: vi.fn(), +})); + +vi.mock("../../src/lib/bun-tmp-janitor.js", () => ({ + startBunTmpJanitor: vi.fn(), +})); + +vi.mock("../../src/lib/daemon.js", () => ({ + attachToDaemon: vi.fn(), + killExistingDaemon: vi.fn(), +})); + +vi.mock("../../src/lib/caller-context.js", () => ({ + isHumanCaller: () => false, + getCallerType: () => "automation", +})); + +vi.mock("../../src/lib/detect-env.js", () => ({ + detectEnvironment: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../../src/lib/detect-agent.js", () => ({ + detectAgentRuntime: vi.fn(), + detectAvailableAgents: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../src/lib/git-utils.js", () => ({ + detectDefaultBranch: vi.fn().mockResolvedValue("main"), +})); + +vi.mock("../../src/lib/prompts.js", () => ({ + promptConfirm: vi.fn().mockResolvedValue(false), + promptSelect: vi.fn(), + promptText: vi.fn(), +})); + +vi.mock("../../src/lib/install-helpers.js", () => ({ + canPromptForInstall: vi.fn().mockReturnValue(false), + genericInstallHints: vi.fn().mockReturnValue([]), + askYesNo: vi.fn().mockResolvedValue(false), + runInteractiveCommand: vi.fn(), + tryInstallWithAttempts: vi.fn(), +})); + +vi.mock("../../src/lib/startup-preflight.js", () => ({ + ensureGit: vi.fn(), + runtimePreflight: vi.fn(), +})); + +vi.mock("../../src/lib/shutdown.js", () => ({ + installShutdownHandlers: vi.fn(), +})); + +vi.mock("../../src/lib/resolve-project.js", () => ({ + resolveOrCreateProject: vi.fn(), +})); + +vi.mock("../../src/lib/project-resolution.js", () => ({ + findProjectForDirectory: vi.fn(), +})); + +vi.mock("../../src/lib/repo-utils.js", () => ({ + extractOwnerRepo: vi.fn(), + isValidRepoString: vi.fn(), +})); + +vi.mock("../../src/lib/project-detection.js", () => ({ + detectProjectType: vi.fn(), + generateRulesFromTemplates: vi.fn(), + formatProjectTypeForDisplay: vi.fn(), +})); + +vi.mock("../../src/lib/cli-errors.js", () => ({ + formatCommandError: vi.fn((err: unknown) => String(err)), +})); + +import { recordActivityEvent } from "@aoagents/ao-core"; +import { registerStart, registerStop } from "../../src/commands/start.js"; + +const recordedEvents = (): Array> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); + registerStart(program); + registerStop(program); + return program; +} + +describe("ao stop — activity events", () => { + let exitSpy: ReturnType; + + beforeEach(() => { + vi.mocked(recordActivityEvent).mockClear(); + mockGetRunning.mockReset(); + mockSessionManager.list.mockReset(); + mockSessionManager.kill.mockReset(); + mockUnregister.mockReset(); + mockWriteLastStop.mockReset(); + mockGetRunning.mockResolvedValue(null); + mockFindPidByPort.mockReset(); + mockFindPidByPort.mockResolvedValue(null); + mockKillProcessTree.mockReset(); + mockKillProcessTree.mockResolvedValue(undefined); + mockIsWindows.mockReset(); + mockIsWindows.mockReturnValue(false); + + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + it("emits cli.stop_invoked at the start of the action", async () => { + const projectArg = "https://token@example.com/org/repo.git"; + // Force a fast failure so the action exits quickly after emitting stop_invoked. + mockGetRunning.mockResolvedValue(null); + // Make loadConfig throw so we hit the outer catch + vi.doMock("@aoagents/ao-core", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args), + isWindows: (...args: unknown[]) => mockIsWindows(...args), + killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args), + recordActivityEvent: vi.mocked(recordActivityEvent), + loadConfig: () => { + throw new Error("config not found"); + }, + }; + }); + + vi.resetModules(); + const reloaded = await import("../../src/commands/start.js"); + const program = new Command(); + program.exitOverride(); + reloaded.registerStop(program); + + await expect(program.parseAsync(["node", "ao", "stop", projectArg])).rejects.toThrow(); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.stop_invoked", + source: "cli", + summary: "ao stop invoked", + data: expect.objectContaining({ + projectArg, + }), + }), + ); + + vi.doUnmock("@aoagents/ao-core"); + }); + + it("emits cli.stop_failed when loadConfig throws", async () => { + vi.doMock("@aoagents/ao-core", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args), + isWindows: (...args: unknown[]) => mockIsWindows(...args), + killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args), + recordActivityEvent: vi.mocked(recordActivityEvent), + loadConfig: () => { + throw new Error("config blew up"); + }, + }; + }); + + vi.resetModules(); + const reloaded = await import("../../src/commands/start.js"); + const program = new Command(); + program.exitOverride(); + reloaded.registerStop(program); + + await expect(program.parseAsync(["node", "ao", "stop"])).rejects.toThrow(); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.stop_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ errorMessage: "config blew up" }), + }), + ); + + vi.doUnmock("@aoagents/ao-core"); + }); + + it("emits cli.daemon_killed when SIGTERM is sent to a running daemon", async () => { + mockGetRunning.mockResolvedValue({ + pid: 99999, + configPath: "/tmp/x.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }); + mockSessionManager.list.mockResolvedValue([]); + + vi.doMock("@aoagents/ao-core", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args), + isWindows: (...args: unknown[]) => mockIsWindows(...args), + killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args), + recordActivityEvent: vi.mocked(recordActivityEvent), + loadConfig: () => ({ + configPath: "/tmp/x.yaml", + port: 3000, + projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } }, + defaults: {}, + }), + }; + }); + + vi.resetModules(); + const reloaded = await import("../../src/commands/start.js"); + const program = new Command(); + program.exitOverride(); + reloaded.registerStop(program); + + try { + await program.parseAsync(["node", "ao", "stop"]); + } catch { + // ao stop may exit; we just want the events + } + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.daemon_killed", + source: "cli", + data: expect.objectContaining({ pid: 99999 }), + }), + ); + expect(mockKillProcessTree).toHaveBeenCalledWith(99999, "SIGTERM"); + + vi.doUnmock("@aoagents/ao-core"); + }); + + it("emits cli.stop_session_failed when sm.kill throws during ao stop", async () => { + mockGetRunning.mockResolvedValue({ + pid: 99999, + configPath: "/tmp/x.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }); + mockSessionManager.list.mockResolvedValue([ + { + id: "sess-1", + projectId: "my-app", + status: "working", + }, + ]); + mockSessionManager.kill.mockRejectedValue(new Error("kill timeout")); + vi.spyOn(process, "kill").mockImplementation(() => true); + + vi.doMock("@aoagents/ao-core", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args), + isWindows: (...args: unknown[]) => mockIsWindows(...args), + killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args), + recordActivityEvent: vi.mocked(recordActivityEvent), + loadConfig: () => ({ + configPath: "/tmp/x.yaml", + port: 3000, + projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } }, + defaults: {}, + }), + // isTerminalSession returns false so the session is treated as active + isTerminalSession: () => false, + }; + }); + + vi.resetModules(); + const reloaded = await import("../../src/commands/start.js"); + const program = new Command(); + program.exitOverride(); + reloaded.registerStop(program); + + try { + await program.parseAsync(["node", "ao", "stop"]); + } catch { + // ignored + } + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.stop_session_failed", + source: "cli", + level: "warn", + sessionId: "sess-1", + data: expect.objectContaining({ errorMessage: "kill timeout" }), + }), + ); + + vi.doUnmock("@aoagents/ao-core"); + }); + + it("emits cli.last_stop_write_failed when ao stop cannot persist restore state", async () => { + mockGetRunning.mockResolvedValue(null); + mockSessionManager.list.mockResolvedValue([ + { + id: "sess-1", + projectId: "my-app", + status: "working", + }, + ]); + mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false }); + mockWriteLastStop.mockRejectedValue(new Error("last-stop lock busy")); + + vi.doMock("@aoagents/ao-core", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args), + isWindows: (...args: unknown[]) => mockIsWindows(...args), + killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args), + recordActivityEvent: vi.mocked(recordActivityEvent), + loadConfig: () => ({ + configPath: "/tmp/x.yaml", + port: 3000, + projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } }, + defaults: {}, + }), + // isTerminalSession returns false so the session is treated as active + isTerminalSession: () => false, + }; + }); + + vi.resetModules(); + const reloaded = await import("../../src/commands/start.js"); + const program = new Command(); + program.exitOverride(); + reloaded.registerStop(program); + + await program.parseAsync(["node", "ao", "stop", "my-app"]); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.last_stop_write_failed", + source: "cli", + level: "error", + projectId: "my-app", + data: expect.objectContaining({ + targetSessionCount: 1, + totalKilled: 1, + errorMessage: "last-stop lock busy", + }), + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + kind: "cli.last_stop_written", + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + kind: "cli.stop_failed", + }), + ); + const logs = vi.mocked(console.log).mock.calls.map((c) => String(c[0])); + expect(logs.some((line) => line.includes("Could not list sessions"))).toBe(false); + expect(logs.some((line) => line.includes("Could not write last-stop state"))).toBe(true); + + vi.doUnmock("@aoagents/ao-core"); + }); +}); + +describe("ao start — activity events (failure paths)", () => { + let exitSpy: ReturnType; + + beforeEach(() => { + vi.mocked(recordActivityEvent).mockClear(); + mockAcquireStartupLock.mockReset(); + mockIsAlreadyRunning.mockReset(); + mockAcquireStartupLock.mockResolvedValue(() => undefined); + mockIsAlreadyRunning.mockResolvedValue(null); + + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + it("emits cli.start_failed with reason 'outer' when resolveOrCreateProject throws", async () => { + const projectArg = "https://token@example.com/org/repo.git"; + const resolveProjectMod = await import("../../src/lib/resolve-project.js"); + vi.mocked(resolveProjectMod.resolveOrCreateProject).mockRejectedValue( + new Error("project resolution exploded"), + ); + + const program = buildProgram(); + + try { + await program.parseAsync(["node", "ao", "start", projectArg]); + } catch { + // process.exit(1) throws in the spy + } + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.start_invoked", + source: "cli", + level: "info", + summary: "ao start invoked", + data: expect.objectContaining({ + projectArg, + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.start_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + reason: "outer", + errorMessage: "project resolution exploded", + }), + }), + ); + }); +}); diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 60a247c8e..b821d3637 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -19,7 +19,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { parse as parseYaml } from "yaml"; import { EventEmitter } from "node:events"; -import type { SessionManager } from "@aoagents/ao-core"; +import { recordActivityEvent, type SessionManager } from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Hoisted mocks @@ -153,6 +153,7 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => { sweepDaemonChildren: mockSweepDaemonChildren, scanAoOrphans: mockScanAoOrphans, reapAoOrphans: mockReapAoOrphans, + recordActivityEvent: vi.fn(), }; }); @@ -336,6 +337,7 @@ beforeEach(async () => { program.exitOverride(); registerStart(program); registerStop(program); + vi.mocked(recordActivityEvent).mockClear(); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); @@ -505,6 +507,9 @@ function makeProject(overrides: Record = {}): Record> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + /** Mock process.cwd() to return a specific directory (avoids process.chdir in workers). */ function mockCwd(dir: string): void { cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); @@ -1571,6 +1576,19 @@ describe("start command — orchestrator session strategy display", () => { await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)"); expect(releaseStartupLock).toHaveBeenCalledTimes(1); + const startFailedEvents = recordedEvents().filter((e) => e.kind === "cli.start_failed"); + expect(startFailedEvents).toHaveLength(1); + expect(startFailedEvents[0]).toEqual( + expect.objectContaining({ + projectId: "my-app", + source: "cli", + level: "error", + data: expect.objectContaining({ + reason: "orchestrator_setup", + errorMessage: "Spawn failed", + }), + }), + ); }); it("fails and cleans up dashboard when sm.restore throws on a killed orchestrator", async () => { @@ -1651,6 +1669,56 @@ describe("start command — orchestrator session strategy display", () => { expect(written.stoppedAt).toBe("2026-04-28T10:00:00.000Z"); }); + it("attributes other-project restore failures to the owning project", async () => { + mockReadLastStop.mockResolvedValue({ + stoppedAt: "2026-04-28T10:00:00.000Z", + projectId: "my-app", + sessionIds: ["app-1"], + otherProjects: [{ projectId: "other-app", sessionIds: ["other-1"] }], + }); + + mockConfigRef.current = makeConfig({ + "my-app": makeProject(), + "other-app": makeProject({ name: "Other App", sessionPrefix: "other" }), + }); + const { findWebDir } = await import("../../src/lib/web-dir.js"); + vi.mocked(findWebDir).mockReturnValue(tmpDir); + writeFileSync(join(tmpDir, "package.json"), "{}"); + + const fakeDashboard = { on: vi.fn(), kill: vi.fn(), emit: vi.fn() }; + mockSpawn.mockReturnValue(fakeDashboard); + + mockSessionManager.restore.mockImplementation((id: string) => { + if (id === "other-1") return Promise.reject(new Error("workspace gone")); + return Promise.resolve(undefined); + }); + + await program.parseAsync(["node", "test", "start", "my-app", "--no-orchestrator"]); + + const restoreFailedEvents = recordedEvents().filter( + (e) => e.kind === "cli.restore_session_failed", + ); + expect(restoreFailedEvents).toHaveLength(1); + expect(restoreFailedEvents[0]).toEqual( + expect.objectContaining({ + projectId: "other-app", + sessionId: "other-1", + source: "cli", + level: "warn", + data: expect.objectContaining({ errorMessage: "workspace gone" }), + }), + ); + + const written = mockWriteLastStop.mock.calls[0][0]; + expect(written).toEqual( + expect.objectContaining({ + projectId: "my-app", + sessionIds: [], + otherProjects: [{ projectId: "other-app", sessionIds: ["other-1"] }], + }), + ); + }); + it("clears last-stop record when every session restored successfully", async () => { mockReadLastStop.mockResolvedValue({ stoppedAt: "2026-04-28T10:00:00.000Z", diff --git a/packages/cli/__tests__/commands/update-instrumentation.test.ts b/packages/cli/__tests__/commands/update-instrumentation.test.ts new file mode 100644 index 000000000..30fb22156 --- /dev/null +++ b/packages/cli/__tests__/commands/update-instrumentation.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for update.ts activity-event instrumentation (issue #1654). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; +import { EventEmitter } from "node:events"; +import * as AoCore from "@aoagents/ao-core"; + +const { mockRunRepoScript } = vi.hoisted(() => ({ + mockRunRepoScript: vi.fn(), +})); + +vi.mock("../../src/lib/script-runner.js", () => ({ + runRepoScript: (...args: unknown[]) => mockRunRepoScript(...args), +})); + +const { + mockDetectInstallMethod, + mockCheckForUpdate, + mockInvalidateCache, + mockGetCurrentVersion, + mockGetUpdateCommand, +} = vi.hoisted(() => ({ + mockDetectInstallMethod: vi.fn(() => "git" as const), + mockCheckForUpdate: vi.fn(), + mockInvalidateCache: vi.fn(), + mockGetCurrentVersion: vi.fn(() => "0.2.2"), + mockGetUpdateCommand: vi.fn(() => "npm install -g @aoagents/ao@latest"), +})); + +vi.mock("../../src/lib/update-check.js", () => ({ + detectInstallMethod: () => mockDetectInstallMethod(), + checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args), + invalidateCache: () => mockInvalidateCache(), + getCurrentVersion: () => mockGetCurrentVersion(), + getUpdateCommand: (...args: unknown[]) => mockGetUpdateCommand(...args), + readCachedUpdateInfo: vi.fn(() => undefined), + resolveUpdateChannel: vi.fn(() => "stable"), +})); + +const { mockPromptConfirm } = vi.hoisted(() => ({ + mockPromptConfirm: vi.fn(async () => true), +})); + +vi.mock("../../src/lib/prompts.js", () => ({ + promptConfirm: (...args: unknown[]) => mockPromptConfirm(...args), +})); + +vi.mock("../../src/lib/running-state.js", () => ({ + getRunning: vi.fn().mockResolvedValue(null), +})); + +vi.mock("../../src/lib/create-session-manager.js", () => ({ + getSessionManager: vi.fn(), +})); + +const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, spawn: (...args: unknown[]) => mockSpawn(...args) }; +}); + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getGlobalConfigPath: () => "/tmp/__ao_update_instrumentation_no_global_config__", + recordActivityEvent: vi.fn(), + }; +}); + +import { registerUpdate } from "../../src/commands/update.js"; + +const recordedEvents = (): Array> => + vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +function createMockChild(exitCode: number | null, signal?: NodeJS.Signals): EventEmitter { + const child = new EventEmitter(); + setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0); + return child; +} + +describe("ao update — activity events", () => { + let program: Command; + let origStdinTTY: boolean | undefined; + let origStdoutTTY: boolean | undefined; + + beforeEach(() => { + vi.mocked(AoCore.recordActivityEvent).mockClear(); + program = new Command(); + program.exitOverride(); + registerUpdate(program); + mockRunRepoScript.mockReset(); + mockDetectInstallMethod.mockReturnValue("git"); + mockCheckForUpdate.mockReset(); + mockInvalidateCache.mockReset(); + mockPromptConfirm.mockReset(); + mockPromptConfirm.mockResolvedValue(true); + mockSpawn.mockReset(); + origStdinTTY = process.stdin.isTTY; + origStdoutTTY = process.stdout.isTTY; + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process.stdin, "isTTY", { value: origStdinTTY, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: origStdoutTTY, configurable: true }); + }); + + it("emits cli.update_failed when ao-update.sh exits non-zero (git path)", async () => { + mockDetectInstallMethod.mockReturnValue("git"); + mockRunRepoScript.mockResolvedValue(2); + + // process.exit is mocked to throw — the first `process.exit(2)` triggers + // the throw, which is then re-caught and emits a second event before the + // final exit. The instrumentation event for the non-zero exit is what + // matters; whichever final exit code propagates is incidental. + await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(/process\.exit/); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.update_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ method: "git", exitCode: 2 }), + }), + ); + }); + + it("emits cli.update_failed when ao-update.sh script is missing (git path)", async () => { + mockDetectInstallMethod.mockReturnValue("git"); + mockRunRepoScript.mockRejectedValue(new Error("Script not found: ao-update.sh")); + + await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow("process.exit(1)"); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.update_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ method: "git", reason: "script_missing" }), + }), + ); + }); + + it("emits cli.update_failed when npm install exits non-zero (npm path)", async () => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockCheckForUpdate.mockResolvedValue({ + currentVersion: "0.2.2", + latestVersion: "0.3.0", + isOutdated: true, + installMethod: "npm-global" as const, + recommendedCommand: "npm install -g @aoagents/ao@latest", + checkedAt: new Date().toISOString(), + }); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + mockSpawn.mockReturnValue(createMockChild(1)); + + await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow("process.exit(1)"); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.update_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ method: "npm-global", exitCode: 1 }), + }), + ); + }); + + it("emits cli.update_failed when npm registry lookup returns no version", async () => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockCheckForUpdate.mockResolvedValue({ + currentVersion: "0.2.2", + latestVersion: null, + isOutdated: false, + installMethod: "npm-global" as const, + recommendedCommand: "npm install -g @aoagents/ao@latest", + checkedAt: null, + }); + + await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.update_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + method: "npm-global", + reason: "registry_unreachable", + }), + }), + ); + }); + + it("emits cli.update_failed when npm registry lookup throws", async () => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockCheckForUpdate.mockRejectedValue(new Error("registry timeout")); + + await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.update_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + method: "npm-global", + reason: "registry_lookup_threw", + errorMessage: "registry timeout", + }), + }), + ); + }); +}); diff --git a/packages/cli/__tests__/lib/resolve-project-instrumentation.test.ts b/packages/cli/__tests__/lib/resolve-project-instrumentation.test.ts new file mode 100644 index 000000000..149eb9135 --- /dev/null +++ b/packages/cli/__tests__/lib/resolve-project-instrumentation.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for resolve-project.ts activity-event instrumentation (issue #1654). + * + * Covers MUST emits: + * - cli.project_resolve_failed (clone failure inside fromUrl) + * - cli.config_recovery_failed (registerFlatConfig returns null) + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as AoCore from "@aoagents/ao-core"; + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordActivityEvent: vi.fn(), + // resolveCloneTarget points to a tmp dir; isRepoAlreadyCloned forces the + // clone path so cloneRepo is invoked (and can throw). + resolveCloneTarget: () => "/tmp/__ao_test_clone_target__", + isRepoAlreadyCloned: () => false, + loadConfig: () => ({ + configPath: "/tmp/__ao_test_global_config__", + projects: {}, + }), + }; +}); + +vi.mock("../../src/lib/startup-preflight.js", () => ({ + ensureGit: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../src/lib/web-dir.js", () => ({ + findFreePort: vi.fn().mockResolvedValue(3000), +})); + +vi.mock("../../src/lib/shell.js", () => ({ + git: vi.fn().mockResolvedValue({ stdout: "" }), +})); + +import { resolveOrCreateProject } from "../../src/lib/resolve-project.js"; + +const recordedEvents = (): Array> => + vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +describe("resolve-project — activity events", () => { + beforeEach(() => { + vi.mocked(AoCore.recordActivityEvent).mockClear(); + }); + + it("emits cli.project_resolve_failed when cloneRepo throws (URL into running daemon)", async () => { + const cloneRepo = vi.fn( + async (_parsed: AoCore.ParsedRepoUrl, _target: string, _cwd: string) => { + throw new Error("network down"); + }, + ); + + await expect( + resolveOrCreateProject( + "https://github.com/foo/bar", + { + addProjectToConfig: vi.fn(), + autoCreateConfig: vi.fn(), + resolveProject: vi.fn(), + resolveProjectByRepo: vi.fn(), + registerFlatConfig: vi.fn().mockResolvedValue(null), + cloneRepo, + }, + // targetGlobalRegistry: true → exercises fromUrlIntoGlobal + { targetGlobalRegistry: true }, + ), + ).rejects.toThrow(/Failed to clone/); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.project_resolve_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + ownerRepo: "foo/bar", + errorMessage: "network down", + }), + }), + ); + }); + + it("emits cli.config_recovery_failed when registerFlatConfig returns null", async () => { + // Trigger fromCwdOrId via undefined arg; if loadConfig() throws something + // other than ConfigNotFoundError, the recovery path runs and asks + // registerFlatConfig to fix it. + // + // Here we force the recovery path to fail by stubbing the deps so that: + // 1. autoCreateConfig is not invoked (fromCwdOrId only calls it when + // loadConfig throws ConfigNotFoundError — we trigger a different + // error so the registerFlatConfig branch runs). + // 2. registerFlatConfig returns null (recovery fails). + // + // We can't easily make the real loadConfig() throw a non-ConfigNotFoundError + // synchronously, so instead we patch findConfigFile via the mocked module + // surface. The simplest, robust approach: simulate the public call shape + // and assert that whenever `registerFlatConfig` returns null, the event + // fires at the call site. To do that we drive the function with a + // controlled cwd that lacks a parseable config but has a config file + // present — replicated by stubbing findConfigFile in @aoagents/ao-core. + + // Reach into the same module mock by re-mocking findConfigFile + loadConfig. + vi.doMock("@aoagents/ao-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordActivityEvent: vi.mocked(AoCore.recordActivityEvent), + // findConfigFile returns a path so the recovery branch runs. + findConfigFile: () => "/tmp/__ao_test_flat_config__", + // loadConfig throws a generic Error (not ConfigNotFoundError) so the + // catch block falls into the registerFlatConfig recovery branch. + loadConfig: () => { + throw new Error("malformed config"); + }, + }; + }); + + vi.resetModules(); + const { resolveOrCreateProject: resolveOrCreateProjectReloaded } = + await import("../../src/lib/resolve-project.js"); + // Re-grab the mock so cleared calls inside the doMock factory don't get lost. + const { recordActivityEvent: reloadedRecord } = await import("@aoagents/ao-core"); + vi.mocked(reloadedRecord).mockClear(); + + await expect( + resolveOrCreateProjectReloaded( + undefined, + { + addProjectToConfig: vi.fn(), + autoCreateConfig: vi.fn(), + resolveProject: vi.fn(), + resolveProjectByRepo: vi.fn(), + registerFlatConfig: vi.fn().mockResolvedValue(null), + cloneRepo: vi.fn(), + }, + {}, + ), + ).rejects.toThrow(/malformed config/); + + const events = vi.mocked(reloadedRecord).mock.calls.map((c) => c[0] as Record); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.config_recovery_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + configPath: "/tmp/__ao_test_flat_config__", + errorMessage: "malformed config", + }), + }), + ); + + vi.doUnmock("@aoagents/ao-core"); + }); +}); diff --git a/packages/cli/__tests__/lib/shutdown.test.ts b/packages/cli/__tests__/lib/shutdown.test.ts new file mode 100644 index 000000000..6ff859cc5 --- /dev/null +++ b/packages/cli/__tests__/lib/shutdown.test.ts @@ -0,0 +1,259 @@ +/** + * Tests for shutdown.ts activity-event instrumentation (issue #1654). + * + * Each test mounts handlers via `installShutdownHandlers`, fires a signal, + * and asserts the expected `cli.*` activity events are emitted. Real + * `process.exit` is stubbed so the test process is not terminated by the + * shutdown handler. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { recordActivityEvent } from "@aoagents/ao-core"; + +const { + mockListSessions, + mockKillSession, + mockGetSessionManager, + mockUnregister, + mockWriteLastStop, + mockStopBunTmpJanitor, + mockStopProjectSupervisor, + mockStopAllLifecycleWorkers, + mockLoadConfig, + mockIsTerminalSession, +} = vi.hoisted(() => ({ + mockListSessions: vi.fn(), + mockKillSession: vi.fn(), + mockGetSessionManager: vi.fn(), + mockUnregister: vi.fn(), + mockWriteLastStop: vi.fn(), + mockStopBunTmpJanitor: vi.fn(), + mockStopProjectSupervisor: vi.fn(), + mockStopAllLifecycleWorkers: vi.fn(), + mockLoadConfig: vi.fn(), + mockIsTerminalSession: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), + isTerminalSession: (...args: unknown[]) => mockIsTerminalSession(...args), + recordActivityEvent: vi.fn(), + }; +}); + +vi.mock("../../src/lib/create-session-manager.js", () => ({ + getSessionManager: (...args: unknown[]) => mockGetSessionManager(...args), +})); + +vi.mock("../../src/lib/lifecycle-service.js", () => ({ + stopAllLifecycleWorkers: (...args: unknown[]) => mockStopAllLifecycleWorkers(...args), +})); + +vi.mock("../../src/lib/project-supervisor.js", () => ({ + stopProjectSupervisor: (...args: unknown[]) => mockStopProjectSupervisor(...args), +})); + +vi.mock("../../src/lib/running-state.js", () => ({ + unregister: (...args: unknown[]) => mockUnregister(...args), + writeLastStop: (...args: unknown[]) => mockWriteLastStop(...args), +})); + +vi.mock("../../src/lib/bun-tmp-janitor.js", () => ({ + stopBunTmpJanitor: (...args: unknown[]) => mockStopBunTmpJanitor(...args), +})); + +const recordedEvents = (): Array> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +const flushAsync = async (): Promise => { + // The shutdown handler launches an async IIFE; allow it to settle. + for (let i = 0; i < 10; i++) { + await new Promise((r) => setImmediate(r)); + } +}; + +describe("shutdown handlers — activity events", () => { + let exitSpy: ReturnType; + let originalListenersSigint: NodeJS.SignalsListener[]; + let originalListenersSigterm: NodeJS.SignalsListener[]; + + beforeEach(async () => { + vi.resetModules(); + vi.mocked(recordActivityEvent).mockClear(); + mockListSessions.mockReset(); + mockKillSession.mockReset(); + mockGetSessionManager.mockReset(); + mockUnregister.mockReset(); + mockWriteLastStop.mockReset(); + mockStopBunTmpJanitor.mockReset(); + mockStopProjectSupervisor.mockReset(); + mockStopAllLifecycleWorkers.mockReset(); + mockLoadConfig.mockReset(); + mockIsTerminalSession.mockReset(); + + mockLoadConfig.mockReturnValue({ projects: {} }); + mockIsTerminalSession.mockReturnValue(false); + mockGetSessionManager.mockResolvedValue({ + list: mockListSessions, + kill: mockKillSession, + }); + mockListSessions.mockResolvedValue([]); + mockKillSession.mockResolvedValue({ cleaned: true }); + mockUnregister.mockResolvedValue(undefined); + mockWriteLastStop.mockResolvedValue(undefined); + mockStopBunTmpJanitor.mockResolvedValue(undefined); + + // Snapshot existing signal listeners so we can restore them after each + // test and avoid leaking handlers across tests in the same process. + originalListenersSigint = process.listeners("SIGINT") as NodeJS.SignalsListener[]; + originalListenersSigterm = process.listeners("SIGTERM") as NodeJS.SignalsListener[]; + + exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + // Throw a sentinel to short-circuit the async IIFE without leaving + // the test process in an unknown state. + return undefined as never; + }) as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + + // Restore signal listeners + process.removeAllListeners("SIGINT"); + process.removeAllListeners("SIGTERM"); + for (const l of originalListenersSigint) process.on("SIGINT", l); + for (const l of originalListenersSigterm) process.on("SIGTERM", l); + }); + + it("emits cli.shutdown_signal when SIGINT is received", async () => { + const { installShutdownHandlers } = await import("../../src/lib/shutdown.js"); + installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" }); + + process.emit("SIGINT", "SIGINT"); + await flushAsync(); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.shutdown_signal", + source: "cli", + projectId: "p1", + data: expect.objectContaining({ signal: "SIGINT", exitCode: 130 }), + }), + ); + }); + + it("emits cli.shutdown_completed after clean shutdown", async () => { + const { installShutdownHandlers } = await import("../../src/lib/shutdown.js"); + installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" }); + + process.emit("SIGTERM", "SIGTERM"); + await flushAsync(); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.shutdown_completed", + source: "cli", + projectId: "p1", + }), + ); + }); + + it("emits cli.shutdown_failed when shutdown body throws before completion", async () => { + const { installShutdownHandlers } = await import("../../src/lib/shutdown.js"); + mockGetSessionManager.mockRejectedValue(new Error("getSessionManager boom")); + + installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" }); + + process.emit("SIGTERM", "SIGTERM"); + await flushAsync(); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.shutdown_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ errorMessage: "getSessionManager boom" }), + }), + ); + // Failure path should NOT emit shutdown_completed + const completedEvents = events.filter((e) => e.kind === "cli.shutdown_completed"); + expect(completedEvents).toHaveLength(0); + }); + + it("still unregisters running state when writing last-stop state fails", async () => { + const { installShutdownHandlers } = await import("../../src/lib/shutdown.js"); + mockListSessions.mockResolvedValue([ + { + id: "s1", + projectId: "p1", + status: "working", + }, + ]); + mockWriteLastStop.mockRejectedValue(new Error("disk full")); + + installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" }); + + process.emit("SIGTERM", "SIGTERM"); + await flushAsync(); + + expect(mockWriteLastStop).toHaveBeenCalled(); + expect(mockUnregister).toHaveBeenCalled(); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.last_stop_write_failed", + source: "cli", + level: "error", + data: expect.objectContaining({ + targetSessionCount: 1, + otherProjectCount: 0, + totalKilled: 1, + errorMessage: "disk full", + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.shutdown_completed", + source: "cli", + projectId: "p1", + }), + ); + expect(events.filter((e) => e.kind === "cli.shutdown_failed")).toHaveLength(0); + }); + + it("emits cli.shutdown_force_exit when the 10s timer fires", async () => { + vi.useFakeTimers(); + try { + const { installShutdownHandlers } = await import("../../src/lib/shutdown.js"); + // Hang the async cleanup so the force-exit timer wins. + mockGetSessionManager.mockReturnValue(new Promise(() => {})); + + installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" }); + + process.emit("SIGINT", "SIGINT"); + // Advance past the 10s timeout + await vi.advanceTimersByTimeAsync(10_000); + + const events = recordedEvents(); + expect(events).toContainEqual( + expect.objectContaining({ + kind: "cli.shutdown_force_exit", + source: "cli", + level: "warn", + data: expect.objectContaining({ timeoutMs: 10_000, exitCode: 130 }), + }), + ); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/cli/src/commands/migrate-storage.ts b/packages/cli/src/commands/migrate-storage.ts index 04f450439..4e90bbd28 100644 --- a/packages/cli/src/commands/migrate-storage.ts +++ b/packages/cli/src/commands/migrate-storage.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import chalk from "chalk"; -import { migrateStorage, rollbackStorage } from "@aoagents/ao-core"; +import { migrateStorage, recordActivityEvent, rollbackStorage } from "@aoagents/ao-core"; export function registerMigrateStorage(program: Command): void { program @@ -13,12 +13,31 @@ export function registerMigrateStorage(program: Command): void { .option("--rollback", "Reverse a previous migration (restores .migrated directories)") .action( async (opts: { dryRun?: boolean; force?: boolean; rollback?: boolean }) => { + recordActivityEvent({ + source: "cli", + kind: "cli.migration_invoked", + level: "info", + summary: `storage ${opts.rollback ? "rollback" : "migration"} invoked`, + data: { + rollback: opts.rollback === true, + dryRun: opts.dryRun === true, + force: opts.force === true, + }, + }); + try { if (opts.rollback) { await rollbackStorage({ dryRun: opts.dryRun, log: (msg) => console.log(msg), }); + recordActivityEvent({ + source: "cli", + kind: "cli.migration_completed", + level: "info", + summary: `storage rollback completed`, + data: { rollback: true, dryRun: opts.dryRun === true }, + }); } else { const result = await migrateStorage({ dryRun: opts.dryRun, @@ -31,8 +50,30 @@ export function registerMigrateStorage(program: Command): void { } else { console.log(chalk.green("\nMigration complete.")); } + recordActivityEvent({ + source: "cli", + kind: "cli.migration_completed", + level: "info", + summary: `storage migration completed (${result.projects} project(s))`, + data: { + rollback: false, + dryRun: opts.dryRun === true, + force: opts.force === true, + projects: result.projects, + }, + }); } } catch (err) { + recordActivityEvent({ + source: "cli", + kind: "cli.migration_failed", + level: "error", + summary: `storage migration failed`, + data: { + rollback: opts.rollback === true, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); console.error( chalk.red(err instanceof Error ? err.message : String(err)), ); diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index 5dfc35c21..9c0a97228 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -6,6 +6,7 @@ import { getPortfolio, getPortfolioSessionCounts, isPortfolioEnabled, + recordActivityEvent, registerProject, unregisterProject, loadPreferences, @@ -94,18 +95,48 @@ export function registerProjectCommand(program: Command): void { const existingConfigPath = candidatePaths.find((candidate) => existsSync(candidate)); if (!existingConfigPath) { + recordActivityEvent({ + source: "cli", + kind: "cli.project_register_failed", + level: "warn", + summary: `ao project add: no agent-orchestrator config found`, + data: { resolvedPath, reason: "no_config_found" }, + }); console.error(chalk.red(`No agent-orchestrator.yaml found at ${resolvedPath}`)); process.exit(1); } try { loadConfig(existingConfigPath); + recordActivityEvent({ + source: "cli", + kind: "cli.project_register_failed", + level: "warn", + summary: `ao project add: found old-format config requiring migration`, + data: { + resolvedPath, + configPath: existingConfigPath, + reason: "old_format", + }, + }); console.error( chalk.red( `Found old-format config at ${existingConfigPath}. Run \`ao start\` in that project to migrate it before using \`ao project add\`.`, ), ); } catch (error) { + recordActivityEvent({ + source: "cli", + kind: "cli.project_register_failed", + level: "error", + summary: `ao project add: config load failed`, + data: { + resolvedPath, + configPath: existingConfigPath, + reason: "load_error", + errorMessage: error instanceof Error ? error.message : String(error), + }, + }); console.error( chalk.red( `Found agent-orchestrator config at ${existingConfigPath}, but it could not be loaded: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index fa03f465d..0ae91e5e9 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -14,7 +14,12 @@ import { join } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { parse as yamlParse, parseDocument } from "yaml"; -import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; +import { + CONFIG_SCHEMA_URL, + findConfigFile, + isCanonicalGlobalConfigPath, + recordActivityEvent, +} from "@aoagents/ao-core"; import { probeGateway, validateToken, @@ -512,6 +517,16 @@ export function registerSetup(program: Command): void { try { await runSetupAction(opts); } catch (err) { + recordActivityEvent({ + source: "cli", + kind: "cli.setup_failed", + level: "error", + summary: `ao setup openclaw failed`, + data: { + aborted: err instanceof SetupAbortedError, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); if (err instanceof SetupAbortedError) { console.error(err.message); process.exit(err.exitCode); @@ -574,6 +589,14 @@ export async function runSetupAction(opts: SetupOptions): Promise { 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 -------------------------------------------------- diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index fed2f0585..cb2730b43 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -4,6 +4,7 @@ import type { Command } from "commander"; import { resolve } from "node:path"; import { loadConfig, + recordActivityEvent, resolveSpawnTarget, TERMINAL_STATUSES, type OrchestratorConfig, @@ -216,6 +217,20 @@ async function spawnSession( throw new Error("Prompt must be at most 4096 characters"); } + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.spawn_invoked", + level: "info", + summary: `ao spawn invoked${issueId ? ` for issue ${issueId}` : ""}`, + data: { + issueId: issueId ?? null, + agent: agent ?? null, + hasPrompt: !!sanitizedPrompt, + claimPr: claimOptions?.claimPr ?? null, + }, + }); + const session = await sm.spawn({ projectId, issueId, @@ -243,9 +258,7 @@ async function spawnSession( const issueLabel = issueId ? ` for issue #${issueId}` : ""; const claimLabel = claimedPrUrl ? ` (claimed ${claimedPrUrl})` : ""; const port = config.port ?? DEFAULT_PORT; - spinner.succeed( - `Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`, - ); + spinner.succeed(`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`); console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`); // Open terminal tab if requested @@ -262,6 +275,18 @@ async function spawnSession( console.log(`SESSION=${session.id}`); } catch (err) { spinner.fail("Failed to create or initialize session"); + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.spawn_failed", + level: "error", + summary: `ao spawn failed${issueId ? ` for issue ${issueId}` : ""}`, + data: { + issueId: issueId ?? null, + agent: agent ?? null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); throw err; } } @@ -279,7 +304,10 @@ export function registerSpawn(program: Command): void { .option("--agent ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-pr ", "Immediately claim an existing PR for the spawned session") .option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user") - .option("--prompt ", "Initial prompt/instructions for the agent (use instead of an issue)") + .option( + "--prompt ", + "Initial prompt/instructions for the agent (use instead of an issue)", + ) .action( async ( issue: string | undefined, @@ -326,8 +354,34 @@ export function registerSpawn(program: Command): void { try { await runSpawnPreflight(config, projectId, claimOptions); await ensureAOPollingProject(projectId); + } catch (err) { + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.spawn_failed", + level: "error", + summary: `ao spawn preflight failed${issueId ? ` for issue ${issueId}` : ""}`, + data: { + issueId: issueId ?? null, + agent: opts.agent ?? null, + claimPr: claimOptions.claimPr ?? null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } - await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt); + try { + await spawnSession( + config, + projectId, + issueId, + opts.open, + opts.agent, + claimOptions, + opts.prompt, + ); } catch (err) { console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); process.exit(1); @@ -386,9 +440,7 @@ export function registerBatchSpawn(program: Command): void { console.log(banner("BATCH SESSION SPAWNER")); console.log(); for (const [pid, items] of groups) { - console.log( - ` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`, - ); + console.log(` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`); } console.log(); @@ -404,6 +456,17 @@ export function registerBatchSpawn(program: Command): void { await runSpawnPreflight(config, groupProjectId); await ensureAOPollingProject(groupProjectId); } catch (err) { + recordActivityEvent({ + projectId: groupProjectId, + source: "cli", + kind: "cli.spawn_failed", + level: "error", + summary: `batch-spawn preflight failed for group`, + data: { + batchSize: items.length, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); process.exit(1); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 7f657c399..0602bba8b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -32,6 +32,7 @@ import { findPidByPort, killProcessTree, loadLocalProjectConfigDetailed, + recordActivityEvent, registerProjectInGlobalConfig, getGlobalConfigPath, type OrchestratorConfig, @@ -116,6 +117,17 @@ import { projectSessionUrl } from "../lib/routes.js"; // HELPERS // ============================================================================= +class CliFailureEventRecordedError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "CliFailureEventRecordedError"; + } +} + +function isCliFailureEventRecordedError(err: unknown): boolean { + return err instanceof CliFailureEventRecordedError; +} + function readProjectBehaviorConfig(projectPath: string): LocalProjectConfig { const localConfig = loadLocalProjectConfigDetailed(projectPath); if (localConfig.kind === "loaded") { @@ -162,6 +174,15 @@ async function registerFlatConfig(configPath: string): Promise { ...(repo ? { repo } : {}), }); + recordActivityEvent({ + projectId: registeredProjectId, + source: "cli", + kind: "cli.config_migrated", + level: "info", + summary: `flat config registered into global config`, + data: { projectPath, configPath }, + }); + console.log(chalk.green(` ✓ Registered "${registeredProjectId}"\n`)); return registeredProjectId; } @@ -940,10 +961,21 @@ async function runStartup( } } catch (err) { spinner.fail("Orchestrator setup failed"); + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.start_failed", + level: "error", + summary: `orchestrator setup failed`, + data: { + reason: "orchestrator_setup", + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); if (dashboardProcess) { dashboardProcess.kill(); } - throw new Error( + throw new CliFailureEventRecordedError( `Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`, { cause: err }, ); @@ -957,10 +989,21 @@ async function runStartup( spinner.succeed("Lifecycle project supervisor started"); } catch (err) { spinner.fail("Project supervisor failed to start"); + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.start_failed", + level: "error", + summary: `project supervisor failed to start`, + data: { + reason: "supervisor_start", + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); if (dashboardProcess) { dashboardProcess.kill(); } - throw new Error( + throw new CliFailureEventRecordedError( `Failed to start project supervisor: ${err instanceof Error ? err.message : String(err)}`, { cause: err }, ); @@ -974,12 +1017,21 @@ async function runStartup( if (lastStop && lastStop.sessionIds.length > 0) { const stoppedAgo = `stopped at ${new Date(lastStop.stoppedAt).toLocaleString()}`; const otherProjects = lastStop.otherProjects ?? []; + const restoreProjectBySessionId = new Map(); // Build flat list of all sessions to restore, grouped for display const allRestoreSessions: string[] = [ ...(lastStop.projectId === projectId ? lastStop.sessionIds : []), ...otherProjects.flatMap((p) => p.sessionIds), ]; + for (const sessionId of lastStop.sessionIds) { + restoreProjectBySessionId.set(sessionId, lastStop.projectId); + } + for (const otherProject of otherProjects) { + for (const sessionId of otherProject.sessionIds) { + restoreProjectBySessionId.set(sessionId, otherProject.projectId); + } + } // Display grouped by project const currentProjectSessions = lastStop.projectId === projectId ? lastStop.sessionIds : []; @@ -1005,6 +1057,17 @@ async function runStartup( if (allRestoreSessions.length > 0) { const shouldRestore = await promptConfirm("Restore these sessions?", true); if (shouldRestore) { + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.restore_started", + level: "info", + summary: `restoring ${allRestoreSessions.length} session(s) from last-stop`, + data: { + sessionCount: allRestoreSessions.length, + stoppedAt: lastStop.stoppedAt, + }, + }); // Use global config so the session manager can see all projects let restoreConfig = config; if (otherProjects.length > 0) { @@ -1029,11 +1092,33 @@ async function runStartup( restoredCount++; } catch (err) { failedSessionIds.add(sessionId); + const restoreProjectId = restoreProjectBySessionId.get(sessionId) ?? projectId; + recordActivityEvent({ + projectId: restoreProjectId, + sessionId, + source: "cli", + kind: "cli.restore_session_failed", + level: "warn", + summary: `failed to restore session`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); warnings.push( ` Warning: could not restore ${sessionId}: ${err instanceof Error ? err.message : String(err)}`, ); } } + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.restore_completed", + level: "info", + summary: `restored ${restoredCount}/${allRestoreSessions.length} session(s)`, + data: { + requested: allRestoreSessions.length, + restored: restoredCount, + failed: failedSessionIds.size, + }, + }); if (restoredCount === allRestoreSessions.length) { restoreSpinner.succeed( `Restored ${restoredCount}/${allRestoreSessions.length} session(s)`, @@ -1086,7 +1171,15 @@ async function runStartup( await clearLastStop(); } } - } catch { + } catch (err) { + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.last_stop_read_failed", + level: "warn", + summary: `failed to read or process last-stop state during startup`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); // Non-fatal: don't block startup if last-stop handling fails } } @@ -1359,6 +1452,21 @@ export function registerStart(program: Command): void { reapOrphans?: boolean; }, ) => { + recordActivityEvent({ + source: "cli", + kind: "cli.start_invoked", + level: "info", + summary: "ao start invoked", + data: { + projectArg: projectArg ?? null, + dashboard: opts?.dashboard !== false, + orchestrator: opts?.orchestrator !== false, + rebuild: opts?.rebuild === true, + dev: opts?.dev === true, + interactive: opts?.interactive === true, + }, + }); + let releaseStartupLock: (() => void) | undefined; let startupLockReleased = false; const unlockStartup = (): void => { @@ -1491,6 +1599,13 @@ export function registerStart(program: Command): void { // Resolve happens below; the suffix mutation runs after. startNewOrchestrator = true; } else if (choice === "restart") { + recordActivityEvent({ + source: "cli", + kind: "cli.daemon_restart", + level: "info", + summary: `user chose restart, killing existing daemon`, + data: { existingPid: running.pid, existingPort: running.port }, + }); await killExistingDaemon(running); console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n")); running = null; @@ -1649,6 +1764,18 @@ export function registerStart(program: Command): void { // Ctrl+C and `ao stop` (which sends SIGTERM) perform a full // graceful shutdown via the handler installed inside runStartup(). } catch (err) { + if (!isCliFailureEventRecordedError(err)) { + recordActivityEvent({ + source: "cli", + kind: "cli.start_failed", + level: "error", + summary: `ao start action failed`, + data: { + reason: "outer", + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + } if (err instanceof Error) { console.error(chalk.red("\nError:"), err.message); } else { @@ -1720,6 +1847,17 @@ export function registerStop(program: Command): void { .option("--purge-session", "Delete mapped OpenCode session when stopping") .option("--all", "Stop all running AO instances") .action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => { + recordActivityEvent({ + source: "cli", + kind: "cli.stop_invoked", + level: "info", + summary: "ao stop invoked", + data: { + projectArg: projectArg ?? null, + all: opts.all === true, + purgeSession: opts.purgeSession === true, + }, + }); try { // Check running.json first const running = await getRunning(); @@ -1796,6 +1934,15 @@ export function registerStop(program: Command): void { killedSessionIds.push(session.id); } } catch (err) { + recordActivityEvent({ + projectId: session.projectId ?? _projectId, + sessionId: session.id, + source: "cli", + kind: "cli.stop_session_failed", + level: "warn", + summary: `failed to kill session during ao stop`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); warnings.push( ` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`, ); @@ -1840,12 +1987,48 @@ export function registerStop(program: Command): void { otherProjects.push({ projectId: pid, sessionIds: ids }); } - await writeLastStop({ - stoppedAt: new Date().toISOString(), - projectId: _projectId, - sessionIds: killedSessionIds.filter((id) => targetActive.some((s) => s.id === id)), - otherProjects: otherProjects.length > 0 ? otherProjects : undefined, - }); + const targetSessionIds = killedSessionIds.filter((id) => + targetActive.some((s) => s.id === id), + ); + try { + await writeLastStop({ + stoppedAt: new Date().toISOString(), + projectId: _projectId, + sessionIds: targetSessionIds, + otherProjects: otherProjects.length > 0 ? otherProjects : undefined, + }); + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.last_stop_written", + level: "info", + summary: `last-stop state written with ${killedSessionIds.length} session(s)`, + data: { + targetSessionCount: targetSessionIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + }, + }); + } catch (err) { + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.last_stop_write_failed", + level: "error", + summary: `failed to write last-stop state during ao stop`, + data: { + targetSessionCount: targetSessionIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + console.log( + chalk.yellow( + ` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + } } } catch (err) { console.log( @@ -1872,7 +2055,29 @@ export function registerStop(program: Command): void { // 0x800700e8). No-op on non-Windows. await sweepWindowsPtyHostsBeforeParentKill(); await sweepRegisteredDaemonChildren(running.pid); - await killProcessTree(running.pid, "SIGTERM"); + try { + await killProcessTree(running.pid, "SIGTERM"); + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.daemon_killed", + level: "info", + summary: `SIGTERM sent to parent ao start`, + data: { pid: running.pid, port: running.port }, + }); + } catch (err) { + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.daemon_killed", + level: "warn", + summary: `parent ao start was already dead`, + data: { + pid: running.pid, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + } await unregister(); } else { await sweepRegisteredDaemonChildren(); @@ -1892,6 +2097,16 @@ export function registerStop(program: Command): void { console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`)); } } catch (err) { + recordActivityEvent({ + source: "cli", + kind: "cli.stop_failed", + level: "error", + summary: `ao stop action failed`, + data: { + projectArg: projectArg ?? null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); if (err instanceof Error) { console.error(chalk.red("\nError:"), err.message); } else { diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 8364d613d..8e3d37dd9 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -8,6 +8,7 @@ import { isWindows, loadConfig, loadGlobalConfig, + recordActivityEvent, type Session, } from "@aoagents/ao-core"; import { runRepoScript } from "../lib/script-runner.js"; @@ -83,6 +84,14 @@ export function registerUpdate(program: Command): void { const method = detectInstallMethod(); + recordActivityEvent({ + source: "cli", + kind: "cli.update_invoked", + level: "info", + summary: `ao update invoked (method: ${method})`, + data: { method, options: opts }, + }); + // Reject git-only flags up front when the install isn't a git source. // Without this, users copy/pasting `ao update --skip-smoke` from older // docs would silently no-op on npm/pnpm/bun installs (the flag would be @@ -225,6 +234,13 @@ async function handleGitUpdate(opts: { try { const exitCode = await runRepoScript("ao-update.sh", args); if (exitCode !== 0) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (git) failed: ao-update.sh exited non-zero`, + data: { method: "git", exitCode }, + }); process.exit(exitCode); } invalidateCache(); @@ -233,6 +249,13 @@ async function handleGitUpdate(opts: { error instanceof Error && error.message.includes("Script not found: ao-update.sh") ) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (git) failed: ao-update.sh missing from bundled assets`, + data: { method: "git", reason: "script_missing" }, + }); console.error( chalk.red( "ao-update.sh is missing from the bundled assets. " + @@ -243,6 +266,16 @@ async function handleGitUpdate(opts: { process.exit(1); } + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (git) failed`, + data: { + method: "git", + errorMessage: error instanceof Error ? error.message : String(error), + }, + }); console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } @@ -260,9 +293,34 @@ async function handleNpmUpdate(method: InstallMethod): Promise { // would overwrite cache.channel before we can read it. const previousChannel = readCachedUpdateInfo(method)?.channel; - const info = await checkForUpdate({ force: true, channel }); + let info: Awaited>; + try { + info = await checkForUpdate({ force: true, channel }); + } catch (error) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (${method}) failed: npm registry lookup threw`, + data: { + method, + channel, + reason: "registry_lookup_threw", + errorMessage: error instanceof Error ? error.message : String(error), + }, + }); + console.error(chalk.red("Could not reach npm registry. Check your network and try again.")); + process.exit(1); + } if (!info.latestVersion) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (${method}) failed: npm registry lookup returned no version`, + data: { method, channel, reason: "registry_unreachable" }, + }); console.error(chalk.red("Could not reach npm registry. Check your network and try again.")); process.exit(1); } @@ -369,6 +427,13 @@ async function handleNpmUpdate(method: InstallMethod): Promise { invalidateCache(); console.log(chalk.green("\nUpdate complete.")); } else { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (${method}) failed: install command exited non-zero`, + data: { method, command, exitCode }, + }); process.exit(exitCode); } } diff --git a/packages/cli/src/lib/credential-resolver.ts b/packages/cli/src/lib/credential-resolver.ts index 133ab6224..75fb6a6ff 100644 --- a/packages/cli/src/lib/credential-resolver.ts +++ b/packages/cli/src/lib/credential-resolver.ts @@ -12,6 +12,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { recordActivityEvent } from "@aoagents/ao-core"; /** Keys that AO agents commonly need and OpenClaw may already store. */ const RESOLVABLE_KEYS = [ @@ -66,8 +67,18 @@ function readOpenClawKeys(): Record { } } } - } catch { - // Malformed config — silently skip. + } catch (err) { + recordActivityEvent({ + source: "cli", + kind: "cli.credential_load_failed", + level: "warn", + summary: `failed to read or parse ~/.openclaw/openclaw.json`, + data: { + configPath, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + // Malformed config — silently skip (event surfaces it). } return keys; diff --git a/packages/cli/src/lib/resolve-project.ts b/packages/cli/src/lib/resolve-project.ts index b6bb2bc69..8d1c34de2 100644 --- a/packages/cli/src/lib/resolve-project.ts +++ b/packages/cli/src/lib/resolve-project.ts @@ -27,6 +27,7 @@ import { isRepoUrl, loadConfig, parseRepoUrl, + recordActivityEvent, registerProjectInGlobalConfig, resolveCloneTarget, isRepoAlreadyCloned, @@ -196,6 +197,18 @@ async function fromUrlIntoGlobal(arg: string, deps: ResolveDeps): Promise timeoutMs) { + recordActivityEvent({ + source: "cli", + kind: "cli.lock_timeout", + level: "warn", + summary: `lock acquisition timed out`, + data: { + resourceName, + lockFile, + timeoutMs, + attempts: attempt, + }, + }); throw new Error(`Could not acquire ${resourceName} (${lockFile})`); } @@ -246,6 +258,18 @@ export async function getRunning(): Promise { if (!isProcessAlive(state.pid)) { // Stale entry — process is dead, clean up writeState(null); + recordActivityEvent({ + source: "cli", + kind: "cli.stale_running_pruned", + level: "warn", + summary: `pruned stale running.json entry (dead pid ${state.pid})`, + data: { + pid: state.pid, + port: state.port, + startedAt: state.startedAt, + projects: state.projects, + }, + }); return null; } diff --git a/packages/cli/src/lib/shutdown.ts b/packages/cli/src/lib/shutdown.ts index 228f5f88d..8e024e4f8 100644 --- a/packages/cli/src/lib/shutdown.ts +++ b/packages/cli/src/lib/shutdown.ts @@ -16,6 +16,7 @@ import { isTerminalSession, loadConfig, markDaemonShutdownHandlerInstalled, + recordActivityEvent, sweepDaemonChildren, } from "@aoagents/ao-core"; import { stopBunTmpJanitor } from "./bun-tmp-janitor.js"; @@ -62,6 +63,15 @@ export function installShutdownHandlers(ctx: ShutdownContext): void { const exitCode = signal === "SIGINT" ? 130 : 0; + recordActivityEvent({ + projectId: ctx.projectId, + source: "cli", + kind: "cli.shutdown_signal", + level: "info", + summary: `received ${signal}, beginning graceful shutdown`, + data: { signal, exitCode }, + }); + try { stopProjectSupervisor(); stopAllLifecycleWorkers(); @@ -69,7 +79,17 @@ export function installShutdownHandlers(ctx: ShutdownContext): void { // Best-effort — never block shutdown on observability. } - const forceExit = setTimeout(() => process.exit(exitCode), SHUTDOWN_TIMEOUT_MS); + const forceExit = setTimeout(() => { + recordActivityEvent({ + projectId: ctx.projectId, + source: "cli", + kind: "cli.shutdown_force_exit", + level: "warn", + summary: `force-exit after ${SHUTDOWN_TIMEOUT_MS}ms timeout`, + data: { signal, timeoutMs: SHUTDOWN_TIMEOUT_MS, exitCode }, + }); + process.exit(exitCode); + }, SHUTDOWN_TIMEOUT_MS); forceExit.unref(); void (async () => { @@ -86,8 +106,16 @@ export function installShutdownHandlers(ctx: ShutdownContext): void { if (result.cleaned || result.alreadyTerminated) { killedSessionIds.push(session.id); } - } catch { - // Best-effort per session + } catch (err) { + recordActivityEvent({ + projectId: session.projectId ?? ctx.projectId, + sessionId: session.id, + source: "cli", + kind: "cli.shutdown_session_kill_failed", + level: "warn", + summary: `failed to kill session during shutdown`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); } } @@ -107,18 +135,49 @@ export function installShutdownHandlers(ctx: ShutdownContext): void { for (const [pid, ids] of otherByProject) { otherProjects.push({ projectId: pid, sessionIds: ids }); } - await writeLastStop({ - stoppedAt: new Date().toISOString(), - projectId: ctx.projectId, - sessionIds: targetIds, - otherProjects: otherProjects.length > 0 ? otherProjects : undefined, - }); + try { + await writeLastStop({ + stoppedAt: new Date().toISOString(), + projectId: ctx.projectId, + sessionIds: targetIds, + otherProjects: otherProjects.length > 0 ? otherProjects : undefined, + }); + } catch (err) { + recordActivityEvent({ + projectId: ctx.projectId, + source: "cli", + kind: "cli.last_stop_write_failed", + level: "error", + summary: `failed to write last-stop state during shutdown`, + data: { + targetSessionCount: targetIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + } } await sweepDaemonChildren({ ownerPid: process.pid }); await unregister(); - } catch { - // Best-effort — always exit even if cleanup fails + recordActivityEvent({ + projectId: ctx.projectId, + source: "cli", + kind: "cli.shutdown_completed", + level: "info", + summary: `clean shutdown completed`, + data: { signal, killedSessionCount: killedSessionIds.length, exitCode }, + }); + } catch (err) { + recordActivityEvent({ + projectId: ctx.projectId, + source: "cli", + kind: "cli.shutdown_failed", + level: "error", + summary: `shutdown body threw before cleanup completed`, + data: { signal, errorMessage: err instanceof Error ? err.message : String(err) }, + }); } try { // Await any in-flight sweep so shutdown does not exit while diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index ed9d79841..d7a9313d9 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -25,6 +25,7 @@ export type ActivityEventSource = | "notifier" | "reaction" | "report-watcher" + | "cli" | "config" | "plugin-registry" | "migration" diff --git a/packages/integration-tests/src/tracker-linear.integration.test.ts b/packages/integration-tests/src/tracker-linear.integration.test.ts index 6749761ea..7a8583cdc 100644 --- a/packages/integration-tests/src/tracker-linear.integration.test.ts +++ b/packages/integration-tests/src/tracker-linear.integration.test.ts @@ -38,15 +38,28 @@ const canRun = hasCredentials && Boolean(LINEAR_TEAM_ID); // Helpers // --------------------------------------------------------------------------- +interface LinearGraphQLOptions { + maxAttempts?: number; + retryDelayMs?: number; + timeoutMs?: number; +} + /** * Direct GraphQL call for test setup/cleanup. * Only available when LINEAR_API_KEY is set. */ -function linearGraphQL(query: string, variables: Record): Promise { +function linearGraphQL( + query: string, + variables: Record, + options: LinearGraphQLOptions = {}, +): Promise { if (!LINEAR_API_KEY) { throw new Error("linearGraphQL requires LINEAR_API_KEY"); } const body = JSON.stringify({ query, variables }); + const maxAttempts = options.maxAttempts ?? 3; + const retryDelayMs = options.retryDelayMs ?? 1_000; + const timeoutMs = options.timeoutMs ?? 30_000; async function executeWithRetry(attempt = 1): Promise { try { @@ -95,9 +108,9 @@ function linearGraphQL(query: string, variables: Record): Pr }, ); - req.setTimeout(30_000, () => { + req.setTimeout(timeoutMs, () => { req.destroy(); - reject(new Error("Linear API request timed out")); + reject(new Error(`Linear API request timed out after ${timeoutMs}ms`)); }); req.on("error", (err) => reject(err)); @@ -105,8 +118,8 @@ function linearGraphQL(query: string, variables: Record): Pr req.end(); }); } catch (err) { - if (attempt < 3) { - await new Promise((resolve) => setTimeout(resolve, 1_000)); + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); return executeWithRetry(attempt + 1); } throw err; @@ -179,7 +192,18 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { if (!issueIdentifier) return; try { - if (issueUuid && LINEAR_API_KEY) { + if (LINEAR_API_KEY) { + const cleanupRequest = { maxAttempts: 1, timeoutMs: 5_000 }; + if (!issueUuid) { + const data = await linearGraphQL<{ issue: { id: string } }>( + `query($id: String!) { issue(id: $id) { id } }`, + { id: issueIdentifier }, + cleanupRequest, + ); + issueUuid = data.issue.id; + } + + if (!issueUuid) return; await linearGraphQL( `mutation($id: String!) { issueUpdate(id: $id, input: { trashed: true }) { @@ -187,6 +211,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { } }`, { id: issueUuid }, + cleanupRequest, ); } else { // Composio-only: best-effort close via plugin diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 6101ac1c3..850431e30 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -53,6 +53,43 @@ type GraphQLTransport = (query: string, variables?: Record) // --------------------------------------------------------------------------- const LINEAR_API_URL = "https://api.linear.app/graphql"; +const DIRECT_TRANSPORT_MAX_ATTEMPTS = 3; +const DIRECT_TRANSPORT_RETRY_DELAY_MS = 500; + +class LinearHttpError extends Error { + constructor( + readonly status: number, + body: string, + ) { + super(`Linear API returned HTTP ${status}: ${body.slice(0, 200)}`); + } + + get transient(): boolean { + return this.status === 408 || this.status === 429 || this.status >= 500; + } +} + +class LinearNetworkError extends Error { + constructor(message: string) { + super(`Linear API network error: ${message}`); + } +} + +class LinearTimeoutError extends Error { + constructor() { + super("Linear API request timed out after 30s"); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableDirectTransportError(err: unknown): boolean { + if (err instanceof LinearHttpError) return err.transient; + if (err instanceof LinearTimeoutError) return true; + return err instanceof LinearNetworkError; +} function getApiKey(): string { const key = process.env["LINEAR_API_KEY"]; @@ -70,84 +107,100 @@ interface LinearResponse { } function createDirectTransport(): GraphQLTransport { - return (query: string, variables?: Record): Promise => { + return async (query: string, variables?: Record): Promise => { const apiKey = getApiKey(); const body = JSON.stringify({ query, variables }); - return new Promise((resolve, reject) => { - const url = new URL(LINEAR_API_URL); - let settled = false; - const settle = (fn: () => void) => { - if (!settled) { - settled = true; - fn(); - } - }; + const execute = (): Promise => + new Promise((resolve, reject) => { + const url = new URL(LINEAR_API_URL); + let settled = false; + const settle = (fn: () => void) => { + if (!settled) { + settled = true; + fn(); + } + }; - const req = request( - { - hostname: url.hostname, - path: url.pathname, - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: apiKey, - "Content-Length": Buffer.byteLength(body), - }, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("error", (err: Error) => settle(() => reject(err))); - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", () => { - settle(() => { - try { - const text = Buffer.concat(chunks).toString("utf-8"); - const status = res.statusCode ?? 0; - if (status < 200 || status >= 300) { - reject(new Error(`Linear API returned HTTP ${status}: ${text.slice(0, 200)}`)); - return; - } - const json: LinearResponse = JSON.parse(text); - if (json.errors && json.errors.length > 0) { - reject(new Error(`Linear API error: ${json.errors[0].message}`)); - return; - } - if (!json.data) { - reject(new Error("Linear API returned no data")); - return; - } - resolve(json.data); - } catch (err) { - reject(err); - } - }); - }); - }, - ); - - req.setTimeout(30_000, () => { - settle(() => { - req.destroy(); - recordTransportActivityEvent({ - source: "tracker", - kind: "tracker.api_timeout", - level: "warn", - summary: "Linear API request timed out after 30s", - data: { - plugin: "tracker-linear", - transport: "direct", - timeoutMs: 30_000, + const req = request( + { + hostname: url.hostname, + path: url.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + "Content-Length": Buffer.byteLength(body), }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("error", (err: Error) => + settle(() => reject(new LinearNetworkError(err.message))), + ); + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + settle(() => { + try { + const text = Buffer.concat(chunks).toString("utf-8"); + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + reject(new LinearHttpError(status, text)); + return; + } + const json: LinearResponse = JSON.parse(text); + if (json.errors && json.errors.length > 0) { + reject(new Error(`Linear API error: ${json.errors[0].message}`)); + return; + } + if (!json.data) { + reject(new Error("Linear API returned no data")); + return; + } + resolve(json.data); + } catch (err) { + reject(err); + } + }); + }); + }, + ); + + req.setTimeout(30_000, () => { + settle(() => { + req.destroy(); + recordTransportActivityEvent({ + source: "tracker", + kind: "tracker.api_timeout", + level: "warn", + summary: "Linear API request timed out after 30s", + data: { + plugin: "tracker-linear", + transport: "direct", + timeoutMs: 30_000, + }, + }); + reject(new LinearTimeoutError()); }); - reject(new Error("Linear API request timed out after 30s")); }); + + req.on("error", (err) => settle(() => reject(new LinearNetworkError(err.message)))); + req.write(body); + req.end(); }); - req.on("error", (err) => settle(() => reject(err))); - req.write(body); - req.end(); - }); + for (let attempt = 1; attempt <= DIRECT_TRANSPORT_MAX_ATTEMPTS; attempt++) { + try { + return await execute(); + } catch (err) { + const shouldRetry = + isRetryableDirectTransportError(err) && attempt < DIRECT_TRANSPORT_MAX_ATTEMPTS; + if (!shouldRetry) throw err; + await sleep(DIRECT_TRANSPORT_RETRY_DELAY_MS * attempt); + } + } + + throw new Error("unreachable"); }; } diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts index d96d21e55..d31f562ac 100644 --- a/packages/plugins/tracker-linear/test/index.test.ts +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -136,6 +136,40 @@ function mockHTTPError(statusCode: number, body: string) { ); } +/** Queue a request-level network error before Linear returns a response. */ +function mockRequestError(message: string) { + requestMock.mockImplementationOnce(() => { + const req = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(() => { + process.nextTick(() => req.emit("error", new Error(message))); + }), + destroy: vi.fn(), + setTimeout: vi.fn(), + }); + return req; + }); +} + +/** Queue a client-side timeout before Linear returns a response. */ +function mockRequestTimeout() { + requestMock.mockImplementationOnce(() => { + let timeoutHandler: (() => void) | undefined; + const req = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(() => { + process.nextTick(() => timeoutHandler?.()); + }), + destroy: vi.fn(), + setTimeout: vi.fn((_ms: number, handler: () => void) => { + timeoutHandler = handler; + return req; + }), + }); + return req; + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -271,11 +305,41 @@ describe("tracker-linear plugin", () => { }); it("throws on HTTP errors", async () => { - mockHTTPError(500, "Internal Server Error"); + mockHTTPError(400, "Bad Request"); await expect(tracker.getIssue("INT-123", project)).rejects.toThrow( - "Linear API returned HTTP 500", + "Linear API returned HTTP 400", ); }); + + it("retries transient HTTP errors", async () => { + mockHTTPError(502, "Bad Gateway"); + mockLinearAPI({ issue: sampleIssueNode }); + + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); + + it("retries request-level network errors", async () => { + mockRequestError("ECONNRESET"); + mockLinearAPI({ issue: sampleIssueNode }); + + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); + + it("retries client-side request timeouts", async () => { + mockRequestTimeout(); + mockLinearAPI({ issue: sampleIssueNode }); + + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); }); // ---- isCompleted ------------------------------------------------------- From 07c90996d21f4c2a27a9e7650850329ff49a804c Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 19 May 2026 02:50:34 +0530 Subject: [PATCH 19/26] fix(web): show Restore button for every exited session, including pr_merged (#1909) * fix(web): show Restore button for every exited session, including pr_merged The Restore button was hidden for sessions exited with `pr_merged` reason (legacy status `cleanup`) on the dashboard kanban and absent altogether from the session-detail "Terminal ended" panel. The core `isRestorable()` helper already allowed restoring these sessions; the dashboard helpers were out of sync, compensating for a non-existent core constraint. - `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES` only, matching core's `isRestorable`. Merged-but-running sessions (runtime still alive) remain non-restorable. - `DoneCard` no longer hides Restore for merged sessions. - `SessionEndedSummary` now exposes a prominent `Restore session` button alongside `Open PR` / `Back to dashboard`, so users don't have to find the small icon button in the header. Closes #1907 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): add :focus-visible styles to ended-summary action buttons Address PR #1909 review feedback. The new Restore session button (and existing Open PR / Back to dashboard pills) lacked a visible focus ring, making keyboard navigation invisible. Apply the same accent outline used elsewhere in the dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/restore-button-pr-merged.md | 19 ++++++++++ packages/web/src/app/globals.css | 8 ++++ packages/web/src/components/Dashboard.tsx | 2 +- packages/web/src/components/SessionDetail.tsx | 2 + .../src/components/SessionEndedSummary.tsx | 19 +++++++++- .../__tests__/Dashboard.doneBar.test.tsx | 4 +- .../__tests__/SessionDetail.desktop.test.tsx | 26 +++++++++++++ packages/web/src/lib/__tests__/types.test.ts | 37 ++++++++++++++++++- packages/web/src/lib/types.ts | 7 +--- 9 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 .changeset/restore-button-pr-merged.md diff --git a/.changeset/restore-button-pr-merged.md b/.changeset/restore-button-pr-merged.md new file mode 100644 index 000000000..125ad6b7a --- /dev/null +++ b/.changeset/restore-button-pr-merged.md @@ -0,0 +1,19 @@ +--- +"@aoagents/ao-web": patch +--- + +fix(web): show Restore button for every exited session, including pr_merged + +The Restore button was hidden for sessions exited with `pr_merged` reason (legacy +status `cleanup`) on the dashboard kanban and absent altogether from the +session-detail "Terminal ended" panel. The core `isRestorable()` helper already +allowed restoring these sessions; the dashboard helpers were out of sync. Fixes +#1907. + +- `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES` only, + matching core's `isRestorable`. Merged-but-running sessions remain + non-restorable (lifecycle isn't terminal). +- `DoneCard` no longer hides Restore for merged sessions. +- `SessionEndedSummary` exposes a prominent `Restore session` button next to + `Open PR` / `Back to dashboard`, so users don't have to find the small + header icon. diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 14f6fcf83..e47aa6308 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -719,10 +719,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { justify-content: center; min-height: 36px; border-radius: 999px; + font-family: inherit; font-size: 12px; font-weight: 650; padding: 0 15px; text-decoration: none; + cursor: pointer; transition: transform 120ms ease, border-color 120ms ease, @@ -747,6 +749,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { text-decoration: none; } +.session-ended-summary__primary:focus-visible, +.session-ended-summary__secondary:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + .session-ended-summary__evidence { display: grid; gap: 6px; diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index c270831f1..ff646762a 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -125,7 +125,7 @@ function DoneCard({ ) : null} {formatRelativeTimeCompact(session.lastActivityAt)} - {canRestore && !isMerged ? ( + {canRestore ? (
+ {isRestorable ? ( + + ) : null} {pr ? ( Open PR #{pr.number} diff --git a/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx index 24cfe6a05..6d838dd70 100644 --- a/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx @@ -60,10 +60,10 @@ describe("Dashboard done bar", () => { expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument(); }); - it("does not render a restore action for merged sessions", () => { + it("renders a restore action for merged sessions", () => { render(); const toggle = screen.getByText("Done / Terminated").closest("button")!; fireEvent.click(toggle); - expect(screen.queryByRole("button", { name: /restore/i })).toBeNull(); + expect(screen.getByRole("button", { name: /restore/i })).toBeInTheDocument(); }); }); diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index 07a77e48f..d92e80e6a 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -223,6 +223,32 @@ describe("SessionDetail desktop layout", () => { "/projects/my-app", ); expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument(); + // The ended-session body also exposes a prominent "Restore session" button + // so users don't have to find the small icon in the header. + expect( + within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", { + name: "Restore session", + }), + ).toBeInTheDocument(); + }); + + it("shows the Restore button in the ended-summary for pr_merged sessions (status=cleanup)", () => { + render( + , + ); + expect( + within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", { + name: "Restore session", + }), + ).toBeInTheDocument(); }); it("keeps restored working sessions live when terminatedAt is stale", () => { diff --git a/packages/web/src/lib/__tests__/types.test.ts b/packages/web/src/lib/__tests__/types.test.ts index ceee22169..04b6036a1 100644 --- a/packages/web/src/lib/__tests__/types.test.ts +++ b/packages/web/src/lib/__tests__/types.test.ts @@ -264,7 +264,7 @@ describe("getAttentionLevel", () => { }); describe("restore affordances", () => { - it("should not mark merged sessions as restorable", () => { + it("should not mark a running merged session as restorable (runtime still alive)", () => { const session = createSession({ status: "merged", lifecycle: { @@ -298,6 +298,41 @@ describe("getAttentionLevel", () => { expect(isDashboardSessionRestorable(session)).toBe(false); }); + + it("should mark a pr_merged-cleanup session as restorable", () => { + const session = createSession({ + status: "cleanup", + lifecycle: { + sessionState: "terminated", + sessionReason: "pr_merged", + prState: "merged", + prReason: "merged", + runtimeState: "missing", + runtimeReason: "pr_merged_cleanup", + session: { + state: "terminated", + reason: "pr_merged", + label: "terminated", + reasonLabel: "pr merged", + }, + pr: { state: "merged", reason: "merged", label: "merged", reasonLabel: "merged" }, + runtime: { + state: "missing", + reason: "pr_merged_cleanup", + label: "missing", + reasonLabel: "pr merged cleanup", + }, + legacyStatus: "cleanup", + evidence: null, + detectingAttempts: 0, + detectingEscalatedAt: null, + summary: "Session cleaned up after PR merge", + guidance: null, + }, + }); + + expect(isDashboardSessionRestorable(session)).toBe(true); + }); }); describe("respond state", () => { diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 5174e0782..85f93d225 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -436,12 +436,9 @@ export function isDashboardSessionRestorable(session: DashboardSession): boolean isDashboardSessionTerminated(session) || session.lifecycle.runtimeState === "missing" || session.lifecycle.runtimeState === "exited"; - return ( - terminalByCoreTruth && session.lifecycle.prState !== "merged" && session.status !== "merged" - ); + return terminalByCoreTruth && !NON_RESTORABLE_STATUSES.has(session.status); } - if (!isDashboardSessionTerminal(session)) return false; - return session.pr?.state !== "merged" && session.status !== "merged"; + return isDashboardSessionTerminal(session) && !NON_RESTORABLE_STATUSES.has(session.status); } /** From f3e45959e678d359623d7fd4d1404def2f34b673 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 19 May 2026 11:47:57 +0530 Subject: [PATCH 20/26] Add orchestrator-driven code review board (#1871) * feat: add orchestrator-driven code review board * feat: wire review findings back to workers * feat(web): send review feedback to workers * fix(core): mark stale review runs outdated * fix: restore reviewer flow after main merge * Fix review lock lint failure * Guard concurrent review executions --------- Co-authored-by: Madhav Kumar --- .../cli/__tests__/commands/review.test.ts | 372 ++++++ .../cli/__tests__/commands/status.test.ts | 85 +- packages/cli/__tests__/program.test.ts | 4 + packages/cli/src/commands/review.ts | 294 +++++ packages/cli/src/commands/status.ts | 103 +- packages/cli/src/program.ts | 2 + .../src/__tests__/code-review-manager.test.ts | 738 +++++++++++ .../src/__tests__/code-review-store.test.ts | 101 ++ .../core/src/__tests__/global-config.test.ts | 51 + .../__tests__/migration-storage-v2.test.ts | 2 + .../src/__tests__/plugin-integration.test.ts | 2 +- packages/core/src/code-review-manager.ts | 1050 +++++++++++++++ packages/core/src/code-review-store.ts | 504 ++++++++ packages/core/src/global-config.ts | 82 +- packages/core/src/index.ts | 48 + packages/core/src/paths.ts | 5 + packages/core/src/prompts/orchestrator.md | 29 + packages/core/vitest.config.ts | 11 + packages/web/e2e/review-board-flows.md | 110 ++ packages/web/e2e/review-board.e2e.ts | 871 +++++++++++++ packages/web/package.json | 1 + packages/web/src/__tests__/api-routes.test.ts | 19 +- .../__tests__/project-detail-route.test.ts | 31 + packages/web/src/__tests__/review-api.test.ts | 328 +++++ .../web/src/app/api/reviews/execute/route.ts | 63 + .../web/src/app/api/reviews/findings/route.ts | 53 + packages/web/src/app/api/reviews/route.ts | 87 ++ .../web/src/app/api/reviews/send/route.ts | 56 + packages/web/src/app/api/sessions/route.ts | 6 +- packages/web/src/app/globals.css | 548 ++++++++ packages/web/src/app/review/page.tsx | 37 + packages/web/src/app/reviews/page.tsx | 11 + .../web/src/app/sessions/[id]/page.test.tsx | 7 +- packages/web/src/components/AttentionZone.tsx | 23 +- packages/web/src/components/Dashboard.tsx | 43 +- .../web/src/components/ProjectSidebar.tsx | 27 +- .../web/src/components/ReviewDashboard.tsx | 1150 +++++++++++++++++ packages/web/src/components/SessionCard.tsx | 158 ++- .../Dashboard.projectOverview.test.tsx | 24 + .../__tests__/ReviewDashboard.test.tsx | 188 +++ .../__tests__/SessionCard.coverage.test.tsx | 13 +- .../__tests__/SessionDetail.desktop.test.tsx | 54 + .../src/lib/__tests__/review-types.test.ts | 21 + packages/web/src/lib/__tests__/types.test.ts | 45 + packages/web/src/lib/review-page-data.ts | 178 +++ packages/web/src/lib/review-types.ts | 77 ++ packages/web/src/lib/routes.ts | 8 + packages/web/src/lib/types.ts | 36 +- 48 files changed, 7637 insertions(+), 119 deletions(-) create mode 100644 packages/cli/__tests__/commands/review.test.ts create mode 100644 packages/cli/src/commands/review.ts create mode 100644 packages/core/src/__tests__/code-review-manager.test.ts create mode 100644 packages/core/src/__tests__/code-review-store.test.ts create mode 100644 packages/core/src/code-review-manager.ts create mode 100644 packages/core/src/code-review-store.ts create mode 100644 packages/web/e2e/review-board-flows.md create mode 100644 packages/web/e2e/review-board.e2e.ts create mode 100644 packages/web/src/__tests__/review-api.test.ts create mode 100644 packages/web/src/app/api/reviews/execute/route.ts create mode 100644 packages/web/src/app/api/reviews/findings/route.ts create mode 100644 packages/web/src/app/api/reviews/route.ts create mode 100644 packages/web/src/app/api/reviews/send/route.ts create mode 100644 packages/web/src/app/review/page.tsx create mode 100644 packages/web/src/app/reviews/page.tsx create mode 100644 packages/web/src/components/ReviewDashboard.tsx create mode 100644 packages/web/src/components/__tests__/ReviewDashboard.test.tsx create mode 100644 packages/web/src/lib/__tests__/review-types.test.ts create mode 100644 packages/web/src/lib/review-page-data.ts create mode 100644 packages/web/src/lib/review-types.ts diff --git a/packages/cli/__tests__/commands/review.test.ts b/packages/cli/__tests__/commands/review.test.ts new file mode 100644 index 000000000..19d0b7950 --- /dev/null +++ b/packages/cli/__tests__/commands/review.test.ts @@ -0,0 +1,372 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createActivitySignal, + createInitialCanonicalLifecycle, + createCodeReviewStore, + type OrchestratorConfig, + type Session, + type SessionManager, +} from "@aoagents/ao-core"; +import type * as AoCore from "@aoagents/ao-core"; + +const { mockConfigRef, mockSessionManager, reviewStoreRootRef } = vi.hoisted(() => ({ + mockConfigRef: { current: null as OrchestratorConfig | null }, + mockSessionManager: { + get: vi.fn(), + list: vi.fn(), + spawn: vi.fn(), + spawnOrchestrator: vi.fn(), + ensureOrchestrator: vi.fn(), + restore: vi.fn(), + kill: vi.fn(), + cleanup: vi.fn(), + send: vi.fn(), + claimPR: vi.fn(), + }, + reviewStoreRootRef: { current: "" }, +})); + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = await importOriginal(); + const createIsolatedStore = (projectId: string, options: AoCore.CodeReviewStoreOptions = {}) => + actual.createCodeReviewStore(projectId, { + ...options, + storeDir: options.storeDir ?? `${reviewStoreRootRef.current}/${projectId}`, + }); + + return { + ...actual, + createCodeReviewStore: createIsolatedStore, + triggerCodeReviewForSession: ( + options: AoCore.TriggerCodeReviewOptions, + input: AoCore.TriggerCodeReviewInput, + ) => + actual.triggerCodeReviewForSession( + { + ...options, + storeFactory: options.storeFactory ?? createIsolatedStore, + }, + input, + ), + executeCodeReviewRun: ( + options: AoCore.ExecuteCodeReviewRunOptions, + input: AoCore.ExecuteCodeReviewRunInput, + ) => + actual.executeCodeReviewRun( + { + ...options, + storeFactory: options.storeFactory ?? createIsolatedStore, + }, + input, + ), + sendCodeReviewFindingsToAgent: ( + options: AoCore.SendCodeReviewFindingsOptions, + input: AoCore.SendCodeReviewFindingsInput, + ) => + actual.sendCodeReviewFindingsToAgent( + { + ...options, + storeFactory: options.storeFactory ?? createIsolatedStore, + }, + input, + ), + loadConfig: () => mockConfigRef.current, + }; +}); + +vi.mock("../../src/lib/create-session-manager.js", () => ({ + getSessionManager: async (): Promise => mockSessionManager as SessionManager, +})); + +function makeSession(overrides: Partial = {}): Session { + const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2026-05-10T10:00:00.000Z")); + lifecycle.session.state = "idle"; + lifecycle.session.reason = "awaiting_external_review"; + lifecycle.pr.state = "open"; + lifecycle.pr.reason = "review_pending"; + lifecycle.pr.number = 7; + lifecycle.pr.url = "https://github.com/acme/app/pull/7"; + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + + return { + id: "app-1", + projectId: "app", + status: "review_pending", + activity: "idle", + activitySignal: createActivitySignal("valid", { + activity: "idle", + timestamp: new Date("2026-05-10T10:00:00.000Z"), + source: "native", + }), + lifecycle, + branch: "feat/todos", + issueId: null, + pr: { + number: 7, + url: "https://github.com/acme/app/pull/7", + title: "feat: todos", + owner: "acme", + repo: "app", + branch: "feat/todos", + baseBranch: "main", + isDraft: false, + }, + workspacePath: null, + runtimeHandle: { id: "tmux-app-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date("2026-05-10T09:00:00.000Z"), + lastActivityAt: new Date("2026-05-10T10:00:00.000Z"), + metadata: {}, + ...overrides, + }; +} + +function createGitRepo(path: string): void { + mkdirSync(path, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: path }); + writeFileSync(join(path, "README.md"), "# App\n"); + execFileSync("git", ["add", "README.md"], { cwd: path }); + execFileSync("git", ["commit", "-m", "initial"], { + cwd: path, + env: { + ...process.env, + GIT_AUTHOR_NAME: "AO Test", + GIT_AUTHOR_EMAIL: "ao@example.com", + GIT_COMMITTER_NAME: "AO Test", + GIT_COMMITTER_EMAIL: "ao@example.com", + }, + }); +} + +let tmpDir: string; +let originalHome: string | undefined; +let consoleLogSpy: ReturnType; + +import { Command } from "commander"; +import { registerReview } from "../../src/commands/review.js"; + +let program: Command; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "ao-cli-review-test-")); + reviewStoreRootRef.current = join(tmpDir, "review-store"); + originalHome = process.env["HOME"]; + process.env["HOME"] = tmpDir; + + mockConfigRef.current = { + configPath: join(tmpDir, "agent-orchestrator.yaml"), + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "codex", workspace: "worktree", notifiers: [] }, + projects: { + app: { + name: "App", + path: join(tmpDir, "app"), + defaultBranch: "main", + sessionPrefix: "app", + }, + docs: { + name: "Docs", + path: join(tmpDir, "docs"), + defaultBranch: "main", + sessionPrefix: "docs", + }, + }, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, + }; + + const appPath = join(tmpDir, "app"); + createGitRepo(appPath); + createGitRepo(join(tmpDir, "docs")); + + mockSessionManager.get.mockReset(); + mockSessionManager.get.mockResolvedValue(makeSession({ workspacePath: appPath })); + mockSessionManager.list.mockReset(); + mockSessionManager.send.mockReset(); + + createCodeReviewStore("app").deleteAll(); + createCodeReviewStore("docs").deleteAll(); + + program = new Command(); + program.exitOverride(); + registerReview(program); + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); +}); + +afterEach(() => { + if (originalHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = originalHome; + } + rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("review command", () => { + it("requests and lists review runs through the CLI", async () => { + await program.parseAsync(["node", "test", "review", "run", "app-1", "--json"]); + + const runPayload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as { + run: { linkedSessionId: string; reviewerSessionId: string; status: string }; + }; + expect(runPayload.run).toMatchObject({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + }); + + consoleLogSpy.mockClear(); + await program.parseAsync(["node", "test", "review", "list", "app", "--json"]); + + const listPayload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as { + runs: Array<{ linkedSessionId: string; reviewerSessionId: string }>; + }; + expect(listPayload.runs).toHaveLength(1); + expect(listPayload.runs[0]).toMatchObject({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + }); + }); + + it("rejects unknown review run statuses", async () => { + await expect( + program.parseAsync(["node", "test", "review", "run", "app-1", "--status", "bogus"]), + ).rejects.toThrow("process.exit(1)"); + }); + + it("can request and execute a review run directly", async () => { + await program.parseAsync([ + "node", + "test", + "review", + "run", + "app-1", + "--execute", + "--command", + `printf '%s\\n' '{"findings":[{"severity":"warning","title":"CLI finding","body":"Detected in CLI."}]}'`, + "--json", + ]); + + const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as { + run: { status: string; findingCount: number; openFindingCount: number }; + }; + expect(payload.run).toMatchObject({ + status: "needs_triage", + findingCount: 1, + openFindingCount: 1, + }); + }); + + it("executes the oldest queued review run across all projects when no run is specified", async () => { + const appStore = createCodeReviewStore("app"); + const docsStore = createCodeReviewStore("docs"); + const appPath = join(tmpDir, "app"); + const docsPath = join(tmpDir, "docs"); + mockSessionManager.get.mockImplementation(async (sessionId: string) => { + if (sessionId === "docs-1") { + return makeSession({ id: "docs-1", projectId: "docs", workspacePath: docsPath }); + } + if (sessionId === "app-1") { + return makeSession({ id: "app-1", projectId: "app", workspacePath: appPath }); + } + return null; + }); + const older = docsStore.createRun( + { + linkedSessionId: "docs-1", + reviewerSessionId: "docs-rev-1", + status: "queued", + }, + new Date("2026-05-10T10:00:00.000Z"), + ); + const newer = appStore.createRun( + { + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + }, + new Date("2026-05-10T11:00:00.000Z"), + ); + + await program.parseAsync([ + "node", + "test", + "review", + "execute", + "--command", + `printf '%s\\n' '{"findings":[]}'`, + "--json", + ]); + + const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as { + run: { id: string; reviewerSessionId: string; status: string }; + }; + expect(payload.run).toMatchObject({ + id: older.id, + reviewerSessionId: "docs-rev-1", + status: "clean", + }); + expect(createCodeReviewStore("app").getRun(newer.id)?.status).toBe("queued"); + }); + + it("sends open review findings to the linked coding worker", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + prNumber: 7, + }); + store.createFinding({ + runId: run.id, + linkedSessionId: "app-1", + severity: "warning", + title: "CLI finding", + body: "Detected in CLI.", + filePath: "src/app.ts", + startLine: 12, + }); + + await program.parseAsync([ + "node", + "test", + "review", + "send", + "app-rev-1", + "--project", + "app", + "--json", + ]); + + const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as { + run: { status: string; openFindingCount: number; sentFindingCount: number }; + sentFindingCount: number; + message: string; + }; + expect(payload).toMatchObject({ + sentFindingCount: 1, + run: { + status: "waiting_update", + openFindingCount: 0, + sentFindingCount: 1, + }, + }); + expect(payload.message).toContain("AO reviewer app-rev-1 found 1 open issue"); + expect(payload.message).toContain("Location: src/app.ts:12"); + expect(mockSessionManager.send).toHaveBeenCalledWith( + "app-1", + expect.stringContaining("[warning] CLI finding"), + ); + }); +}); diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index e9af2024a..7e7ab90b4 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -16,8 +16,10 @@ import { type ActivityState, createInitialCanonicalLifecycle, createActivitySignal, + createCodeReviewStore, sessionFromMetadata, } from "@aoagents/ao-core"; +import type * as AoCore from "@aoagents/ao-core"; const { mockTmux, @@ -32,6 +34,7 @@ const { mockSessionManager, mockGetPluginRegistry, sessionsDirRef, + reviewStoreRootRef, } = vi.hoisted(() => ({ mockTmux: vi.fn(), mockGit: vi.fn(), @@ -54,6 +57,7 @@ const { }, mockGetPluginRegistry: vi.fn(), sessionsDirRef: { current: "" }, + reviewStoreRootRef: { current: "" }, })); vi.mock("../../src/lib/shell.js", () => ({ @@ -76,10 +80,17 @@ vi.mock("../../src/lib/shell.js", () => ({ })); vi.mock("@aoagents/ao-core", async (importOriginal) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, + createCodeReviewStore: ( + projectId: string, + options: AoCore.CodeReviewStoreOptions = {}, + ) => + actual.createCodeReviewStore(projectId, { + ...options, + storeDir: options.storeDir ?? `${reviewStoreRootRef.current}/${projectId}`, + }), loadConfig: () => mockConfigRef.current, }; }); @@ -211,6 +222,7 @@ vi.mock("../../src/lib/create-session-manager.js", () => ({ let tmpDir: string; let sessionsDir: string; +let originalHome: string | undefined; import { Command } from "commander"; import { registerStatus } from "../../src/commands/status.js"; @@ -223,6 +235,8 @@ let processOnceSpy: ReturnType | undefined; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "ao-status-test-")); + originalHome = process.env["HOME"]; + process.env["HOME"] = tmpDir; const configPath = join(tmpDir, "agent-orchestrator.yaml"); writeFileSync(configPath, "projects: {}"); @@ -256,6 +270,7 @@ beforeEach(() => { sessionsDir = join(tmpDir, "sessions"); mkdirSync(sessionsDir, { recursive: true }); sessionsDirRef.current = sessionsDir; + reviewStoreRootRef.current = join(tmpDir, "code-reviews"); program = new Command(); program.exitOverride(); @@ -296,6 +311,11 @@ beforeEach(() => { }); afterEach(() => { + if (originalHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = originalHome; + } setIntervalSpy?.mockRestore(); setIntervalSpy = undefined; clearIntervalSpy?.mockRestore(); @@ -326,6 +346,67 @@ describe("status command", () => { expect(output).toContain("no active sessions"); }); + it("shows AO-local reviewer runs separately from coding sessions", async () => { + const store = createCodeReviewStore("my-app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + summary: "Review run", + }); + store.createFinding({ + runId: run.id, + linkedSessionId: "app-1", + severity: "warning", + title: "Review finding", + body: "Finding body", + }); + + await program.parseAsync(["node", "test", "status"]); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("Reviews:"); + expect(output).toContain("app-rev-1"); + expect(output).toContain("needs_triage"); + expect(output).toContain("app-1"); + expect(output).toContain("1 open finding"); + }); + + it("includes reviewer runs in JSON status output", async () => { + const store = createCodeReviewStore("my-app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + }); + store.createFinding({ + runId: run.id, + linkedSessionId: "app-1", + severity: "warning", + title: "Review finding", + body: "Finding body", + }); + + await program.parseAsync(["node", "test", "status", "--json"]); + + const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(jsonCalls) as { + reviews: Array<{ reviewerSessionId: string; linkedSessionId: string; openFindingCount: number }>; + meta: { reviewRunCount: number; activeReviewRunCount: number; openReviewFindingCount: number }; + }; + expect(parsed.reviews).toHaveLength(1); + expect(parsed.reviews[0]).toMatchObject({ + reviewerSessionId: "app-rev-1", + linkedSessionId: "app-1", + openFindingCount: 1, + }); + expect(parsed.meta).toMatchObject({ + reviewRunCount: 1, + activeReviewRunCount: 1, + openReviewFindingCount: 1, + }); + }); + it("displays sessions from tmux with metadata", async () => { // Create metadata files writeFileSync( diff --git a/packages/cli/__tests__/program.test.ts b/packages/cli/__tests__/program.test.ts index e854a2b03..5af2ac638 100644 --- a/packages/cli/__tests__/program.test.ts +++ b/packages/cli/__tests__/program.test.ts @@ -10,4 +10,8 @@ describe("createProgram", () => { it("registers the project command", () => { expect(createProgram().commands.some((command) => command.name() === "project")).toBe(true); }); + + it("registers the review command", () => { + expect(createProgram().commands.some((command) => command.name() === "review")).toBe(true); + }); }); diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts new file mode 100644 index 000000000..488b24032 --- /dev/null +++ b/packages/cli/src/commands/review.ts @@ -0,0 +1,294 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { + createShellCodeReviewRunner, + createCodeReviewStore, + executeCodeReviewRun, + loadConfig, + sendCodeReviewFindingsToAgent, + SessionNotFoundError, + triggerCodeReviewForSession, + type CodeReviewRunStatus, + type CodeReviewRunSummary, +} from "@aoagents/ao-core"; +import { getSessionManager } from "../lib/create-session-manager.js"; + +const RUN_STATUSES: ReadonlySet = new Set([ + "queued", + "preparing", + "running", + "needs_triage", + "sent_to_agent", + "waiting_update", + "clean", + "outdated", + "failed", + "cancelled", +]); + +function parseRunStatus(value: string | undefined): CodeReviewRunStatus | undefined { + if (!value) return undefined; + if (RUN_STATUSES.has(value as CodeReviewRunStatus)) { + return value as CodeReviewRunStatus; + } + throw new Error(`Unknown review status: ${value}`); +} + +function printRun(run: CodeReviewRunSummary): void { + const findings = + run.openFindingCount === 1 ? "1 open finding" : `${run.openFindingCount} open findings`; + const parts = [ + chalk.green(run.reviewerSessionId), + chalk.dim(run.id), + run.status, + chalk.cyan(run.linkedSessionId), + findings, + ]; + + if (run.prNumber) { + parts.push(chalk.blue(`PR #${run.prNumber}`)); + } + + console.log(parts.join(" ")); +} + +function printSendResult(run: CodeReviewRunSummary, sentFindingCount: number): void { + const findings = sentFindingCount === 1 ? "1 finding" : `${sentFindingCount} findings`; + console.log(chalk.green(`Sent ${findings} to ${chalk.cyan(run.linkedSessionId)}:`)); + printRun(run); +} + +function getRunProjectId( + projectIds: string[], + runId: string, +): { projectId: string; run: CodeReviewRunSummary } | null { + for (const projectId of projectIds) { + const run = createCodeReviewStore(projectId) + .listRunSummaries() + .find((entry) => entry.id === runId || entry.reviewerSessionId === runId); + if (run) return { projectId, run }; + } + return null; +} + +function getNextQueuedRun( + projectIds: string[], +): { projectId: string; run: CodeReviewRunSummary } | null { + return ( + projectIds + .flatMap((projectId) => + createCodeReviewStore(projectId) + .listRunSummaries({ status: "queued" }) + .map((run) => ({ projectId, run })), + ) + .sort( + (a, b) => + a.run.createdAt.localeCompare(b.run.createdAt) || + a.projectId.localeCompare(b.projectId) || + a.run.id.localeCompare(b.run.id), + )[0] ?? null + ); +} + +export function registerReview(program: Command): void { + const review = program.command("review").description("Manage AO-local reviewer runs"); + + review + .command("run") + .description("Request a reviewer run for a worker session") + .argument("", "Worker session ID") + .option("--summary ", "Summary to store on the review run") + .option("--status ", "Initial run status (defaults to queued)") + .option("--execute", "Execute the review run immediately") + .option("--command ", "Shell command to execute as the reviewer") + .option("--json", "Output as JSON") + .action( + async ( + sessionId: string, + opts: { + summary?: string; + status?: string; + execute?: boolean; + command?: string; + json?: boolean; + }, + ) => { + try { + const config = loadConfig(); + const sessionManager = await getSessionManager(config); + let run = await triggerCodeReviewForSession( + { config, sessionManager }, + { + sessionId, + requestedBy: "cli", + status: parseRunStatus(opts.status), + summary: opts.summary, + }, + ); + + if (opts.execute || opts.command) { + run = await executeCodeReviewRun( + { + config, + sessionManager, + ...(opts.command ? { runReviewer: createShellCodeReviewRunner(opts.command) } : {}), + }, + { projectId: run.projectId, runId: run.id }, + ); + } + + if (opts.json) { + console.log(JSON.stringify({ run }, null, 2)); + return; + } + + console.log( + chalk.green( + opts.execute || opts.command ? "Review run executed:" : "Review run requested:", + ), + ); + printRun(run); + } catch (error) { + if (error instanceof SessionNotFoundError) { + console.error(chalk.red(error.message)); + process.exit(1); + } + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + }, + ); + + review + .command("execute") + .description("Execute a queued AO-local reviewer run") + .argument("[project]", "Project ID (searches all projects if omitted)") + .option("--run ", "Review run ID or reviewer session ID") + .option("--command ", "Shell command to execute as the reviewer") + .option("--force", "Execute even if the run is not queued") + .option("--json", "Output as JSON") + .action( + async ( + projectId: string | undefined, + opts: { run?: string; command?: string; force?: boolean; json?: boolean }, + ) => { + try { + const config = loadConfig(); + if (projectId && !config.projects[projectId]) { + throw new Error(`Unknown project: ${projectId}`); + } + + const projectIds = projectId ? [projectId] : Object.keys(config.projects); + const target = opts.run + ? getRunProjectId(projectIds, opts.run) + : getNextQueuedRun(projectIds); + if (!target) { + throw new Error( + opts.run ? `Review run not found: ${opts.run}` : "No queued review runs found.", + ); + } + + const sessionManager = await getSessionManager(config); + const run = await executeCodeReviewRun( + { + config, + sessionManager, + force: opts.force, + ...(opts.command ? { runReviewer: createShellCodeReviewRunner(opts.command) } : {}), + }, + { projectId: target.projectId, runId: target.run.id }, + ); + + if (opts.json) { + console.log(JSON.stringify({ run }, null, 2)); + return; + } + + console.log(chalk.green("Review run executed:")); + printRun(run); + } catch (error) { + if (error instanceof SessionNotFoundError) { + console.error(chalk.red(error.message)); + process.exit(1); + } + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + }, + ); + + review + .command("send") + .description("Send open AO-local review findings to the linked coding worker") + .argument("", "Review run ID or reviewer session ID") + .option("-p, --project ", "Project ID (searches all projects if omitted)") + .option("--json", "Output as JSON") + .action(async (runRef: string, opts: { project?: string; json?: boolean }) => { + try { + const config = loadConfig(); + if (opts.project && !config.projects[opts.project]) { + throw new Error(`Unknown project: ${opts.project}`); + } + + const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); + const target = getRunProjectId(projectIds, runRef); + if (!target) { + throw new Error(`Review run not found: ${runRef}`); + } + + const sessionManager = await getSessionManager(config); + const result = await sendCodeReviewFindingsToAgent( + { config, sessionManager }, + { projectId: target.projectId, runId: target.run.id }, + ); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + printSendResult(result.run, result.sentFindingCount); + } catch (error) { + if (error instanceof SessionNotFoundError) { + console.error(chalk.red(error.message)); + process.exit(1); + } + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + }); + + review + .command("list") + .description("List AO-local reviewer runs") + .argument("[project]", "Project ID (lists all projects if omitted)") + .option("--json", "Output as JSON") + .action(async (projectId: string | undefined, opts: { json?: boolean }) => { + try { + const config = loadConfig(); + if (projectId && !config.projects[projectId]) { + throw new Error(`Unknown project: ${projectId}`); + } + + const projectIds = projectId ? [projectId] : Object.keys(config.projects); + const runs = projectIds.flatMap((id) => createCodeReviewStore(id).listRunSummaries()); + + if (opts.json) { + console.log(JSON.stringify({ runs }, null, 2)); + return; + } + + if (runs.length === 0) { + console.log(chalk.dim("No review runs found.")); + return; + } + + for (const run of runs) { + printRun(run); + } + } catch (error) { + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 16e063bcc..72b3327f9 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -19,6 +19,9 @@ import { loadConfig, getProjectSessionsDir, readAgentReportAuditTrailAsync, + createCodeReviewStore, + type CodeReviewRunStatus, + type CodeReviewRunSummary, } from "@aoagents/ao-core"; import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js"; import { @@ -61,6 +64,24 @@ interface StatusOptions { reports?: string; } +interface ProjectReviewStatus { + projectId: string; + runs: CodeReviewRunSummary[]; + runCount: number; + activeRunCount: number; + openFindingCount: number; +} + +const REVIEW_ATTENTION_STATUSES: ReadonlySet = new Set([ + "queued", + "preparing", + "running", + "needs_triage", + "sent_to_agent", + "waiting_update", + "failed", +]); + /** Parse --reports value: "full" → Infinity, positive integer → N, undefined → 0 (off). */ function parseReportsLimit(value: string | undefined): number { if (value === undefined) return 0; @@ -89,6 +110,21 @@ function maybeClearScreen(): void { } } +function gatherProjectReviewStatus(projectId: string): ProjectReviewStatus { + const runs = createCodeReviewStore(projectId).listRunSummaries(); + const activeRunCount = runs.filter( + (run) => REVIEW_ATTENTION_STATUSES.has(run.status) || run.openFindingCount > 0, + ).length; + const openFindingCount = runs.reduce((sum, run) => sum + run.openFindingCount, 0); + return { + projectId, + runs, + runCount: runs.length, + activeRunCount, + openFindingCount, + }; +} + async function gatherSessionInfo( session: Session, agent: Agent, @@ -306,6 +342,44 @@ function printOrchestratorRow(info: SessionInfo): void { printReportRows(info.reports, " "); } +function printReviewStatus(summary: ProjectReviewStatus): void { + if (summary.runCount === 0) return; + + const findings = + summary.openFindingCount === 1 + ? "1 open finding" + : `${summary.openFindingCount} open findings`; + const active = + summary.activeRunCount === 1 ? "1 active" : `${summary.activeRunCount} active`; + + console.log( + ` ${chalk.magenta("Reviews:")} ${summary.runCount} run${summary.runCount !== 1 ? "s" : ""} · ${active} · ${findings}`, + ); + + const visibleRuns = summary.runs + .filter((run) => REVIEW_ATTENTION_STATUSES.has(run.status) || run.openFindingCount > 0) + .slice(0, 5); + + for (const run of visibleRuns) { + const findingText = + run.openFindingCount === 1 + ? "1 open finding" + : `${run.openFindingCount} open findings`; + console.log( + ` ${chalk.green(run.reviewerSessionId)} ${chalk.dim(run.status)} → ${chalk.cyan(run.linkedSessionId)} ${chalk.dim(findingText)}`, + ); + } + + const hiddenCount = summary.runs.length - visibleRuns.length; + if (hiddenCount > 0) { + console.log( + chalk.dim( + ` ${hiddenCount} more review run${hiddenCount !== 1 ? "s" : ""}. Use \`ao review list ${summary.projectId}\`.`, + ), + ); + } +} + export function registerStatus(program: Command): void { program .command("status") @@ -406,8 +480,12 @@ export function registerStatus(program: Command): void { // Show projects that have no sessions too (if not filtered) const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); const jsonOutput: SessionInfo[] = []; + const reviewOutput: CodeReviewRunSummary[] = []; let totalWorkers = 0; let totalOrchestrators = 0; + let totalReviewRuns = 0; + let totalActiveReviewRuns = 0; + let totalOpenReviewFindings = 0; for (const projectId of projectIds) { const projectConfig = config.projects[projectId]; @@ -416,6 +494,11 @@ export function registerStatus(program: Command): void { const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) => a.id.localeCompare(b.id), ); + const reviewStatus = gatherProjectReviewStatus(projectId); + reviewOutput.push(...reviewStatus.runs); + totalReviewRuns += reviewStatus.runCount; + totalActiveReviewRuns += reviewStatus.activeRunCount; + totalOpenReviewFindings += reviewStatus.openFindingCount; // Resolve agent and SCM for this project via the shared registry const agentName = projectConfig.agent ?? config.defaults.agent; @@ -429,6 +512,7 @@ export function registerStatus(program: Command): void { if (projectSessions.length === 0) { if (!opts.json) { console.log(chalk.dim(" (no active sessions)")); + printReviewStatus(reviewStatus); console.log(); } continue; @@ -462,6 +546,7 @@ export function registerStatus(program: Command): void { if (workers.length === 0) { console.log(chalk.dim(" (no active sessions)")); + printReviewStatus(reviewStatus); console.log(); continue; } @@ -470,13 +555,23 @@ export function registerStatus(program: Command): void { for (const info of workers) { printSessionRow(info); } + printReviewStatus(reviewStatus); console.log(); } if (opts.json) { console.log( JSON.stringify( - { data: jsonOutput, meta: { hiddenTerminatedCount } }, + { + data: jsonOutput, + reviews: reviewOutput, + meta: { + hiddenTerminatedCount, + reviewRunCount: totalReviewRuns, + activeReviewRunCount: totalActiveReviewRuns, + openReviewFindingCount: totalOpenReviewFindings, + }, + }, null, 2, ), @@ -487,6 +582,12 @@ export function registerStatus(program: Command): void { ` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` + (totalOrchestrators > 0 ? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}` + : "") + + (totalReviewRuns > 0 + ? ` · ${totalReviewRuns} review run${totalReviewRuns !== 1 ? "s" : ""}` + + (totalOpenReviewFindings > 0 + ? ` · ${totalOpenReviewFindings} open finding${totalOpenReviewFindings !== 1 ? "s" : ""}` + : "") : ""), ), ); diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 71cb76136..6e0519a88 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -5,6 +5,7 @@ import { registerSession } from "./commands/session.js"; import { registerSend } from "./commands/send.js"; import { registerAcknowledge, registerReport } from "./commands/report.js"; import { registerReviewCheck } from "./commands/review-check.js"; +import { registerReview } from "./commands/review.js"; import { registerDashboard } from "./commands/dashboard.js"; import { registerOpen } from "./commands/open.js"; import { registerStart, registerStop } from "./commands/start.js"; @@ -39,6 +40,7 @@ export function createProgram(): Command { registerAcknowledge(program); registerReport(program); registerReviewCheck(program); + registerReview(program); registerDashboard(program); registerOpen(program); registerVerify(program); diff --git a/packages/core/src/__tests__/code-review-manager.test.ts b/packages/core/src/__tests__/code-review-manager.test.ts new file mode 100644 index 000000000..8c9e4802a --- /dev/null +++ b/packages/core/src/__tests__/code-review-manager.test.ts @@ -0,0 +1,738 @@ +import { randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createActivitySignal } from "../activity-signal.js"; +import { createCodeReviewStore, type CodeReviewStore } from "../code-review-store.js"; +import { + buildCodexCodeReviewArgs, + CodeReviewNoOpenFindingsError, + executeCodeReviewRun, + markOutdatedCodeReviewRunsForSession, + parseReviewerOutput, + prepareGitReviewerWorkspace, + sendCodeReviewFindingsToAgent, + triggerCodeReviewForSession, +} from "../code-review-manager.js"; +import { createInitialCanonicalLifecycle } from "../lifecycle-state.js"; +import { + SessionNotFoundError, + type OrchestratorConfig, + type Session, + type SessionManager, +} from "../types.js"; + +let storeDir: string; +let store: CodeReviewStore; + +const config: OrchestratorConfig = { + configPath: "/tmp/ao/agent-orchestrator.yaml", + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "codex", workspace: "worktree", notifiers: [] }, + projects: { + app: { + name: "App", + path: "/tmp/app", + defaultBranch: "main", + sessionPrefix: "app", + }, + }, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, +}; + +function makeSession(overrides: Partial & { id?: string } = {}): Session { + const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2026-05-10T10:00:00.000Z")); + lifecycle.session.state = "idle"; + lifecycle.session.reason = "awaiting_external_review"; + lifecycle.pr.state = "open"; + lifecycle.pr.reason = "review_pending"; + lifecycle.pr.number = 7; + lifecycle.pr.url = "https://github.com/acme/app/pull/7"; + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + + return { + id: "app-1", + projectId: "app", + status: "review_pending", + activity: "idle", + activitySignal: createActivitySignal("valid", { + activity: "idle", + timestamp: new Date("2026-05-10T10:00:00.000Z"), + source: "native", + }), + lifecycle, + branch: "feat/todos", + issueId: null, + pr: { + number: 7, + url: "https://github.com/acme/app/pull/7", + title: "feat: todos", + owner: "acme", + repo: "app", + branch: "feat/todos", + baseBranch: "main", + isDraft: false, + }, + workspacePath: "/tmp/app-worktree", + runtimeHandle: { id: "tmux-app-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date("2026-05-10T09:00:00.000Z"), + lastActivityAt: new Date("2026-05-10T10:00:00.000Z"), + metadata: {}, + ...overrides, + }; +} + +function makeSessionManager( + session: Session | null, + overrides: Partial = {}, +): SessionManager { + const manager: SessionManager = { + get: async (sessionId: string) => (session?.id === sessionId ? session : null), + list: async () => (session ? [session] : []), + spawn: async () => { + throw new Error("not implemented"); + }, + spawnOrchestrator: async () => { + throw new Error("not implemented"); + }, + ensureOrchestrator: async () => { + throw new Error("not implemented"); + }, + relaunchOrchestrator: async () => { + throw new Error("not implemented"); + }, + restore: async () => { + throw new Error("not implemented"); + }, + kill: async () => ({ cleaned: false, alreadyTerminated: false }), + cleanup: async () => ({ killed: [], skipped: [], errors: [] }), + send: async () => {}, + claimPR: async () => { + throw new Error("not implemented"); + }, + }; + + return Object.assign(manager, overrides); +} + +beforeEach(() => { + storeDir = join(tmpdir(), `ao-test-code-review-manager-${randomUUID()}`); + mkdirSync(storeDir, { recursive: true }); + store = createCodeReviewStore("app", { storeDir }); +}); + +afterEach(() => { + rmSync(storeDir, { recursive: true, force: true }); +}); + +describe("triggerCodeReviewForSession", () => { + it("creates a queued review run linked to the worker session", async () => { + const session = makeSession(); + const run = await triggerCodeReviewForSession( + { + config, + sessionManager: makeSessionManager(session), + storeFactory: () => store, + resolveTargetSha: async () => "abc123", + now: new Date("2026-05-10T11:00:00.000Z"), + }, + { sessionId: "app-1", requestedBy: "cli" }, + ); + + expect(run).toMatchObject({ + projectId: "app", + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + targetSha: "abc123", + prNumber: 7, + prUrl: "https://github.com/acme/app/pull/7", + summary: "Review requested from CLI for app-1.", + findingCount: 0, + }); + expect(store.listRuns()).toHaveLength(1); + }); + + it("allocates reviewer ids from all prior project review runs", async () => { + store.createRun({ linkedSessionId: "app-1", reviewerSessionId: "app-rev-1" }); + store.createRun({ linkedSessionId: "app-2", reviewerSessionId: "app-rev-7" }); + + const run = await triggerCodeReviewForSession( + { + config, + sessionManager: makeSessionManager(makeSession({ id: "app-3" })), + storeFactory: () => store, + resolveTargetSha: async () => undefined, + }, + { sessionId: "app-3", requestedBy: "web" }, + ); + + expect(run.reviewerSessionId).toBe("app-rev-8"); + }); + + it("allocates unique reviewer ids for concurrent review requests", async () => { + let resolveGate: (() => void) | undefined; + const gate = new Promise((resolve) => { + resolveGate = resolve; + }); + let shaLookups = 0; + const options = { + config, + sessionManager: makeSessionManager(makeSession()), + storeFactory: () => store, + resolveTargetSha: async () => { + shaLookups++; + if (shaLookups === 2) { + resolveGate?.(); + } + await gate; + return "abc123"; + }, + }; + + const [first, second] = await Promise.all([ + triggerCodeReviewForSession(options, { sessionId: "app-1", requestedBy: "web" }), + triggerCodeReviewForSession(options, { sessionId: "app-1", requestedBy: "web" }), + ]); + + expect(new Set([first.reviewerSessionId, second.reviewerSessionId]).size).toBe(2); + expect( + store + .listRuns() + .map((run) => run.reviewerSessionId) + .sort(), + ).toEqual(["app-rev-1", "app-rev-2"]); + }); + + it("marks previous review runs for older worker SHAs as outdated", async () => { + const oldTriage = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + targetSha: "old-sha", + }); + const oldClean = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-2", + status: "clean", + targetSha: "old-sha", + }); + const failed = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-3", + status: "failed", + targetSha: "old-sha", + }); + const otherWorker = store.createRun({ + linkedSessionId: "app-2", + reviewerSessionId: "app-rev-4", + status: "needs_triage", + targetSha: "old-sha", + }); + + const run = await triggerCodeReviewForSession( + { + config, + sessionManager: makeSessionManager(makeSession()), + storeFactory: () => store, + resolveTargetSha: async () => "new-sha", + now: new Date("2026-05-10T12:00:00.000Z"), + }, + { sessionId: "app-1", requestedBy: "web" }, + ); + + expect(run.reviewerSessionId).toBe("app-rev-5"); + expect(store.getRun(oldTriage.id)?.status).toBe("outdated"); + expect(store.getRun(oldClean.id)?.status).toBe("outdated"); + expect(store.getRun(failed.id)?.status).toBe("failed"); + expect(store.getRun(otherWorker.id)?.status).toBe("needs_triage"); + }); + + it("marks stale review runs outdated when the worker HEAD changes", async () => { + const oldWaiting = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "waiting_update", + targetSha: "old-sha", + }); + const currentQueued = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-2", + status: "queued", + targetSha: "new-sha", + }); + const failed = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-3", + status: "failed", + targetSha: "old-sha", + }); + + const updatedCount = await markOutdatedCodeReviewRunsForSession({ + store, + session: makeSession(), + resolveTargetSha: async () => "new-sha", + now: new Date("2026-05-10T12:00:00.000Z"), + }); + + expect(updatedCount).toBe(1); + expect(store.getRun(oldWaiting.id)?.status).toBe("outdated"); + expect(store.getRun(currentQueued.id)?.status).toBe("queued"); + expect(store.getRun(failed.id)?.status).toBe("failed"); + }); + + it("rejects missing and orchestrator sessions", async () => { + await expect( + triggerCodeReviewForSession( + { config, sessionManager: makeSessionManager(null), storeFactory: () => store }, + { sessionId: "app-404" }, + ), + ).rejects.toBeInstanceOf(SessionNotFoundError); + + await expect( + triggerCodeReviewForSession( + { + config, + sessionManager: makeSessionManager( + makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), + ), + storeFactory: () => store, + }, + { sessionId: "app-orchestrator" }, + ), + ).rejects.toThrow(/Cannot request code review for orchestrator session/); + }); +}); + +describe("executeCodeReviewRun", () => { + it("runs a reviewer in an isolated workspace and persists findings", async () => { + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + targetSha: "abc123", + }); + + const summary = await executeCodeReviewRun( + { + config, + sessionManager: makeSessionManager(makeSession()), + storeFactory: () => store, + prepareWorkspace: async ({ run }) => `/tmp/reviews/${run.reviewerSessionId}`, + runReviewer: async ({ workspacePath }) => ({ + summary: `Reviewed ${workspacePath}`, + findings: [ + { + severity: "error", + title: "Broken save path", + body: "The save handler drops failed writes.", + filePath: "src/save.ts", + startLine: 12, + confidence: 0.9, + }, + ], + }), + }, + { projectId: "app", runId: run.id }, + ); + + expect(summary).toMatchObject({ + status: "needs_triage", + reviewerWorkspacePath: "/tmp/reviews/app-rev-1", + findingCount: 1, + openFindingCount: 1, + summary: "Reviewed /tmp/reviews/app-rev-1", + }); + expect(store.listFindings({ runId: run.id })[0]).toMatchObject({ + severity: "error", + title: "Broken save path", + filePath: "src/save.ts", + startLine: 12, + }); + }); + + it("falls back to the project default branch when the session PR base branch is empty", async () => { + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + targetSha: "abc123", + }); + let observedBaseRef: string | undefined; + const session = makeSession({ + pr: { + ...makeSession().pr!, + baseBranch: "", + }, + }); + + const summary = await executeCodeReviewRun( + { + config, + sessionManager: makeSessionManager(session), + storeFactory: () => store, + prepareWorkspace: async () => "/tmp/reviews/app-rev-1", + runReviewer: async ({ baseRef }) => { + observedBaseRef = baseRef; + return { findings: [] }; + }, + }, + { projectId: "app", runId: run.id }, + ); + + expect(observedBaseRef).toBe("main"); + expect(summary.status).toBe("clean"); + }); + + it("allows only one concurrent execution to claim the same queued review run", async () => { + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + targetSha: "abc123", + }); + let resolveSessionLookup: (() => void) | undefined; + const sessionLookupGate = new Promise((resolve) => { + resolveSessionLookup = resolve; + }); + let sessionLookups = 0; + let prepareCalls = 0; + const sessionManager = makeSessionManager(makeSession(), { + get: async () => { + sessionLookups++; + if (sessionLookups === 2) { + resolveSessionLookup?.(); + } + await sessionLookupGate; + return makeSession(); + }, + }); + + const first = executeCodeReviewRun( + { + config, + sessionManager, + storeFactory: () => store, + prepareWorkspace: async () => { + prepareCalls++; + return "/tmp/reviews/app-rev-1"; + }, + runReviewer: async () => ({ findings: [] }), + }, + { projectId: "app", runId: run.id }, + ); + while (sessionLookups === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const second = executeCodeReviewRun( + { + config, + sessionManager, + storeFactory: () => store, + prepareWorkspace: async () => { + prepareCalls++; + return "/tmp/reviews/app-rev-1"; + }, + runReviewer: async () => ({ findings: [] }), + }, + { projectId: "app", runId: run.id }, + ); + await Promise.resolve(); + resolveSessionLookup?.(); + + const results = await Promise.allSettled([first, second]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(prepareCalls).toBe(1); + expect(store.getRun(run.id)?.status).toBe("clean"); + }); + + it("marks clean reviews clean and records failed reviewer executions", async () => { + const cleanRun = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + }); + + const cleanSummary = await executeCodeReviewRun( + { + config, + sessionManager: makeSessionManager(makeSession()), + storeFactory: () => store, + prepareWorkspace: async () => "/tmp/reviews/app-rev-1", + runReviewer: async () => ({ rawOutput: '{"findings":[]}' }), + }, + { projectId: "app", runId: cleanRun.id }, + ); + expect(cleanSummary.status).toBe("clean"); + expect(cleanSummary.findingCount).toBe(0); + + const failedRun = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-2", + status: "queued", + }); + const failedSummary = await executeCodeReviewRun( + { + config, + sessionManager: makeSessionManager(makeSession()), + storeFactory: () => store, + prepareWorkspace: async () => "/tmp/reviews/app-rev-2", + runReviewer: async () => { + throw new Error("review command crashed"); + }, + }, + { projectId: "app", runId: failedRun.id }, + ); + expect(failedSummary.status).toBe("failed"); + expect(failedSummary.terminationReason).toBe("review command crashed"); + }); +}); + +describe("sendCodeReviewFindingsToAgent", () => { + it("sends open review findings to the linked coding worker and marks them sent", async () => { + const session = makeSession(); + const sentMessages: Array<{ sessionId: string; message: string }> = []; + const run = store.createRun({ + linkedSessionId: session.id, + reviewerSessionId: "app-rev-1", + status: "needs_triage", + targetSha: "abc123", + prNumber: 7, + prUrl: "https://github.com/acme/app/pull/7", + }); + const first = store.createFinding({ + runId: run.id, + linkedSessionId: session.id, + severity: "error", + title: "Broken save path", + body: "The save handler drops failed writes.", + filePath: "src/save.ts", + startLine: 12, + confidence: 0.9, + }); + const second = store.createFinding({ + runId: run.id, + linkedSessionId: session.id, + severity: "warning", + title: "Missing retry", + body: "The request fails permanently on transient network errors.", + filePath: "src/api.ts", + startLine: 4, + endLine: 8, + }); + const dismissed = store.createFinding({ + runId: run.id, + linkedSessionId: session.id, + severity: "info", + title: "Dismissed nit", + body: "This should not be sent.", + status: "dismissed", + }); + + const result = await sendCodeReviewFindingsToAgent( + { + config, + sessionManager: makeSessionManager(session, { + send: async (sessionId, message) => { + sentMessages.push({ sessionId, message }); + }, + }), + storeFactory: () => store, + now: () => new Date("2026-05-10T12:00:00.000Z"), + }, + { projectId: "app", runId: run.id }, + ); + + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0]?.sessionId).toBe("app-1"); + expect(sentMessages[0]?.message).toContain("AO reviewer app-rev-1 found 2 open issues"); + expect(sentMessages[0]?.message).toContain("Review run:"); + expect(sentMessages[0]?.message).toContain("[error] Broken save path"); + expect(sentMessages[0]?.message).toContain("Location: src/save.ts:12"); + expect(sentMessages[0]?.message).toContain("[warning] Missing retry"); + expect(sentMessages[0]?.message).toContain("Location: src/api.ts:4-8"); + expect(sentMessages[0]?.message).not.toContain("Dismissed nit"); + expect(result).toMatchObject({ + sentFindingCount: 2, + run: { + status: "waiting_update", + openFindingCount: 0, + sentFindingCount: 2, + dismissedFindingCount: 1, + }, + }); + expect(store.getFinding(first.id)).toMatchObject({ + status: "sent_to_agent", + sentToAgentAt: "2026-05-10T12:00:00.000Z", + }); + expect(store.getFinding(second.id)?.status).toBe("sent_to_agent"); + expect(store.getFinding(dismissed.id)?.status).toBe("dismissed"); + }); + + it("does not send or mutate when there are no open findings", async () => { + const session = makeSession(); + const run = store.createRun({ + linkedSessionId: session.id, + reviewerSessionId: "app-rev-1", + status: "clean", + }); + store.createFinding({ + runId: run.id, + linkedSessionId: session.id, + severity: "warning", + title: "Already sent", + body: "Do not resend this.", + status: "sent_to_agent", + }); + const sentMessages: string[] = []; + + await expect( + sendCodeReviewFindingsToAgent( + { + config, + sessionManager: makeSessionManager(session, { + send: async (_sessionId, message) => { + sentMessages.push(message); + }, + }), + storeFactory: () => store, + }, + { projectId: "app", runId: run.id }, + ), + ).rejects.toBeInstanceOf(CodeReviewNoOpenFindingsError); + + expect(sentMessages).toEqual([]); + expect(store.getRun(run.id)?.status).toBe("clean"); + }); +}); + +describe("runCodexCodeReview", () => { + it("uses generic codex exec instead of the review subcommand base/prompt combination", () => { + const args = buildCodexCodeReviewArgs("/tmp/review-output.json", "Return JSON only."); + + expect(args).toEqual([ + "exec", + "--sandbox", + "read-only", + "--output-last-message", + "/tmp/review-output.json", + "Return JSON only.", + ]); + expect(args).not.toContain("review"); + expect(args).not.toContain("--base"); + }); +}); + +describe("prepareGitReviewerWorkspace", () => { + it("prunes stale git worktree metadata when the reviewer workspace directory is gone", async () => { + const tmpHome = join(tmpdir(), `ao-test-review-worktree-${randomUUID()}`); + const originalHome = process.env["HOME"]; + process.env["HOME"] = tmpHome; + + try { + const repoPath = join(tmpHome, "repo"); + mkdirSync(repoPath, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoPath }); + writeFileSync(join(repoPath, "README.md"), "# App\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoPath }); + execFileSync("git", ["commit", "-m", "initial"], { + cwd: repoPath, + env: { + ...process.env, + GIT_AUTHOR_NAME: "AO Test", + GIT_AUTHOR_EMAIL: "ao@example.com", + GIT_COMMITTER_NAME: "AO Test", + GIT_COMMITTER_EMAIL: "ao@example.com", + }, + }); + + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-stale", + status: "queued", + }); + const project = { ...config.projects.app!, path: repoPath }; + const workspaceRoot = join( + tmpHome, + ".agent-orchestrator", + "projects", + "app", + "code-reviews", + "workspaces", + ); + const workspacePath = join(workspaceRoot, run.reviewerSessionId); + mkdirSync(workspaceRoot, { recursive: true }); + execFileSync("git", ["worktree", "add", "--detach", workspacePath, "HEAD"], { + cwd: repoPath, + }); + rmSync(workspacePath, { recursive: true, force: true }); + + const preparedPath = await prepareGitReviewerWorkspace({ + projectId: "app", + project, + session: makeSession({ workspacePath: repoPath }), + run, + }); + + expect(preparedPath).toBe(workspacePath); + expect(existsSync(workspacePath)).toBe(true); + } finally { + if (originalHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = originalHome; + } + rmSync(tmpHome, { recursive: true, force: true }); + } + }); +}); + +describe("parseReviewerOutput", () => { + it("parses JSON findings and falls back to a single reviewer-output finding", () => { + expect( + parseReviewerOutput( + JSON.stringify({ + findings: [{ severity: "warning", title: "Risk", body: "A concrete issue." }], + }), + ), + ).toMatchObject([{ severity: "warning", title: "Risk", body: "A concrete issue." }]); + expect(parseReviewerOutput("No findings.")).toEqual([]); + expect(parseReviewerOutput("Unexpected reviewer text")).toMatchObject([ + { severity: "warning", title: "Reviewer output", body: "Unexpected reviewer text" }, + ]); + }); + + it("does not drop structured findings whose text mentions no findings", () => { + expect( + parseReviewerOutput( + JSON.stringify({ + findings: [ + { + severity: "warning", + title: "No findings banner is stale", + body: "The UI still says no findings even when the reviewer found one.", + filePath: "src/review.ts", + startLine: 42, + }, + ], + }), + ), + ).toMatchObject([ + { + severity: "warning", + title: "No findings banner is stale", + body: "The UI still says no findings even when the reviewer found one.", + filePath: "src/review.ts", + startLine: 42, + }, + ]); + }); +}); diff --git a/packages/core/src/__tests__/code-review-store.test.ts b/packages/core/src/__tests__/code-review-store.test.ts new file mode 100644 index 000000000..2f7e7c422 --- /dev/null +++ b/packages/core/src/__tests__/code-review-store.test.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createCodeReviewStore, type CodeReviewStore } from "../code-review-store.js"; + +let storeDir: string; +let store: CodeReviewStore; + +beforeEach(() => { + storeDir = join(tmpdir(), `ao-test-code-review-store-${randomUUID()}`); + mkdirSync(storeDir, { recursive: true }); + store = createCodeReviewStore("app", { storeDir }); +}); + +afterEach(() => { + rmSync(storeDir, { recursive: true, force: true }); +}); + +describe("CodeReviewStore", () => { + it("creates runs and lists summaries with finding counts", () => { + const run = store.createRun( + { + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + targetSha: "abc123", + prNumber: 42, + }, + new Date("2026-05-10T10:00:00.000Z"), + ); + + store.createFinding( + { + runId: run.id, + linkedSessionId: "app-1", + severity: "error", + title: "Missing guard", + body: "The handler reads a nullable value without checking it.", + filePath: "src/auth.ts", + startLine: 12, + fingerprint: "auth-guard", + }, + new Date("2026-05-10T10:01:00.000Z"), + ); + const dismissed = store.createFinding( + { + runId: run.id, + linkedSessionId: "app-1", + severity: "info", + title: "Naming nit", + body: "Consider a clearer name.", + }, + new Date("2026-05-10T10:02:00.000Z"), + ); + store.updateFinding(dismissed.id, { status: "dismissed", dismissedBy: "operator" }); + + const summaries = store.listRunSummaries({ linkedSessionId: "app-1" }); + expect(summaries).toHaveLength(1); + expect(summaries[0]).toMatchObject({ + id: run.id, + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + findingCount: 2, + openFindingCount: 1, + dismissedFindingCount: 1, + }); + }); + + it("filters runs and findings independently", () => { + const first = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "clean", + }); + const second = store.createRun({ + linkedSessionId: "app-2", + reviewerSessionId: "app-rev-2", + status: "failed", + }); + store.createFinding({ + runId: second.id, + linkedSessionId: "app-2", + severity: "warning", + title: "Race condition", + body: "This update can be lost.", + }); + + expect(store.listRuns({ status: "clean" }).map((run) => run.id)).toEqual([first.id]); + expect(store.listRuns({ linkedSessionId: "app-2" }).map((run) => run.id)).toEqual([second.id]); + expect(store.listFindings({ linkedSessionId: "app-1" })).toEqual([]); + expect(store.listFindings({ linkedSessionId: "app-2" })).toHaveLength(1); + }); + + it("rejects path traversal ids", () => { + expect(() => store.getRun("../bad")).toThrow(/Unsafe review run id/); + expect(() => store.getFinding("../bad")).toThrow(/Unsafe review finding id/); + }); +}); diff --git a/packages/core/src/__tests__/global-config.test.ts b/packages/core/src/__tests__/global-config.test.ts index 25f4a755a..2e7c51628 100644 --- a/packages/core/src/__tests__/global-config.test.ts +++ b/packages/core/src/__tests__/global-config.test.ts @@ -351,6 +351,57 @@ describe("global-config storage identity", () => { }); }); + it("preserves wrapped config defaults when repairing local behavior", () => { + const repoPath = createRepo("wrapped-local-defaults", "https://github.com/OpenAI/demo.git"); + const projectId = registerProjectInGlobalConfig( + "wrapped-local-defaults", + "Wrapped Local Defaults", + repoPath, + { defaultBranch: "main" }, + configPath, + ); + writeFileSync( + join(repoPath, "agent-orchestrator.yaml"), + [ + "defaults:", + " agent: codex", + " runtime: tmux", + " workspace: worktree", + " orchestrator:", + " agent: codex", + " worker:", + " agent: opencode", + "projects:", + " wrapped-local-defaults:", + ` path: ${repoPath}`, + " name: Wrapped Local Defaults", + "", + ].join("\n"), + ); + + expect(resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath)).toMatchObject({ + resolveError: expect.stringContaining("wrapped projects: format"), + }); + + repairWrappedLocalProjectConfig(projectId, repoPath); + + const repaired = parseYaml(readFileSync(join(repoPath, "agent-orchestrator.yaml"), "utf-8")); + expect(repaired).toEqual({ + agent: "codex", + runtime: "tmux", + workspace: "worktree", + orchestrator: { agent: "codex" }, + worker: { agent: "opencode" }, + }); + expect(resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath)).toMatchObject({ + agent: "codex", + runtime: "tmux", + workspace: "worktree", + orchestrator: { agent: "codex" }, + worker: { agent: "opencode" }, + }); + }); + it("repairs wrapped local .yml configs without creating a .yaml sibling", () => { const repoPath = createRepo("wrapped-local-yml", "https://github.com/OpenAI/demo.git"); const configPathYml = join(repoPath, "agent-orchestrator.yml"); diff --git a/packages/core/src/__tests__/migration-storage-v2.test.ts b/packages/core/src/__tests__/migration-storage-v2.test.ts index 3d7cc4b09..915431ce9 100644 --- a/packages/core/src/__tests__/migration-storage-v2.test.ts +++ b/packages/core/src/__tests__/migration-storage-v2.test.ts @@ -12,6 +12,8 @@ import { } from "../migration/storage-v2.js"; import { readMetadata } from "../metadata.js"; +vi.setConfig({ testTimeout: 20_000 }); + function createTempDir(): string { const dir = join( tmpdir(), diff --git a/packages/core/src/__tests__/plugin-integration.test.ts b/packages/core/src/__tests__/plugin-integration.test.ts index b8e736c9c..04e38c852 100644 --- a/packages/core/src/__tests__/plugin-integration.test.ts +++ b/packages/core/src/__tests__/plugin-integration.test.ts @@ -25,7 +25,7 @@ vi.mock("node:child_process", () => { const execFile = Object.assign(vi.fn(), { [Symbol.for("nodejs.util.promisify.custom")]: ghMock, }); - return { execFile }; + return { execFile, exec: vi.fn() }; }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/code-review-manager.ts b/packages/core/src/code-review-manager.ts new file mode 100644 index 000000000..756cc3d1c --- /dev/null +++ b/packages/core/src/code-review-manager.ts @@ -0,0 +1,1050 @@ +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { + type CodeReviewFinding, + type CodeReviewSeverity, + createCodeReviewStore, + type CodeReviewRun, + type CodeReviewRunStatus, + type CodeReviewRunSummary, + type CodeReviewStore, +} from "./code-review-store.js"; +import { getProjectCodeReviewsDir } from "./paths.js"; +import { + isOrchestratorSession, + SessionNotFoundError, + type OrchestratorConfig, + type ProjectConfig, + type Session, + type SessionManager, +} from "./types.js"; +import { getShell, isWindows, killProcessTree } from "./platform.js"; + +const REVIEW_COMMAND_TIMEOUT_MS = 10 * 60_000; +const REVIEW_COMMAND_MAX_BUFFER = 8 * 1024 * 1024; +const REVIEW_RUN_CREATION_LOCK_FILE = ".create-run.lock"; +const REVIEW_RUN_EXECUTION_LOCK_PREFIX = ".execute-run-"; +const REVIEW_RUN_CREATION_LOCK_WAIT_MS = 5_000; +const REVIEW_RUN_CREATION_LOCK_STALE_MS = 30_000; +const REVIEW_LOCK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +async function execFileAsync( + file: string, + args: string[], + options: { + cwd?: string; + timeout?: number; + maxBuffer?: number; + env?: NodeJS.ProcessEnv; + shell?: boolean | string; + windowsHide?: boolean; + } = {}, +): Promise<{ stdout: string; stderr: string }> { + const { execFile } = await import("node:child_process"); + return promisify(execFile)(file, args, { windowsHide: true, ...options }); +} + +async function execFileWithClosedStdin( + file: string, + args: string[], + options: { + cwd?: string; + timeout?: number; + maxBuffer?: number; + env?: NodeJS.ProcessEnv; + shell?: boolean | string; + windowsHide?: boolean; + } = {}, +): Promise<{ stdout: string; stderr: string }> { + const { spawn } = await import("node:child_process"); + + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options.cwd, + env: options.env, + shell: options.shell, + windowsHide: options.windowsHide ?? true, + detached: !isWindows(), + stdio: ["ignore", "pipe", "pipe"], + }); + const maxBuffer = options.maxBuffer ?? REVIEW_COMMAND_MAX_BUFFER; + let stdout = ""; + let stderr = ""; + let settled = false; + + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + callback(); + }; + + const terminateChild = () => { + if (child.pid !== undefined) { + return killProcessTree(child.pid).catch(() => undefined); + } + child.kill("SIGTERM"); + return Promise.resolve(); + }; + + const fail = (message: string, code?: number | null, signal?: NodeJS.Signals | null) => { + const error = new Error(message) as Error & { + code?: number | null; + signal?: NodeJS.Signals | null; + stdout?: string; + stderr?: string; + }; + error.code = code; + error.signal = signal; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }; + + const timer = + options.timeout && options.timeout > 0 + ? setTimeout(() => { + void terminateChild().finally(() => { + finish(() => fail(`Command timed out after ${options.timeout}ms`, null, "SIGTERM")); + }); + }, options.timeout) + : null; + + const append = (kind: "stdout" | "stderr", chunk: Buffer) => { + const next = chunk.toString(); + if (kind === "stdout") stdout += next; + else stderr += next; + + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) <= maxBuffer) return; + void terminateChild(); + finish(() => fail(`Command output exceeded maxBuffer ${maxBuffer}`)); + }; + + child.stdout?.on("data", (chunk: Buffer) => append("stdout", chunk)); + child.stderr?.on("data", (chunk: Buffer) => append("stderr", chunk)); + child.once("error", (error) => finish(() => reject(error))); + child.once("close", (code, signal) => { + finish(() => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + fail(`Command failed with code ${code ?? signal ?? "unknown"}`, code, signal); + }); + }); + }); +} + +export type CodeReviewRequestSource = "cli" | "web" | "system"; + +export interface TriggerCodeReviewInput { + sessionId: string; + requestedBy?: CodeReviewRequestSource; + status?: CodeReviewRunStatus; + summary?: string; +} + +export interface TriggerCodeReviewOptions { + config: OrchestratorConfig; + sessionManager: SessionManager; + storeFactory?: (projectId: string) => CodeReviewStore; + resolveTargetSha?: (session: Session) => Promise; + now?: Date; +} + +export interface CodeReviewRunnerFinding { + severity?: CodeReviewSeverity; + title?: string; + body?: string; + filePath?: string; + startLine?: number; + endLine?: number; + category?: string; + confidence?: number; + fingerprint?: string; +} + +export interface CodeReviewRunnerResult { + findings?: CodeReviewRunnerFinding[]; + summary?: string; + rawOutput?: string; +} + +export interface CodeReviewRunnerContext { + config: OrchestratorConfig; + project: ProjectConfig; + session: Session; + run: CodeReviewRun; + workspacePath: string; + baseRef: string; +} + +export type CodeReviewRunner = ( + context: CodeReviewRunnerContext, +) => Promise; + +export type PrepareCodeReviewWorkspace = (context: { + projectId: string; + project: ProjectConfig; + session: Session; + run: CodeReviewRun; +}) => Promise; + +export interface ExecuteCodeReviewRunOptions { + config: OrchestratorConfig; + sessionManager: SessionManager; + storeFactory?: (projectId: string) => CodeReviewStore; + prepareWorkspace?: PrepareCodeReviewWorkspace; + runReviewer?: CodeReviewRunner; + now?: () => Date; + force?: boolean; +} + +export interface ExecuteCodeReviewRunInput { + projectId: string; + runId: string; +} + +export interface SendCodeReviewFindingsOptions { + config: OrchestratorConfig; + sessionManager: SessionManager; + storeFactory?: (projectId: string) => CodeReviewStore; + now?: () => Date; +} + +export interface SendCodeReviewFindingsInput { + projectId: string; + runId: string; +} + +export interface SendCodeReviewFindingsResult { + run: CodeReviewRunSummary; + sentFindingCount: number; + message: string; +} + +export interface MarkOutdatedCodeReviewRunsInput { + store: CodeReviewStore; + session: Session; + resolveTargetSha?: (session: Session) => Promise; + now?: Date; +} + +export class CodeReviewRunNotFoundError extends Error { + constructor(runId: string) { + super(`Code review run not found: ${runId}`); + this.name = "CodeReviewRunNotFoundError"; + } +} + +export class CodeReviewRunNotExecutableError extends Error { + readonly runId: string; + readonly reviewerSessionId: string; + readonly status: CodeReviewRunStatus; + + constructor(run: CodeReviewRun) { + super(`Code review run ${run.reviewerSessionId} is ${run.status}, not queued`); + this.name = "CodeReviewRunNotExecutableError"; + this.runId = run.id; + this.reviewerSessionId = run.reviewerSessionId; + this.status = run.status; + } +} + +export class CodeReviewInvalidSessionError extends Error { + constructor(message: string) { + super(message); + this.name = "CodeReviewInvalidSessionError"; + } +} + +export class CodeReviewNoOpenFindingsError extends Error { + readonly runId: string; + readonly reviewerSessionId: string; + + constructor(run: CodeReviewRun) { + super(`No open review findings to send for ${run.reviewerSessionId}.`); + this.name = "CodeReviewNoOpenFindingsError"; + this.runId = run.id; + this.reviewerSessionId = run.reviewerSessionId; + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function parsePrNumber(url: string | undefined): number | undefined { + if (!url) return undefined; + const match = url.match(/\/pull\/(\d+)(?:\D*$|$)/); + if (!match) return undefined; + const parsed = Number.parseInt(match[1], 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; +} + +function formatFindingLocation(finding: CodeReviewFinding): string | null { + if (!finding.filePath) return null; + if (finding.startLine === undefined) return finding.filePath; + if (finding.endLine !== undefined && finding.endLine !== finding.startLine) { + return `${finding.filePath}:${finding.startLine}-${finding.endLine}`; + } + return `${finding.filePath}:${finding.startLine}`; +} + +function formatFindingForAgent(finding: CodeReviewFinding, index: number): string { + const lines = [`${index}. [${finding.severity}] ${finding.title}`]; + const location = formatFindingLocation(finding); + if (location) lines.push(` Location: ${location}`); + if (finding.confidence !== undefined) lines.push(` Confidence: ${finding.confidence}`); + lines.push(" Details:"); + lines.push( + ...finding.body + .split(/\r?\n/) + .map((line) => ` ${line}`) + .filter((line, lineIndex, allLines) => line.trim() || lineIndex < allLines.length - 1), + ); + return lines.join("\n"); +} + +function parseFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function parseSeverity(value: unknown): CodeReviewSeverity { + switch (value) { + case "error": + case "warning": + case "info": + return value; + default: + return "warning"; + } +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function stripMarkdownJsonFence(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + return match?.[1]?.trim() ?? trimmed; +} + +function tryParseJsonCandidate(value: string): unknown | null { + const candidates = [stripMarkdownJsonFence(value)]; + const fenced = value.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fenced?.[1]) candidates.push(fenced[1].trim()); + + for (const line of value + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean) + .reverse()) { + if (line.startsWith("{") || line.startsWith("[")) { + candidates.push(line); + } + } + + for (const candidate of candidates) { + try { + return JSON.parse(candidate) as unknown; + } catch { + // Keep trying looser candidates. + } + } + + return null; +} + +function normalizeFinding(value: unknown, fallbackIndex: number): CodeReviewRunnerFinding | null { + const record = asRecord(value); + if (!record) return null; + + const body = + typeof record["body"] === "string" + ? record["body"].trim() + : typeof record["message"] === "string" + ? record["message"].trim() + : ""; + if (!body) return null; + + const title = + typeof record["title"] === "string" && record["title"].trim() + ? record["title"].trim() + : `Review finding ${fallbackIndex}`; + + const filePath = + typeof record["filePath"] === "string" + ? record["filePath"] + : typeof record["path"] === "string" + ? record["path"] + : undefined; + + return { + severity: parseSeverity(record["severity"]), + title: truncate(title, 160), + body: truncate(body, 12_000), + filePath, + startLine: parseFiniteNumber(record["startLine"] ?? record["line"]), + endLine: parseFiniteNumber(record["endLine"]), + category: typeof record["category"] === "string" ? record["category"] : undefined, + confidence: parseFiniteNumber(record["confidence"]), + fingerprint: typeof record["fingerprint"] === "string" ? record["fingerprint"] : undefined, + }; +} + +export function parseReviewerOutput(output: string): CodeReviewRunnerFinding[] { + const trimmed = output.trim(); + if (!trimmed) return []; + + const parsed = tryParseJsonCandidate(trimmed); + const parsedRecord = asRecord(parsed); + const rawFindings = Array.isArray(parsed) + ? parsed + : Array.isArray(parsedRecord?.["findings"]) + ? parsedRecord["findings"] + : null; + + if (rawFindings) { + return rawFindings + .map((finding, index) => normalizeFinding(finding, index + 1)) + .filter((finding): finding is CodeReviewRunnerFinding => finding !== null); + } + + if (/^no findings?\.?$/i.test(trimmed)) return []; + + return [ + { + severity: "warning", + title: "Reviewer output", + body: truncate(trimmed, 12_000), + }, + ]; +} + +function allocateReviewerSessionId(existingRuns: CodeReviewRun[], sessionPrefix: string): string { + let max = 0; + const pattern = new RegExp(`^${escapeRegex(sessionPrefix)}-rev-(\\d+)$`); + + for (const run of existingRuns) { + const match = run.reviewerSessionId.match(pattern); + if (!match) continue; + const parsed = Number.parseInt(match[1], 10); + if (!Number.isNaN(parsed) && parsed > max) { + max = parsed; + } + } + + return `${sessionPrefix}-rev-${max + 1}`; +} + +function isFsErrorWithCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function assertSafeReviewLockId(id: string, label: string): void { + if (!id || id === "." || id === ".." || !REVIEW_LOCK_ID_PATTERN.test(id)) { + throw new Error(`Unsafe ${label}: "${id}"`); + } +} + +async function acquireCodeReviewStoreLock({ + store, + lockFileName, + label, +}: { + store: CodeReviewStore; + lockFileName: string; + label: string; +}): Promise<() => void> { + mkdirSync(store.storeDir, { recursive: true }); + const lockPath = join(store.storeDir, lockFileName); + const startedAt = Date.now(); + + while (true) { + try { + const fd = openSync(lockPath, "wx"); + writeFileSync(fd, `${process.pid}\n${new Date().toISOString()}\n`); + let released = false; + return () => { + if (released) return; + released = true; + try { + closeSync(fd); + } catch { + // The descriptor may already be closed if process cleanup raced with release. + } + try { + unlinkSync(lockPath); + } catch { + // Another process may already have cleaned up a stale lock. + } + }; + } catch (error) { + if (!isFsErrorWithCode(error, "EEXIST")) { + throw error; + } + + try { + const lockAgeMs = Date.now() - statSync(lockPath).mtimeMs; + if (lockAgeMs > REVIEW_RUN_CREATION_LOCK_STALE_MS) { + unlinkSync(lockPath); + continue; + } + } catch (staleError) { + if (isFsErrorWithCode(staleError, "ENOENT")) { + continue; + } + } + + if (Date.now() - startedAt > REVIEW_RUN_CREATION_LOCK_WAIT_MS) { + throw new Error(`Timed out waiting for ${label} lock`, { cause: error }); + } + await delay(25); + } + } +} + +async function withReviewRunCreationLock( + store: CodeReviewStore, + callback: () => T | Promise, +): Promise { + const release = await acquireCodeReviewStoreLock({ + store, + lockFileName: REVIEW_RUN_CREATION_LOCK_FILE, + label: "code review run creation", + }); + try { + return await callback(); + } finally { + release(); + } +} + +async function withReviewRunExecutionLock( + store: CodeReviewStore, + runId: string, + callback: () => T | Promise, +): Promise { + assertSafeReviewLockId(runId, "review run id"); + const release = await acquireCodeReviewStoreLock({ + store, + lockFileName: `${REVIEW_RUN_EXECUTION_LOCK_PREFIX}${runId}.lock`, + label: `code review run ${runId} execution`, + }); + try { + return await callback(); + } finally { + release(); + } +} + +const SUPERSEDABLE_RUN_STATUSES: ReadonlySet = new Set([ + "queued", + "needs_triage", + "sent_to_agent", + "waiting_update", + "clean", +]); + +function markSupersededReviewRuns({ + store, + existingRuns, + linkedSessionId, + targetSha, + now, +}: { + store: CodeReviewStore; + existingRuns: CodeReviewRun[]; + linkedSessionId: string; + targetSha: string | undefined; + now: Date; +}): number { + if (!targetSha) return 0; + + let updatedCount = 0; + + for (const run of existingRuns) { + if (run.linkedSessionId !== linkedSessionId) continue; + if (!run.targetSha || run.targetSha === targetSha) continue; + if (!SUPERSEDABLE_RUN_STATUSES.has(run.status)) continue; + store.updateRun(run.id, { status: "outdated" }, now); + updatedCount++; + } + + return updatedCount; +} + +async function resolveGitHeadSha(session: Session): Promise { + const cwd = session.workspacePath; + if (!cwd) return undefined; + + try { + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd, + timeout: 5_000, + }); + const sha = stdout.trim(); + return sha.length > 0 ? sha : undefined; + } catch { + return undefined; + } +} + +async function git(cwd: string, args: string[], timeout = 30_000): Promise { + const { stdout } = await execFileAsync("git", args, { cwd, timeout }); + return stdout.trim(); +} + +async function resolveWorkspaceHead( + workspacePath: string | null | undefined, +): Promise { + if (!workspacePath) return undefined; + try { + return await git(workspacePath, ["rev-parse", "HEAD"], 5_000); + } catch { + return undefined; + } +} + +async function removeReviewerWorktree(repoPath: string, workspacePath: string): Promise { + if (!existsSync(workspacePath)) { + try { + await git(repoPath, ["worktree", "prune"]); + } catch { + // Best-effort cleanup of stale git metadata before adding the worktree again. + } + return; + } + + try { + await git(repoPath, ["worktree", "remove", "--force", workspacePath]); + return; + } catch { + try { + await git(repoPath, ["worktree", "prune"]); + } catch { + // Best-effort before falling back to directory removal. + } + rmSync(workspacePath, { recursive: true, force: true }); + } +} + +export async function markOutdatedCodeReviewRunsForSession({ + store, + session, + resolveTargetSha = resolveGitHeadSha, + now = new Date(), +}: MarkOutdatedCodeReviewRunsInput): Promise { + const targetSha = await resolveTargetSha(session); + return markSupersededReviewRuns({ + store, + existingRuns: store.listRuns({ linkedSessionId: session.id }), + linkedSessionId: session.id, + targetSha, + now, + }); +} + +export async function prepareGitReviewerWorkspace({ + projectId, + project, + session, + run, +}: { + projectId: string; + project: ProjectConfig; + session: Session; + run: CodeReviewRun; +}): Promise { + const workspaceRoot = join(getProjectCodeReviewsDir(projectId), "workspaces"); + const workspacePath = join(workspaceRoot, run.reviewerSessionId); + mkdirSync(workspaceRoot, { recursive: true }); + await removeReviewerWorktree(project.path, workspacePath); + + const ref = run.targetSha ?? (await resolveWorkspaceHead(session.workspacePath)) ?? "HEAD"; + await git(project.path, ["worktree", "add", "--detach", workspacePath, ref], 60_000); + return workspacePath; +} + +function buildDefaultReviewPrompt(context: CodeReviewRunnerContext): string { + return [ + "You are an AO reviewer agent. Review this repository snapshot for concrete bugs only.", + "Do not modify files. Do not publish comments anywhere.", + `Review the changes against base ref "${context.baseRef}". Start with: git diff --merge-base ${context.baseRef} HEAD -- .`, + "If that diff command fails, inspect git status/log and compare this detached reviewer workspace to the base ref using read-only commands.", + `Linked coding worker: ${context.session.id}`, + `Reviewer run: ${context.run.reviewerSessionId}`, + `Base ref: ${context.baseRef}`, + "Return only JSON using this schema:", + '{"findings":[{"severity":"warning|error|info","title":"short title","body":"specific issue and fix","filePath":"optional/path","startLine":1,"endLine":1,"confidence":0.8}]}', + 'If there are no concrete bugs, return {"findings":[]}.', + ].join("\n"); +} + +async function readOutputFile(path: string): Promise { + if (!existsSync(path)) return null; + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} + +export function createShellCodeReviewRunner(command: string): CodeReviewRunner { + return async (context) => { + const shell = getShell(); + const { stdout, stderr } = await execFileAsync(shell.cmd, shell.args(command), { + cwd: context.workspacePath, + timeout: REVIEW_COMMAND_TIMEOUT_MS, + maxBuffer: REVIEW_COMMAND_MAX_BUFFER, + env: process.env, + }); + return { rawOutput: stdout.trim() || stderr.trim() }; + }; +} + +export function buildCodexCodeReviewArgs(outputFile: string, prompt: string): string[] { + return ["exec", "--sandbox", "read-only", "--output-last-message", outputFile, prompt]; +} + +export async function runCodexCodeReview( + context: CodeReviewRunnerContext, +): Promise { + const outputFile = join(context.workspacePath, ".ao-code-review-output.json"); + const prompt = buildDefaultReviewPrompt(context); + const args = buildCodexCodeReviewArgs(outputFile, prompt); + + try { + const { stdout, stderr } = await execFileWithClosedStdin("codex", args, { + cwd: context.workspacePath, + timeout: REVIEW_COMMAND_TIMEOUT_MS, + maxBuffer: REVIEW_COMMAND_MAX_BUFFER, + env: process.env, + shell: isWindows(), + }); + const outputFileContents = await readOutputFile(outputFile); + const rawOutput = outputFileContents ?? (stdout.trim() || stderr.trim()); + return { rawOutput }; + } catch (error) { + const details = + error instanceof Error && "stderr" in error && typeof error.stderr === "string" + ? error.stderr.trim() + : error instanceof Error + ? error.message + : String(error); + throw new Error(`Codex review failed: ${details}`, { cause: error }); + } +} + +function defaultReviewSummary(session: Session, source: CodeReviewRequestSource): string { + const sourceLabel = source === "cli" ? "CLI" : source === "web" ? "dashboard" : "automation"; + return `Review requested from ${sourceLabel} for ${session.id}.`; +} + +export function formatCodeReviewFindingsForAgent({ + run, + findings, + session, +}: { + run: CodeReviewRun; + findings: CodeReviewFinding[]; + session: Session; +}): string { + const prLabel = run.prNumber + ? `PR #${run.prNumber}${run.prUrl ? ` (${run.prUrl})` : ""}` + : run.prUrl + ? `PR ${run.prUrl}` + : "the current PR"; + const targetLabel = run.targetSha ? `\nTarget SHA reviewed: ${run.targetSha}` : ""; + + return [ + `AO reviewer ${run.reviewerSessionId} found ${findings.length} open issue${ + findings.length === 1 ? "" : "s" + } for ${prLabel}.`, + `Linked coding worker: ${session.id}`, + `Review run: ${run.id}${targetLabel}`, + "", + "Please address each finding below. Verify each issue against the current source before editing, then update the PR branch and push your fixes.", + "When you start working on these, report `ao report addressing-reviews`. When the fixes are ready for another review, report `ao report ready-for-review`.", + "", + "Findings:", + findings.map((finding, index) => formatFindingForAgent(finding, index + 1)).join("\n\n"), + ].join("\n"); +} + +export async function triggerCodeReviewForSession( + { + config, + sessionManager, + storeFactory = createCodeReviewStore, + resolveTargetSha = resolveGitHeadSha, + now = new Date(), + }: TriggerCodeReviewOptions, + input: TriggerCodeReviewInput, +): Promise { + const session = await sessionManager.get(input.sessionId); + if (!session) { + throw new SessionNotFoundError(input.sessionId); + } + + const project = config.projects[session.projectId]; + if (!project) { + throw new CodeReviewInvalidSessionError( + `Unknown project for session ${session.id}: ${session.projectId}`, + ); + } + + const sessionPrefix = project.sessionPrefix ?? session.projectId; + const allSessionPrefixes = Object.entries(config.projects).map( + ([projectId, projectConfig]) => projectConfig.sessionPrefix ?? projectId, + ); + if (isOrchestratorSession(session, sessionPrefix, allSessionPrefixes)) { + throw new CodeReviewInvalidSessionError( + `Cannot request code review for orchestrator session: ${session.id}`, + ); + } + + const store = storeFactory(session.projectId); + const prUrl = session.pr?.url ?? session.metadata["pr"]; + const prNumber = session.pr?.number ?? parsePrNumber(prUrl); + const targetSha = await resolveTargetSha(session); + const requestedBy = input.requestedBy ?? "system"; + + return withReviewRunCreationLock(store, () => { + const existingRuns = store.listRuns(); + const reviewerSessionId = allocateReviewerSessionId(existingRuns, sessionPrefix); + + markSupersededReviewRuns({ + store, + existingRuns, + linkedSessionId: session.id, + targetSha, + now, + }); + + const run = store.createRun( + { + linkedSessionId: session.id, + reviewerSessionId, + status: input.status ?? "queued", + targetSha, + prNumber, + prUrl, + summary: input.summary ?? defaultReviewSummary(session, requestedBy), + }, + now, + ); + + return { + ...run, + findingCount: 0, + openFindingCount: 0, + dismissedFindingCount: 0, + sentFindingCount: 0, + resolvedFindingCount: 0, + }; + }); +} + +function summarizeRun(store: CodeReviewStore, runId: string): CodeReviewRunSummary { + const run = store.listRunSummaries().find((entry) => entry.id === runId); + if (!run) { + throw new CodeReviewRunNotFoundError(runId); + } + return run; +} + +function getExecutableRun(store: CodeReviewStore, runId: string, force: boolean): CodeReviewRun { + const run = store.getRun(runId); + if (!run) { + throw new CodeReviewRunNotFoundError(runId); + } + + if (run.status === "preparing" || run.status === "running") { + throw new CodeReviewRunNotExecutableError(run); + } + + if (!force && !["queued", "failed"].includes(run.status)) { + throw new CodeReviewRunNotExecutableError(run); + } + + return run; +} + +export async function executeCodeReviewRun( + { + config, + sessionManager, + storeFactory = createCodeReviewStore, + prepareWorkspace = prepareGitReviewerWorkspace, + runReviewer = runCodexCodeReview, + now = () => new Date(), + force = false, + }: ExecuteCodeReviewRunOptions, + { projectId, runId }: ExecuteCodeReviewRunInput, +): Promise { + const project = config.projects[projectId]; + if (!project) { + throw new Error(`Unknown project: ${projectId}`); + } + + const store = storeFactory(projectId); + const claimed = await withReviewRunExecutionLock(store, runId, async () => { + const executableRun = getExecutableRun(store, runId, force); + const session = await sessionManager.get(executableRun.linkedSessionId); + if (!session) { + throw new SessionNotFoundError(executableRun.linkedSessionId); + } + + const startedAt = now(); + const run = store.updateRun( + executableRun.id, + { + status: "preparing", + startedAt: executableRun.startedAt ?? startedAt.toISOString(), + completedAt: undefined, + terminationReason: undefined, + }, + startedAt, + ); + + return { run, session }; + }); + let run = claimed.run; + const session = claimed.session; + + try { + const workspacePath = await prepareWorkspace({ projectId, project, session, run }); + run = store.updateRun( + run.id, + { status: "running", reviewerWorkspacePath: workspacePath }, + now(), + ); + const baseRef = session.pr?.baseBranch?.trim() || project.defaultBranch; + const result = await runReviewer({ config, project, session, run, workspacePath, baseRef }); + const findings = result.findings ?? parseReviewerOutput(result.rawOutput ?? ""); + + for (const finding of findings) { + store.createFinding( + { + runId: run.id, + linkedSessionId: run.linkedSessionId, + severity: finding.severity ?? "warning", + title: finding.title?.trim() || "Review finding", + body: finding.body?.trim() || "Reviewer reported an issue without details.", + filePath: finding.filePath, + startLine: finding.startLine, + endLine: finding.endLine, + category: finding.category, + confidence: finding.confidence, + fingerprint: finding.fingerprint, + }, + now(), + ); + } + + const completedAt = now(); + store.updateRun( + run.id, + { + status: findings.length > 0 ? "needs_triage" : "clean", + completedAt: completedAt.toISOString(), + summary: result.summary ?? run.summary, + terminationReason: undefined, + }, + completedAt, + ); + } catch (error) { + const completedAt = now(); + store.updateRun( + run.id, + { + status: "failed", + completedAt: completedAt.toISOString(), + terminationReason: error instanceof Error ? error.message : String(error), + }, + completedAt, + ); + } + + return summarizeRun(store, run.id); +} + +export async function sendCodeReviewFindingsToAgent( + { + config, + sessionManager, + storeFactory = createCodeReviewStore, + now = () => new Date(), + }: SendCodeReviewFindingsOptions, + { projectId, runId }: SendCodeReviewFindingsInput, +): Promise { + const project = config.projects[projectId]; + if (!project) { + throw new Error(`Unknown project: ${projectId}`); + } + + const store = storeFactory(projectId); + const run = store.getRun(runId); + if (!run) { + throw new CodeReviewRunNotFoundError(runId); + } + + const session = await sessionManager.get(run.linkedSessionId); + if (!session) { + throw new SessionNotFoundError(run.linkedSessionId); + } + + const findings = store.listFindings({ runId: run.id, status: "open" }); + if (findings.length === 0) { + throw new CodeReviewNoOpenFindingsError(run); + } + + const message = formatCodeReviewFindingsForAgent({ run, findings, session }); + await sessionManager.send(session.id, message); + + const sentAt = now(); + for (const finding of findings) { + store.updateFinding( + finding.id, + { + status: "sent_to_agent", + sentToAgentAt: sentAt.toISOString(), + }, + sentAt, + ); + } + + store.updateRun(run.id, { status: "waiting_update" }, sentAt); + + return { + run: summarizeRun(store, run.id), + sentFindingCount: findings.length, + message, + }; +} diff --git a/packages/core/src/code-review-store.ts b/packages/core/src/code-review-store.ts new file mode 100644 index 000000000..5f8875314 --- /dev/null +++ b/packages/core/src/code-review-store.ts @@ -0,0 +1,504 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFileSync } from "./atomic-write.js"; +import { getProjectCodeReviewsDir } from "./paths.js"; + +export type CodeReviewRunStatus = + | "queued" + | "preparing" + | "running" + | "needs_triage" + | "sent_to_agent" + | "waiting_update" + | "clean" + | "outdated" + | "failed" + | "cancelled"; + +export type CodeReviewFindingStatus = "open" | "dismissed" | "sent_to_agent" | "resolved"; + +export type CodeReviewSeverity = "info" | "warning" | "error"; + +export interface CodeReviewRun { + id: string; + projectId: string; + linkedSessionId: string; + reviewerSessionId: string; + status: CodeReviewRunStatus; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + targetSha?: string; + baseSha?: string; + prNumber?: number; + prUrl?: string; + reviewerWorkspacePath?: string; + summary?: string; + terminationReason?: string; +} + +export interface CodeReviewFinding { + id: string; + projectId: string; + runId: string; + linkedSessionId: string; + status: CodeReviewFindingStatus; + severity: CodeReviewSeverity; + title: string; + body: string; + filePath?: string; + startLine?: number; + endLine?: number; + category?: string; + confidence?: number; + fingerprint?: string; + createdAt: string; + updatedAt: string; + dismissedAt?: string; + dismissedBy?: string; + sentToAgentAt?: string; +} + +export interface CodeReviewRunSummary extends CodeReviewRun { + findingCount: number; + openFindingCount: number; + dismissedFindingCount: number; + sentFindingCount: number; + resolvedFindingCount: number; +} + +export interface CodeReviewStoreOptions { + /** Override storage dir for tests. Defaults to the project code review store path. */ + storeDir?: string; +} + +export interface ListCodeReviewRunsFilter { + linkedSessionId?: string; + status?: CodeReviewRunStatus; +} + +export interface ListCodeReviewFindingsFilter { + runId?: string; + linkedSessionId?: string; + status?: CodeReviewFindingStatus; +} + +export interface CreateCodeReviewRunInput { + linkedSessionId: string; + reviewerSessionId: string; + status?: CodeReviewRunStatus; + targetSha?: string; + baseSha?: string; + prNumber?: number; + prUrl?: string; + reviewerWorkspacePath?: string; + summary?: string; +} + +export interface CreateCodeReviewFindingInput { + runId: string; + linkedSessionId: string; + severity: CodeReviewSeverity; + title: string; + body: string; + status?: CodeReviewFindingStatus; + filePath?: string; + startLine?: number; + endLine?: number; + category?: string; + confidence?: number; + fingerprint?: string; +} + +const REVIEW_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +function assertSafeReviewId(id: string, label: string): void { + if (!id || id === "." || id === ".." || !REVIEW_ID_PATTERN.test(id)) { + throw new Error(`Unsafe ${label}: "${id}"`); + } +} + +function normalizeIsoTimestamp(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function parseOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function parseRunStatus(value: unknown): CodeReviewRunStatus { + switch (value) { + case "queued": + case "preparing": + case "running": + case "needs_triage": + case "sent_to_agent": + case "waiting_update": + case "clean": + case "outdated": + case "failed": + case "cancelled": + return value; + default: + return "queued"; + } +} + +function parseFindingStatus(value: unknown): CodeReviewFindingStatus { + switch (value) { + case "dismissed": + case "sent_to_agent": + case "resolved": + return value; + case "open": + default: + return "open"; + } +} + +function parseSeverity(value: unknown): CodeReviewSeverity { + switch (value) { + case "error": + case "warning": + case "info": + return value; + default: + return "warning"; + } +} + +function readJsonFile(path: string): unknown | null { + try { + return JSON.parse(readFileSync(path, "utf-8")) as unknown; + } catch { + return null; + } +} + +function writeJsonFile(path: string, value: unknown): void { + atomicWriteFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function removeUndefined>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), + ) as T; +} + +function compareUpdatedDesc( + a: { updatedAt: string; createdAt: string; id: string }, + b: { updatedAt: string; createdAt: string; id: string }, +): number { + return ( + Date.parse(b.updatedAt) - Date.parse(a.updatedAt) || + Date.parse(b.createdAt) - Date.parse(a.createdAt) || + a.id.localeCompare(b.id) + ); +} + +function parseRun(projectId: string, value: unknown): CodeReviewRun | null { + if (!isRecord(value)) return null; + const id = parseOptionalString(value["id"]); + const linkedSessionId = parseOptionalString(value["linkedSessionId"]); + const reviewerSessionId = parseOptionalString(value["reviewerSessionId"]); + const createdAt = normalizeIsoTimestamp(value["createdAt"]); + const updatedAt = normalizeIsoTimestamp(value["updatedAt"]) ?? createdAt; + if (!id || !linkedSessionId || !reviewerSessionId || !createdAt || !updatedAt) return null; + + return removeUndefined({ + id, + projectId: parseOptionalString(value["projectId"]) ?? projectId, + linkedSessionId, + reviewerSessionId, + status: parseRunStatus(value["status"]), + createdAt, + updatedAt, + startedAt: normalizeIsoTimestamp(value["startedAt"]), + completedAt: normalizeIsoTimestamp(value["completedAt"]), + targetSha: parseOptionalString(value["targetSha"]), + baseSha: parseOptionalString(value["baseSha"]), + prNumber: parseNumber(value["prNumber"]), + prUrl: parseOptionalString(value["prUrl"]), + reviewerWorkspacePath: parseOptionalString(value["reviewerWorkspacePath"]), + summary: parseOptionalString(value["summary"]), + terminationReason: parseOptionalString(value["terminationReason"]), + }); +} + +function parseFinding(projectId: string, value: unknown): CodeReviewFinding | null { + if (!isRecord(value)) return null; + const id = parseOptionalString(value["id"]); + const runId = parseOptionalString(value["runId"]); + const linkedSessionId = parseOptionalString(value["linkedSessionId"]); + const title = parseOptionalString(value["title"]); + const body = parseOptionalString(value["body"]); + const createdAt = normalizeIsoTimestamp(value["createdAt"]); + const updatedAt = normalizeIsoTimestamp(value["updatedAt"]) ?? createdAt; + if (!id || !runId || !linkedSessionId || !title || !body || !createdAt || !updatedAt) { + return null; + } + + return removeUndefined({ + id, + projectId: parseOptionalString(value["projectId"]) ?? projectId, + runId, + linkedSessionId, + status: parseFindingStatus(value["status"]), + severity: parseSeverity(value["severity"]), + title, + body, + filePath: parseOptionalString(value["filePath"]), + startLine: parseNumber(value["startLine"]), + endLine: parseNumber(value["endLine"]), + category: parseOptionalString(value["category"]), + confidence: parseNumber(value["confidence"]), + fingerprint: parseOptionalString(value["fingerprint"]), + createdAt, + updatedAt, + dismissedAt: normalizeIsoTimestamp(value["dismissedAt"]), + dismissedBy: parseOptionalString(value["dismissedBy"]), + sentToAgentAt: normalizeIsoTimestamp(value["sentToAgentAt"]), + }); +} + +export class CodeReviewStore { + readonly projectId: string; + readonly storeDir: string; + + constructor(projectId: string, options: CodeReviewStoreOptions = {}) { + this.projectId = projectId; + this.storeDir = options.storeDir ?? getProjectCodeReviewsDir(projectId); + } + + get runsDir(): string { + return join(this.storeDir, "runs"); + } + + get findingsDir(): string { + return join(this.storeDir, "findings"); + } + + ensure(): void { + mkdirSync(this.runsDir, { recursive: true }); + mkdirSync(this.findingsDir, { recursive: true }); + } + + listRuns(filter: ListCodeReviewRunsFilter = {}): CodeReviewRun[] { + return this.readAllRuns() + .filter((run) => !filter.linkedSessionId || run.linkedSessionId === filter.linkedSessionId) + .filter((run) => !filter.status || run.status === filter.status) + .sort(compareUpdatedDesc); + } + + listRunSummaries(filter: ListCodeReviewRunsFilter = {}): CodeReviewRunSummary[] { + const findings = this.listFindings(); + const countsByRun = new Map>(); + + for (const finding of findings) { + const counts = countsByRun.get(finding.runId) ?? { + findingCount: 0, + openFindingCount: 0, + dismissedFindingCount: 0, + sentFindingCount: 0, + resolvedFindingCount: 0, + }; + counts.findingCount++; + if (finding.status === "open") counts.openFindingCount++; + if (finding.status === "dismissed") counts.dismissedFindingCount++; + if (finding.status === "sent_to_agent") counts.sentFindingCount++; + if (finding.status === "resolved") counts.resolvedFindingCount++; + countsByRun.set(finding.runId, counts); + } + + return this.listRuns(filter).map((run) => ({ + ...run, + ...(countsByRun.get(run.id) ?? { + findingCount: 0, + openFindingCount: 0, + dismissedFindingCount: 0, + sentFindingCount: 0, + resolvedFindingCount: 0, + }), + })); + } + + getRun(runId: string): CodeReviewRun | null { + assertSafeReviewId(runId, "review run id"); + return parseRun(this.projectId, readJsonFile(this.runPath(runId))); + } + + createRun(input: CreateCodeReviewRunInput, now = new Date()): CodeReviewRun { + const id = `review-run-${randomUUID()}`; + const timestamp = now.toISOString(); + const run: CodeReviewRun = removeUndefined({ + id, + projectId: this.projectId, + linkedSessionId: input.linkedSessionId, + reviewerSessionId: input.reviewerSessionId, + status: input.status ?? "queued", + createdAt: timestamp, + updatedAt: timestamp, + startedAt: input.status === "running" ? timestamp : undefined, + targetSha: input.targetSha, + baseSha: input.baseSha, + prNumber: input.prNumber, + prUrl: input.prUrl, + reviewerWorkspacePath: input.reviewerWorkspacePath, + summary: input.summary, + }); + this.writeRun(run); + return run; + } + + updateRun( + runId: string, + patch: Partial>, + now = new Date(), + ): CodeReviewRun { + const existing = this.getRun(runId); + if (!existing) { + throw new Error(`Code review run not found: ${runId}`); + } + const next = removeUndefined({ + ...existing, + ...patch, + id: existing.id, + projectId: existing.projectId, + createdAt: existing.createdAt, + updatedAt: now.toISOString(), + }); + this.writeRun(next); + return next; + } + + listFindings(filter: ListCodeReviewFindingsFilter = {}): CodeReviewFinding[] { + return this.readAllFindings() + .filter((finding) => !filter.runId || finding.runId === filter.runId) + .filter( + (finding) => !filter.linkedSessionId || finding.linkedSessionId === filter.linkedSessionId, + ) + .filter((finding) => !filter.status || finding.status === filter.status) + .sort(compareUpdatedDesc); + } + + getFinding(findingId: string): CodeReviewFinding | null { + assertSafeReviewId(findingId, "review finding id"); + return parseFinding(this.projectId, readJsonFile(this.findingPath(findingId))); + } + + createFinding(input: CreateCodeReviewFindingInput, now = new Date()): CodeReviewFinding { + if (!this.getRun(input.runId)) { + throw new Error(`Code review run not found: ${input.runId}`); + } + const id = `review-finding-${randomUUID()}`; + const timestamp = now.toISOString(); + const finding: CodeReviewFinding = removeUndefined({ + id, + projectId: this.projectId, + runId: input.runId, + linkedSessionId: input.linkedSessionId, + status: input.status ?? "open", + severity: input.severity, + title: input.title, + body: input.body, + filePath: input.filePath, + startLine: input.startLine, + endLine: input.endLine, + category: input.category, + confidence: input.confidence, + fingerprint: input.fingerprint, + createdAt: timestamp, + updatedAt: timestamp, + }); + this.writeFinding(finding); + return finding; + } + + updateFinding( + findingId: string, + patch: Partial< + Omit + >, + now = new Date(), + ): CodeReviewFinding { + const existing = this.getFinding(findingId); + if (!existing) { + throw new Error(`Code review finding not found: ${findingId}`); + } + const next = removeUndefined({ + ...existing, + ...patch, + id: existing.id, + projectId: existing.projectId, + runId: existing.runId, + linkedSessionId: existing.linkedSessionId, + createdAt: existing.createdAt, + updatedAt: now.toISOString(), + }); + this.writeFinding(next); + return next; + } + + deleteAll(): void { + rmSync(this.storeDir, { recursive: true, force: true }); + } + + private runPath(runId: string): string { + assertSafeReviewId(runId, "review run id"); + return join(this.runsDir, `${runId}.json`); + } + + private findingPath(findingId: string): string { + assertSafeReviewId(findingId, "review finding id"); + return join(this.findingsDir, `${findingId}.json`); + } + + private writeRun(run: CodeReviewRun): void { + this.ensure(); + writeJsonFile(this.runPath(run.id), run); + } + + private writeFinding(finding: CodeReviewFinding): void { + this.ensure(); + writeJsonFile(this.findingPath(finding.id), finding); + } + + private readAllRuns(): CodeReviewRun[] { + return this.readAllJsonFiles(this.runsDir) + .map((value) => parseRun(this.projectId, value)) + .filter((run): run is CodeReviewRun => run !== null); + } + + private readAllFindings(): CodeReviewFinding[] { + return this.readAllJsonFiles(this.findingsDir) + .map((value) => parseFinding(this.projectId, value)) + .filter((finding): finding is CodeReviewFinding => finding !== null); + } + + private readAllJsonFiles(dir: string): unknown[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => readJsonFile(join(dir, entry.name))) + .filter((value) => value !== null); + } +} + +export function createCodeReviewStore( + projectId: string, + options: CodeReviewStoreOptions = {}, +): CodeReviewStore { + return new CodeReviewStore(projectId, options); +} diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts index 6ad925953..fd9678d5b 100644 --- a/packages/core/src/global-config.ts +++ b/packages/core/src/global-config.ts @@ -472,6 +472,69 @@ export function writeLocalProjectConfig( return configPath; } +function isRecord(value: unknown): value is Record { + return value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value); +} + +function mergeRoleBehavior( + defaults: Record, + project: Record, + key: "orchestrator" | "worker", +): Record | undefined { + const defaultRole = isRecord(defaults[key]) ? defaults[key] : undefined; + const projectRole = isRecord(project[key]) ? project[key] : undefined; + const merged = { + ...(defaultRole ?? {}), + ...(projectRole ?? {}), + }; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function buildRepairedLocalProjectConfig( + parsed: Record, + project: Record, +): Record { + const defaults = isRecord(parsed["defaults"]) ? parsed["defaults"] : {}; + const defaultBehavior: Record = {}; + for (const key of ["runtime", "agent", "workspace"] as const) { + if (defaults[key] !== null && defaults[key] !== undefined) { + defaultBehavior[key] = defaults[key]; + } + } + + const { + name: _name, + path: _path, + sessionPrefix: _sessionPrefix, + projectId: _projectId, + source: _source, + registeredAt: _registeredAt, + displayName: _displayName, + orchestrator: _orchestrator, + worker: _worker, + ...projectBehavior + } = project; + void _name; + void _path; + void _sessionPrefix; + void _projectId; + void _source; + void _registeredAt; + void _displayName; + void _orchestrator; + void _worker; + + const behavior = { + ...defaultBehavior, + ...projectBehavior, + }; + const orchestrator = mergeRoleBehavior(defaults, project, "orchestrator"); + const worker = mergeRoleBehavior(defaults, project, "worker"); + if (orchestrator) behavior["orchestrator"] = orchestrator; + if (worker) behavior["worker"] = worker; + return behavior; +} + export function repairWrappedLocalProjectConfig(projectId: string, projectPath: string): void { const localConfigResult = loadLocalProjectConfigDetailed(projectPath); if (localConfigResult.kind !== "old-format" || !localConfigResult.path) { @@ -501,24 +564,7 @@ export function repairWrappedLocalProjectConfig(projectId: string, projectPath: ); } - const { - name: _name, - path: _path, - sessionPrefix: _sessionPrefix, - projectId: _projectId, - source: _source, - registeredAt: _registeredAt, - displayName: _displayName, - ...behaviorFields - } = project; - void _name; - void _path; - void _sessionPrefix; - void _projectId; - void _source; - void _registeredAt; - void _displayName; - + const behaviorFields = buildRepairedLocalProjectConfig(parsed, project); writeLocalProjectConfig(projectPath, behaviorFields, configPath); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6cd385068..047bf10d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -43,6 +43,53 @@ export { export { createInitialCanonicalLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; export { sessionFromMetadata } from "./utils/session-from-metadata.js"; +// AO-local code review store +export { CodeReviewStore, createCodeReviewStore } from "./code-review-store.js"; +export type { + CodeReviewFinding, + CodeReviewFindingStatus, + CodeReviewRun, + CodeReviewRunStatus, + CodeReviewRunSummary, + CodeReviewSeverity, + CodeReviewStoreOptions, + CreateCodeReviewFindingInput, + CreateCodeReviewRunInput, + ListCodeReviewFindingsFilter, + ListCodeReviewRunsFilter, +} from "./code-review-store.js"; +export { + CodeReviewInvalidSessionError, + CodeReviewNoOpenFindingsError, + CodeReviewRunNotExecutableError, + CodeReviewRunNotFoundError, + createShellCodeReviewRunner, + executeCodeReviewRun, + formatCodeReviewFindingsForAgent, + markOutdatedCodeReviewRunsForSession, + parseReviewerOutput, + prepareGitReviewerWorkspace, + runCodexCodeReview, + sendCodeReviewFindingsToAgent, + triggerCodeReviewForSession, +} from "./code-review-manager.js"; +export type { + CodeReviewRunner, + CodeReviewRunnerContext, + CodeReviewRunnerFinding, + CodeReviewRunnerResult, + CodeReviewRequestSource, + ExecuteCodeReviewRunInput, + ExecuteCodeReviewRunOptions, + MarkOutdatedCodeReviewRunsInput, + PrepareCodeReviewWorkspace, + SendCodeReviewFindingsInput, + SendCodeReviewFindingsOptions, + SendCodeReviewFindingsResult, + TriggerCodeReviewInput, + TriggerCodeReviewOptions, +} from "./code-review-manager.js"; + // Lifecycle transitions — centralized transition boundary (#137) export { applyLifecycleDecision, @@ -246,6 +293,7 @@ export { getProjectDir, getProjectSessionsDir, getProjectWorktreesDir, + getProjectCodeReviewsDir, getProjectFeedbackReportsDir, getOrchestratorPath, getSessionPath, diff --git a/packages/core/src/paths.ts b/packages/core/src/paths.ts index 1485eeadc..40fdc6ce7 100644 --- a/packages/core/src/paths.ts +++ b/packages/core/src/paths.ts @@ -125,6 +125,11 @@ export function getProjectWorktreesDir(projectId: string): string { return join(getProjectDir(projectId), "worktrees"); } +/** Get the AO-local code review store directory for a project. */ +export function getProjectCodeReviewsDir(projectId: string): string { + return join(getProjectDir(projectId), "code-reviews"); +} + /** Get the feedback reports directory for a project (V2 layout). */ export function getProjectFeedbackReportsDir(projectId: string): string { return join(getProjectDir(projectId), "feedback-reports"); diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 08ca7ec02..2551bc287 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -39,6 +39,12 @@ ao spawn --prompt "Refactor the auth module to use JWT" # List sessions ao session ls -p {{projectId}} +# List AO-local reviewer runs +ao review list {{projectId}} + +# Send completed AO-local review findings back to the linked coding worker +ao review send {{projectSessionPrefix}}-rev-1 -p {{projectId}} + # Send message to a session ao send {{projectSessionPrefix}}-1 "Your message here" @@ -64,6 +70,10 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}} - `ao spawn [issue] [--prompt ]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr ]{{REPO_CONFIGURED_SECTION_END}}`: Spawn a worker session{{REPO_CONFIGURED_SECTION_START}}; use issue ID or --prompt for freeform tasks{{REPO_CONFIGURED_SECTION_END}}{{REPO_NOT_CONFIGURED_SECTION_START}} with --prompt for freeform tasks{{REPO_NOT_CONFIGURED_SECTION_END}} {{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn `: Spawn multiple sessions in parallel (project auto-detected) {{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project) +- `ao review list [project]`: List AO-local reviewer runs. These are review agents/runs, not coding worker sessions. +- `ao review run [--execute]`: Request a reviewer run for a coding worker session. +- `ao review execute [project] [--run ]`: Execute a queued reviewer run. +- `ao review send [-p project]`: Send open AO-local findings from a completed reviewer run to its linked coding worker, then mark the run as waiting for worker updates. {{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr [session]`: Attach an existing PR to a worker session {{REPO_CONFIGURED_SECTION_END}}- `ao session attach `: Attach to a session's terminal (a tmux window on Unix; a ConPTY pty-host on Windows) - `ao session kill `: Kill a specific session @@ -96,6 +106,7 @@ ao spawn --prompt "Add rate limiting to the /api/upload endpoint" Use `ao status` to see: - Current session status (working, pr_open, review_pending, etc.) +- AO-local reviewer run summary and open finding counts {{REPO_CONFIGURED_SECTION_START}}- PR state (open/merged/closed) - CI status (passing/failing/pending) - Review decision (approved/changes_requested/pending) @@ -111,6 +122,24 @@ ao status --reports full # full audit trail per session Reach for this when an inferred status disagrees with what the worker said, when deciding whether to send a follow-up instruction vs. wait, or when triaging a session that looks stuck. +Reviewer runs are intentionally separate from coding worker sessions. A reviewer run has its own workspace and context, and does not appear in `ao session ls` as a coding session. Use `ao status` for the summary and `ao review list {{projectId}}` for the detailed reviewer-run list. + +When a reviewer run has open findings, do not manually summarize them from memory. Use `ao review send -p {{projectId}}` to hand the stored findings back to the linked coding worker through AO. After sending, monitor the worker and request a new review once it reports the fixes are ready. + +### AO-Local Review Loop + +When the user asks you to review a worker, review a PR, or keep reviewing until clean, handle the loop internally: + +1. Inspect current state with `ao status` and identify the coding worker session. +2. Request and execute the reviewer run with `ao review run --execute`. +3. If the run is clean, report that the work is AO-review clean. +4. If the run has open findings, send the stored findings to the linked coding worker with `ao review send -p {{projectId}}`. +5. Monitor the coding worker with `ao status` and wait for it to push fixes or report `ready-for-review`. +6. Re-run `ao review run --execute` after the worker updates. +7. Continue until the review is clean, the worker is stuck, the user asks you to stop, or the configured review round limit is reached. + +Do not ask the user to manually run review commands for routine review/fix iterations. Treat review commands as orchestration internals, the same way worker spawning and `ao send` are orchestration internals. + ### Explicit Agent Reports Worker agents self-declare their workflow phase using `ao acknowledge` and `ao report ` (started, working, waiting, needs-input, fixing-ci, addressing-reviews, pr-created, draft-pr-created, ready-for-review, completed). These reports are persisted alongside the canonical lifecycle and may inform lifecycle inference, but do not replace runtime/activity/SCM-derived truth. diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 9654a19dc..ec85fd4ef 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -6,6 +6,17 @@ import { defineConfig } from "vitest/config"; const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + resolve: { + alias: [ + { find: /^@aoagents\/ao-core$/, replacement: resolve(__dirname, "src/index.ts") }, + { + find: /^@aoagents\/ao-core\/scm-webhook-utils$/, + replacement: resolve(__dirname, "src/scm-webhook-utils.ts"), + }, + { find: /^@aoagents\/ao-core\/types$/, replacement: resolve(__dirname, "src/types.ts") }, + { find: /^@aoagents\/ao-core\/utils$/, replacement: resolve(__dirname, "src/utils.ts") }, + ], + }, plugins: [ { name: "raw-markdown", diff --git a/packages/web/e2e/review-board-flows.md b/packages/web/e2e/review-board-flows.md new file mode 100644 index 000000000..a8b4cc706 --- /dev/null +++ b/packages/web/e2e/review-board-flows.md @@ -0,0 +1,110 @@ +# Review Board E2E Flows + +These flows cover the reviewer-agent UI as an orchestrator-owned surface, not as a second +command center. The project orchestrator is the entry point for creating and running reviews. +The review board observes, inspects, and navigates reviewer work. Reviewer runs remain linked to +a coding worker, but they must not reuse the worker's terminal context. + +## Flow 1: Enter through the project orchestrator + +1. Open the project coding dashboard at `/projects/:projectId`. +2. Open the header `Orchestrator` action. +3. Confirm the app lands on `/projects/:projectId/sessions/:orchestratorId`. +4. Confirm this is the project orchestrator session, not a worker or reviewer session. + +## Flow 2: Coding to Reviews navigation stays available + +1. Open the project coding dashboard at `/projects/:projectId`. +2. Confirm the shared header shows `Coding` as the active workspace mode. +3. Click `Reviews`. +4. Confirm the browser lands on `/review?project=:projectId`. +5. Confirm `Reviews` is now active and `Coding` links back to `/projects/:projectId`. + +## Flow 3: Orchestrator requests reviews + +1. Start with a worker card that is ready for review. +2. From the orchestrator flow, issue the AO review command for that worker. +3. Confirm a queued review run appears on the review board. +4. Confirm the review run is linked to the coding worker and displays worker metadata. +5. Confirm no reviewer coding session metadata is created. + +## Flow 4: Orchestrator executes multiple queued reviewer runs + +1. Create at least two queued review runs. +2. From the orchestrator flow, issue two reviewer execution commands without waiting between them. +3. Confirm both cards can be observed in a reviewing state. +4. Confirm completed runs move to either `Triage` when findings exist or `Clean` when no findings exist. + +## Flow 5: Inspect findings + +1. Let the orchestrator execute a run whose reviewer result contains a finding. +2. Confirm the run lands in `Triage`. +3. Click the `view` finding action or the card `details` action. +4. Confirm the details panel lists severity, title, location, body, open count, total count, and worker actions. +5. Close the drawer with the close button or Escape. + +## Flow 6: Worker and orchestrator links + +1. From a review card, click `Worker`. +2. Confirm the app navigates to the coding dashboard focused on the linked worker. +3. Return to the review board. +4. Confirm the header `Orchestrator` action opens or restores the same project orchestrator used by the coding dashboard. + +## Flow 7: Failure and retry + +1. Execute a queued run with a reviewer command that fails. +2. Confirm the card moves to `Failed`. +3. From the orchestrator flow, issue a retry command for the same run. +4. Confirm the retry can execute the same run again with `force: true`. + +## Flow 8: Feedback availability + +1. Show a review run linked to a worker with no live runtime. +2. Confirm the card shows the worker runtime state instead of a `Feedback` action. +3. Show a review run linked to a live worker. +4. Confirm `Feedback` sends open review findings to the linked worker. +5. Confirm the app opens the worker terminal section after the send succeeds. + +## Flow 9: CLI and UI share the same review store + +1. Request a review through the orchestrator AO command path. +2. Confirm the run appears in `/review?project=:projectId` without refreshing any mocked data. +3. Execute that run through the orchestrator AO command path and a deterministic local reviewer command. +4. Confirm the UI reflects the persisted result after a reload. + +## Flow 10: Clean review result + +1. Let the orchestrator execute a reviewer command that returns `{"findings":[]}`. +2. Confirm the run moves to `Clean`. +3. Confirm the card reports `0 findings`. +4. Open details and confirm it reports no captured findings. + +## Flow 11: Reviewer isolation from coding sessions + +1. Execute a reviewer run. +2. Confirm the reviewer has a snapshot workspace under `code-reviews/workspaces/:reviewerSessionId`. +3. Confirm there is no coding session metadata file for `:reviewerSessionId`. +4. Confirm the linked worker card and terminal route remain the coding worker, not the reviewer. + +## Flow 12: New worker commit supersedes old review runs + +1. Complete a review for the worker's current `HEAD`. +2. Commit a new change in the worker repository. +3. From the orchestrator flow, request a new review for the same worker. +4. Confirm older review runs for the previous `HEAD` move to `Outdated`. +5. Confirm the new review remains actionable in `Queued`. + +## Flow 13: Same orchestrator across modes + +1. Open the coding dashboard and capture the header `Orchestrator` link. +2. Open the review board and capture the header `Orchestrator` link. +3. Confirm both links point to the same project orchestrator session. +4. Confirm the review board does not offer `Spawn Orchestrator` when one already exists. + +## Flow 14: Send reviewer findings back to worker + +1. Complete a review run with open findings. +2. From the review board, click `Feedback` for that review run. +3. Confirm AO sends the stored finding details to the linked coding worker. +4. Confirm the review run moves to `Waiting`. +5. Confirm open findings become sent findings and are no longer counted as open. diff --git a/packages/web/e2e/review-board.e2e.ts b/packages/web/e2e/review-board.e2e.ts new file mode 100644 index 000000000..f4e966ece --- /dev/null +++ b/packages/web/e2e/review-board.e2e.ts @@ -0,0 +1,871 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { request } from "node:http"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium, type Locator, type Page } from "playwright"; +import { createCodeReviewStore } from "../../core/src/code-review-store.ts"; +import { + createInitialCanonicalLifecycle, + deriveLegacyStatus, +} from "../../core/src/lifecycle-state.ts"; +import { writeMetadata } from "../../core/src/metadata.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WEB_DIR = resolve(__dirname, ".."); +const REPO_ROOT = resolve(WEB_DIR, "../.."); +const PROJECT_ID = "todo-app"; +const SESSION_ID = "todo-1"; +const ORCHESTRATOR_ID = "todo-orchestrator"; +const PR_URL = "https://github.com/acme/todo-app/pull/1"; + +interface Fixture { + rootDir: string; + homeDir: string; + projectDir: string; + globalConfigPath: string; + localConfigPath: string; + tmuxSessionPrefix: string; + tmuxSessions: string[]; +} + +interface ServerHandle { + baseUrl: string; + stop: () => Promise; +} + +function shellJson(value: unknown): string { + return JSON.stringify(value).replaceAll("'", "'\"'\"'"); +} + +function buildStaticReviewCommand(findings: unknown[], delayMs = 0): string { + const payload = { findings }; + return `node -e 'setTimeout(() => console.log(${shellJson(JSON.stringify(payload))}), ${delayMs})'`; +} + +function buildFindingReviewCommand(delayMs: number): string { + return buildStaticReviewCommand( + [ + { + severity: "warning", + title: "E2E reviewer finding", + body: "The e2e review command created this finding.", + filePath: "README.md", + startLine: 1, + confidence: 0.9, + }, + ], + delayMs, + ); +} + +function git(cwd: string, args: string[]): void { + execFileSync("git", args, { + cwd, + stdio: "ignore", + env: { + ...process.env, + GIT_AUTHOR_NAME: "AO E2E", + GIT_AUTHOR_EMAIL: "ao-e2e@example.com", + GIT_COMMITTER_NAME: "AO E2E", + GIT_COMMITTER_EMAIL: "ao-e2e@example.com", + }, + }); +} + +function startCodexBackedTmux(sessionName: string, cwd: string): void { + execFileSync( + "tmux", + [ + "new-session", + "-d", + "-s", + sessionName, + "-c", + cwd, + "exec codex --no-alt-screen --sandbox danger-full-access --ask-for-approval never", + ], + { stdio: "ignore" }, + ); +} + +function killTmuxSession(sessionName: string): void { + try { + execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" }); + } catch { + // Best effort cleanup for local e2e fixtures. + } +} + +function listTmuxSessions(): string[] { + try { + return execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }) + .split(/\r?\n/) + .map((sessionName) => sessionName.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +function killReviewBoardTmuxSessions(fixture: Fixture): void { + const sessionNames = new Set([ + ...fixture.tmuxSessions, + ...listTmuxSessions().filter((sessionName) => + sessionName.startsWith(fixture.tmuxSessionPrefix), + ), + ]); + + for (const sessionName of sessionNames) { + killTmuxSession(sessionName); + } +} + +function installFixtureSignalCleanup(fixture: Fixture): void { + process.once("exit", () => killReviewBoardTmuxSessions(fixture)); + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + killReviewBoardTmuxSessions(fixture); + process.exit(signal === "SIGINT" ? 130 : 143); + }); + } +} + +function captureTmuxPane(sessionName: string): string { + try { + return execFileSync("tmux", ["capture-pane", "-p", "-t", sessionName], { + encoding: "utf-8", + }); + } catch { + return ""; + } +} + +async function waitForTmuxText( + sessionName: string, + pattern: RegExp, + label: string, + timeoutMs = 45_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastCapture = ""; + + while (Date.now() < deadline) { + lastCapture = captureTmuxPane(sessionName); + if (pattern.test(lastCapture)) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + + throw new Error(`Expected tmux text: ${label}\n${lastCapture}`); +} + +async function getFreePort(): Promise { + return new Promise((resolvePromise, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Could not allocate a TCP port"))); + return; + } + const port = address.port; + server.close(() => resolvePromise(port)); + }); + }); +} + +function probe(url: URL): Promise { + return new Promise((resolveProbe) => { + const req = request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: "HEAD", + timeout: 2_000, + }, + (res) => { + res.resume(); + resolveProbe(true); + }, + ); + req.on("error", () => resolveProbe(false)); + req.on("timeout", () => { + req.destroy(); + resolveProbe(false); + }); + req.end(); + }); +} + +async function waitForServer(baseUrl: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + const url = new URL("/projects/todo-app", baseUrl); + while (Date.now() < deadline) { + if (await probe(url)) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + throw new Error(`Next dev server did not respond at ${baseUrl} within ${timeoutMs}ms`); +} + +async function startWebServer(fixture: Fixture): Promise { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const output: string[] = []; + const child: ChildProcess = spawn("pnpm", ["dev:next"], { + cwd: WEB_DIR, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + HOME: fixture.homeDir, + AO_GLOBAL_CONFIG: fixture.globalConfigPath, + AO_CONFIG_PATH: fixture.localConfigPath, + AO_CODE_REVIEW_COMMAND: buildFindingReviewCommand(1_000), + PORT: String(port), + NEXT_TELEMETRY_DISABLED: "1", + AO_NO_UPDATE_NOTIFIER: "1", + AGENT_ORCHESTRATOR_CI: "1", + }, + }); + + const collect = (chunk: Buffer) => { + output.push(chunk.toString()); + if (output.length > 80) output.splice(0, output.length - 80); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + + try { + await waitForServer(baseUrl, 45_000); + } catch (error) { + child.kill("SIGTERM"); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n${output.join("").trim()}`, + { cause: error }, + ); + } + + return { + baseUrl, + stop: async () => { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolveStop) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolveStop(); + }, 5_000); + child.once("exit", () => { + clearTimeout(timer); + resolveStop(); + }); + }); + }, + }; +} + +function createFixture(): Fixture { + const rootDir = mkdtempSync(join(tmpdir(), "ao-review-board-e2e-")); + const homeDir = join(rootDir, "home"); + const projectDir = join(rootDir, "todo-app"); + const globalConfigPath = join(homeDir, ".agent-orchestrator", "config.yaml"); + const localConfigPath = join(projectDir, "agent-orchestrator.yaml"); + const sessionsDir = join(homeDir, ".agent-orchestrator", "projects", PROJECT_ID, "sessions"); + const tmuxSuffix = basename(rootDir).replace(/[^a-zA-Z0-9_-]/g, "-"); + const tmuxSessionPrefix = `${tmuxSuffix}-`; + const workerTmuxName = `${tmuxSuffix}-worker`; + const orchestratorTmuxName = `${tmuxSuffix}-orchestrator`; + + mkdirSync(dirname(globalConfigPath), { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(sessionsDir, { recursive: true }); + + writeFileSync(join(projectDir, "README.md"), "# Todo App\n"); + writeFileSync(localConfigPath, "agent: codex\nruntime: tmux\nworkspace: worktree\n"); + git(projectDir, ["init", "-b", "main"]); + git(projectDir, ["add", "."]); + git(projectDir, ["commit", "-m", "initial"]); + startCodexBackedTmux(workerTmuxName, projectDir); + startCodexBackedTmux(orchestratorTmuxName, REPO_ROOT); + + writeFileSync( + globalConfigPath, + [ + "defaults:", + " runtime: tmux", + " agent: codex", + " workspace: worktree", + " notifiers: []", + "notifiers: {}", + "projects:", + ` ${PROJECT_ID}:`, + ` path: ${JSON.stringify(projectDir)}`, + " displayName: Todo App", + " defaultBranch: main", + " sessionPrefix: todo", + "", + ].join("\n"), + ); + + process.env["HOME"] = homeDir; + process.env["AO_GLOBAL_CONFIG"] = globalConfigPath; + process.env["AO_CONFIG_PATH"] = localConfigPath; + + const createdAt = new Date("2026-05-13T10:00:00.000Z"); + const workerLifecycle = createInitialCanonicalLifecycle("worker", createdAt); + workerLifecycle.session.state = "idle"; + workerLifecycle.session.reason = "awaiting_external_review"; + workerLifecycle.session.startedAt = createdAt.toISOString(); + workerLifecycle.session.lastTransitionAt = createdAt.toISOString(); + workerLifecycle.pr.state = "open"; + workerLifecycle.pr.reason = "review_pending"; + workerLifecycle.pr.number = 1; + workerLifecycle.pr.url = PR_URL; + workerLifecycle.pr.lastObservedAt = createdAt.toISOString(); + workerLifecycle.runtime.state = "alive"; + workerLifecycle.runtime.reason = "process_running"; + workerLifecycle.runtime.lastObservedAt = createdAt.toISOString(); + workerLifecycle.runtime.handle = { id: workerTmuxName, runtimeName: "tmux", data: {} }; + workerLifecycle.runtime.tmuxName = workerTmuxName; + + writeMetadata(sessionsDir, SESSION_ID, { + worktree: projectDir, + branch: "main", + status: deriveLegacyStatus(workerLifecycle), + lifecycle: workerLifecycle, + project: PROJECT_ID, + pr: PR_URL, + displayName: "E2E todo review target", + agent: "codex", + createdAt: createdAt.toISOString(), + tmuxName: workerTmuxName, + runtimeHandle: { id: workerTmuxName, runtimeName: "tmux", data: {} }, + }); + + const orchestratorLifecycle = createInitialCanonicalLifecycle("orchestrator", createdAt); + orchestratorLifecycle.session.state = "working"; + orchestratorLifecycle.session.reason = "task_in_progress"; + orchestratorLifecycle.session.startedAt = createdAt.toISOString(); + orchestratorLifecycle.session.lastTransitionAt = createdAt.toISOString(); + orchestratorLifecycle.runtime.state = "alive"; + orchestratorLifecycle.runtime.reason = "process_running"; + orchestratorLifecycle.runtime.lastObservedAt = createdAt.toISOString(); + orchestratorLifecycle.runtime.handle = { + id: orchestratorTmuxName, + runtimeName: "tmux", + data: {}, + }; + orchestratorLifecycle.runtime.tmuxName = orchestratorTmuxName; + + writeMetadata(sessionsDir, ORCHESTRATOR_ID, { + worktree: join(rootDir, "orchestrator-worktree"), + branch: "orchestrator/todo-orchestrator", + status: deriveLegacyStatus(orchestratorLifecycle), + lifecycle: orchestratorLifecycle, + role: "orchestrator", + project: PROJECT_ID, + displayName: "# Todo App Orchestrator", + agent: "codex", + createdAt: createdAt.toISOString(), + tmuxName: orchestratorTmuxName, + runtimeHandle: { id: orchestratorTmuxName, runtimeName: "tmux", data: {} }, + }); + + const store = createCodeReviewStore(PROJECT_ID); + store.deleteAll(); + store.createRun({ + linkedSessionId: SESSION_ID, + reviewerSessionId: "todo-rev-failed", + status: "failed", + prNumber: 1, + prUrl: PR_URL, + summary: "Seeded failed run for retry coverage.", + }); + + return { + rootDir, + homeDir, + projectDir, + globalConfigPath, + localConfigPath, + tmuxSessionPrefix, + tmuxSessions: [workerTmuxName, orchestratorTmuxName], + }; +} + +function reviewCard(page: Page, reviewerSessionId: string): Locator { + return page.locator(`[data-reviewer-session-id="${reviewerSessionId}"]`); +} + +function projectAoDir(fixture: Fixture): string { + return join(fixture.homeDir, ".agent-orchestrator", "projects", PROJECT_ID); +} + +function runAoCli(fixture: Fixture, args: string[]): string { + return execFileSync("pnpm", ["--dir", join(REPO_ROOT, "packages/cli"), "dev", ...args], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: fixture.homeDir, + AO_GLOBAL_CONFIG: fixture.globalConfigPath, + AO_CONFIG_PATH: fixture.localConfigPath, + AO_NO_UPDATE_NOTIFIER: "1", + AGENT_ORCHESTRATOR_CI: "1", + }, + }); +} + +function runAoCliAsync(fixture: Fixture, args: string[]): Promise { + return new Promise((resolveRun, rejectRun) => { + const child = spawn("pnpm", ["--dir", join(REPO_ROOT, "packages/cli"), "dev", ...args], { + cwd: REPO_ROOT, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + HOME: fixture.homeDir, + AO_GLOBAL_CONFIG: fixture.globalConfigPath, + AO_CONFIG_PATH: fixture.localConfigPath, + AO_NO_UPDATE_NOTIFIER: "1", + AGENT_ORCHESTRATOR_CI: "1", + }, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once("error", rejectRun); + child.once("exit", (code) => { + if (code === 0) { + resolveRun(stdout); + return; + } + rejectRun(new Error(`ao ${args.join(" ")} failed with ${code}\n${stdout}\n${stderr}`)); + }); + }); +} + +function orchestratorReviewRun(fixture: Fixture, args: string[]): unknown { + return parseJsonCommandOutput(runAoCli(fixture, ["review", "run", SESSION_ID, ...args])); +} + +function orchestratorReviewExecute(fixture: Fixture, args: string[]): unknown { + return parseJsonCommandOutput(runAoCli(fixture, ["review", "execute", PROJECT_ID, ...args])); +} + +async function orchestratorReviewExecuteAsync( + fixture: Fixture, + args: string[], +): Promise { + return parseJsonCommandOutput( + await runAoCliAsync(fixture, ["review", "execute", PROJECT_ID, ...args]), + ); +} + +function parseJsonCommandOutput(output: string): unknown { + const lines = output.split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index--) { + const line = lines[index]?.trim(); + if (!line || (!line.startsWith("{") && !line.startsWith("["))) continue; + try { + return JSON.parse(lines.slice(index).join("\n")); + } catch { + // Keep scanning for the actual JSON payload. pnpm can echo JSON-looking command args. + } + } + + throw new Error(`Expected JSON command output, received: ${output}`); +} + +async function expectVisible(locator: Locator, label: string): Promise { + try { + await locator.waitFor({ state: "visible", timeout: 15_000 }); + } catch (error) { + throw new Error( + `Expected visible: ${label}\n${error instanceof Error ? error.message : error}`, + { cause: error }, + ); + } +} + +async function clickWorkspaceMode(page: Page, name: "Coding" | "Reviews"): Promise { + await page + .getByRole("navigation", { name: "Workspace mode" }) + .getByRole("link", { name, exact: true }) + .click(); +} + +async function step(name: string, run: () => Promise): Promise { + process.stdout.write(`\n- ${name}\n`); + await run(); +} + +async function main(): Promise { + const fixture = createFixture(); + installFixtureSignalCleanup(fixture); + const server = await startWebServer(fixture); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + try { + await step("enter the project through its orchestrator", async () => { + await page.goto(`${server.baseUrl}/projects/${PROJECT_ID}/sessions/${ORCHESTRATOR_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible(page.getByText("# Todo App Orchestrator").first(), "orchestrator title"); + }); + + await step("navigate between coding and review modes from the shared header", async () => { + await page.goto(`${server.baseUrl}/projects/${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + const nav = page.getByRole("navigation", { name: "Workspace mode" }); + await expectVisible(nav.getByRole("link", { name: "Coding", exact: true }), "Coding tab"); + assert.equal( + await nav.getByRole("link", { name: "Coding", exact: true }).getAttribute("aria-current"), + "page", + ); + + await clickWorkspaceMode(page, "Reviews"); + await page.waitForURL(`**/review?project=${PROJECT_ID}`); + const reviewNav = page.getByRole("navigation", { name: "Workspace mode" }); + assert.equal( + await reviewNav + .getByRole("link", { name: "Reviews", exact: true }) + .getAttribute("aria-current"), + "page", + ); + assert.equal( + await reviewNav.getByRole("link", { name: "Coding", exact: true }).getAttribute("href"), + `/projects/${PROJECT_ID}`, + ); + }); + + await step("confirm coding and review modes use the same project orchestrator", async () => { + await page.goto(`${server.baseUrl}/projects/${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + const codingOrchestrator = page.getByRole("link", { name: "Orchestrator" }).first(); + await expectVisible(codingOrchestrator, "coding orchestrator link"); + const codingHref = await codingOrchestrator.getAttribute("href"); + assert.equal(codingHref, `/projects/${PROJECT_ID}/sessions/${ORCHESTRATOR_ID}`); + + await clickWorkspaceMode(page, "Reviews"); + await page.waitForURL(`**/review?project=${PROJECT_ID}`); + const reviewOrchestrator = page.getByRole("link", { name: "Open project orchestrator" }); + await expectVisible(reviewOrchestrator, "review orchestrator link"); + assert.equal(await reviewOrchestrator.getAttribute("href"), codingHref); + assert.equal( + await page.getByRole("button", { name: "Spawn Orchestrator" }).count(), + 0, + "review board should reuse the existing orchestrator instead of spawning another", + ); + }); + + await step("orchestrator requests the first review", async () => { + const requested = orchestratorReviewRun(fixture, [ + "--summary", + "Orchestrator requested initial E2E review", + "--json", + ]) as { run: { reviewerSessionId: string; status: string } }; + assert.equal(requested.run.status, "queued"); + assert.equal(requested.run.reviewerSessionId, "todo-rev-1"); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await page.waitForURL(`**/review?project=${PROJECT_ID}`); + await expectVisible(reviewCard(page, "todo-rev-1"), "todo-rev-1 queued review card"); + await expectVisible(reviewCard(page, "todo-rev-failed"), "seeded failed retry card"); + }); + + await step("orchestrator requests another review", async () => { + const requested = orchestratorReviewRun(fixture, [ + "--summary", + "Orchestrator requested parallel E2E review", + "--json", + ]) as { run: { reviewerSessionId: string; status: string } }; + assert.equal(requested.run.status, "queued"); + assert.equal(requested.run.reviewerSessionId, "todo-rev-2"); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible(reviewCard(page, "todo-rev-2"), "todo-rev-2 queued review card"); + }); + + await step("orchestrator runs two queued reviewer runs concurrently", async () => { + const startedAt = Date.now(); + const firstExecution = orchestratorReviewExecuteAsync(fixture, [ + "--run", + "todo-rev-1", + "--command", + buildFindingReviewCommand(8_000), + "--json", + ]); + const secondExecution = orchestratorReviewExecuteAsync(fixture, [ + "--run", + "todo-rev-2", + "--command", + buildFindingReviewCommand(8_000), + "--json", + ]); + + const [firstResult, secondResult] = (await Promise.all([ + firstExecution, + secondExecution, + ])) as [ + { run: { reviewerSessionId: string; status: string } }, + { run: { reviewerSessionId: string; status: string } }, + ]; + assert.equal(firstResult.run.status, "needs_triage"); + assert.equal(secondResult.run.status, "needs_triage"); + assert.ok( + Date.now() - startedAt < 14_000, + "reviewer executions should run concurrently, not serially", + ); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-1"][data-review-status="needs_triage"]'), + "first card in triage", + ); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-2"][data-review-status="needs_triage"]'), + "second card in triage", + ); + }); + + await step("confirm reviewer workspaces stay isolated from coding sessions", async () => { + const aoProjectDir = projectAoDir(fixture); + assert.equal( + existsSync(join(aoProjectDir, "code-reviews", "workspaces", "todo-rev-1")), + true, + "executed reviewer run should have a snapshot workspace", + ); + assert.equal( + existsSync(join(aoProjectDir, "sessions", "todo-rev-1.json")), + false, + "reviewer run must not create coding session metadata", + ); + await expectVisible( + reviewCard(page, "todo-rev-1").getByRole("link", { name: "Worker" }), + "worker link still points at coding worker", + ); + }); + + await step("open findings details from a triage review card", async () => { + const first = reviewCard(page, "todo-rev-1"); + await first.getByRole("button", { name: "view" }).click(); + const dialog = page.getByRole("dialog", { name: /E2E todo review target/i }); + await expectVisible(dialog, "review details dialog"); + await expectVisible(dialog.getByText("E2E reviewer finding"), "finding title"); + await expectVisible(dialog.getByText("README.md:1"), "finding location"); + await expectVisible( + dialog.getByText("The e2e review command created this finding."), + "finding body", + ); + await expectVisible(dialog.getByRole("link", { name: "Open terminal" }), "terminal link"); + await dialog.getByLabel("Close review details").click(); + }); + + await step("review board sends review findings back to the linked worker", async () => { + await reviewCard(page, "todo-rev-1").getByRole("button", { name: "Feedback" }).click(); + await page.waitForURL( + `**/projects/${PROJECT_ID}/sessions/${SESSION_ID}#session-terminal-section`, + ); + + await waitForTmuxText( + fixture.tmuxSessions[0] ?? "", + /E2E reviewer finding/, + "worker receives AO-local review finding", + ); + + const store = createCodeReviewStore(PROJECT_ID); + const sentRun = store + .listRunSummaries() + .find((run) => run.reviewerSessionId === "todo-rev-1"); + assert.ok(sentRun, "sent review run should still exist"); + assert.equal(sentRun.status, "waiting_update"); + assert.equal(sentRun.openFindingCount, 0); + assert.equal(sentRun.sentFindingCount, 1); + + const sentFindings = store.listFindings({ runId: sentRun.id }); + assert.equal(sentFindings.length, 1); + assert.equal(sentFindings[0]?.status, "sent_to_agent"); + assert.ok(sentFindings[0]?.sentToAgentAt, "sent finding should record handoff time"); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + const sentCard = reviewCard(page, "todo-rev-1"); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-1"][data-review-status="waiting_update"]'), + "sent review moved to waiting", + ); + await expectVisible( + sentCard.getByText(/waiting update · 1 finding · 1 sent/i), + "sent truth line", + ); + assert.equal( + await sentCard.getByRole("button", { name: /open finding/i }).count(), + 0, + "sent findings should not still be counted as open", + ); + }); + + await step("jump from review card back to the linked coding worker", async () => { + await reviewCard(page, "todo-rev-1").getByRole("link", { name: "Worker" }).click(); + await page.waitForURL(`**/projects/${PROJECT_ID}?session=${SESSION_ID}`); + assert.equal(new URL(page.url()).searchParams.get("session"), SESSION_ID); + }); + + await step("orchestrator marks older review runs outdated after a new worker commit", async () => { + writeFileSync(join(fixture.projectDir, "todo.txt"), "new worker commit\n"); + git(fixture.projectDir, ["add", "todo.txt"]); + git(fixture.projectDir, ["commit", "-m", "worker update"]); + + const requested = orchestratorReviewRun(fixture, [ + "--summary", + "Orchestrator requested review after worker update", + "--json", + ]) as { run: { reviewerSessionId: string; status: string } }; + assert.equal(requested.run.status, "queued"); + assert.equal(requested.run.reviewerSessionId, "todo-rev-3"); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible(reviewCard(page, "todo-rev-3"), "todo-rev-3 queued review card"); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-1"][data-review-status="outdated"]'), + "older triage review marked outdated", + ); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-2"][data-review-status="outdated"]'), + "second older triage review marked outdated", + ); + }); + + await step("orchestrator runs a clean review and the UI observes it", async () => { + const requested = orchestratorReviewRun(fixture, [ + "--summary", + "Orchestrator requested clean review", + "--json", + ]) as { run: { id: string; reviewerSessionId: string; status: string } }; + assert.equal(requested.run.status, "queued"); + + const executed = orchestratorReviewExecute(fixture, [ + "--run", + requested.run.reviewerSessionId, + "--command", + buildStaticReviewCommand([]), + "--json", + ]) as { run: { reviewerSessionId: string; status: string; findingCount: number } }; + assert.equal(executed.run.status, "clean"); + assert.equal(executed.run.findingCount, 0); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + const cleanCard = reviewCard(page, requested.run.reviewerSessionId); + await expectVisible( + cleanCard.locator('[data-review-status="clean"]').or(cleanCard), + "CLI clean review card", + ); + await expectVisible( + page.locator( + `[data-reviewer-session-id="${requested.run.reviewerSessionId}"][data-review-status="clean"]`, + ), + "CLI clean review in clean column", + ); + await expectVisible(cleanCard.getByText(/clean · 0 findings/i), "clean truth line"); + await cleanCard.getByRole("button", { name: "details" }).click(); + const dialog = page.getByRole("dialog", { name: /E2E todo review target/i }); + await expectVisible(dialog.getByText("No findings captured for this run."), "clean details"); + await dialog.getByLabel("Close review details").click(); + }); + + await step("orchestrator retries a failed reviewer run", async () => { + const failed = reviewCard(page, "todo-rev-failed"); + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible(failed, "failed review card"); + const retryPayload = orchestratorReviewExecute(fixture, [ + "--run", + "todo-rev-failed", + "--force", + "--command", + buildFindingReviewCommand(0), + "--json", + ]) as { run: { status?: string; terminationReason?: string } }; + assert.notEqual( + retryPayload.run.status, + "failed", + retryPayload.run.terminationReason ?? "retry should not fail", + ); + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible( + page.locator( + '[data-reviewer-session-id="todo-rev-failed"][data-review-status="needs_triage"]', + ), + "failed card moved to triage after retry", + ); + }); + + await step("confirm review cards expose worker and orchestrator affordances", async () => { + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "domcontentloaded", + }); + const retried = reviewCard(page, "todo-rev-failed"); + await expectVisible(retried.getByRole("button", { name: "Feedback" }), "feedback button"); + const orchestrator = page.getByRole("link", { name: "Open project orchestrator" }); + await expectVisible(orchestrator, "project orchestrator link"); + assert.equal( + await orchestrator.getAttribute("href"), + `/projects/${PROJECT_ID}/sessions/${ORCHESTRATOR_ID}`, + ); + }); + + process.stdout.write("\nReview board e2e flows passed.\n"); + } finally { + try { + await browser.close(); + } catch { + // Best effort cleanup; tmux sessions and fixture files still need cleanup. + } + try { + await server.stop(); + } catch { + // Best effort cleanup; tmux sessions and fixture files still need cleanup. + } + killReviewBoardTmuxSessions(fixture); + if (process.env["AO_E2E_KEEP_ARTIFACTS"] !== "1") { + rmSync(fixture.rootDir, { recursive: true, force: true }); + } else { + process.stdout.write(`\nKept e2e fixture at ${fixture.rootDir}\n`); + } + } +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/web/package.json b/packages/web/package.json index 83a59ace5..191e07ee9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -36,6 +36,7 @@ "dev:optimized": "rimraf .next dist-server && next build && tsc -p tsconfig.server.json && node dist-server/start-all.js", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:e2e:review": "tsx e2e/review-board.e2e.ts", "test:watch": "vitest", "clean": "node scripts/guard-production-artifact-clean.mjs && rimraf .next dist-server", "screenshot": "tsx e2e/screenshot.ts", diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 0a32bf0dc..3bb2eb496 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -674,29 +674,18 @@ describe("API Routes", () => { const enrichSpy = vi .spyOn(serialize, "enrichSessionPR") - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); expect(res.status).toBe(200); - expect(enrichSpy).toHaveBeenCalledTimes(3); + expect(enrichSpy).toHaveBeenCalledTimes(2); expect(enrichSpy.mock.calls[0]).toEqual([ expect.objectContaining({ id: "worker-live" }), - expect.anything(), - sessionsWithPRs[0]!.pr, ]); expect(enrichSpy.mock.calls[1]).toEqual([ expect.objectContaining({ id: "worker-killed" }), - expect.anything(), - sessionsWithPRs[1]!.pr, - { cacheOnly: true }, - ]); - expect(enrichSpy.mock.calls[2]).toEqual([ - expect.objectContaining({ id: "worker-killed" }), - expect.anything(), - sessionsWithPRs[1]!.pr, ]); metadataSpy.mockRestore(); @@ -751,8 +740,6 @@ describe("API Routes", () => { expect(enrichSpy).toHaveBeenCalledTimes(1); expect(enrichSpy.mock.calls[0]).toEqual([ expect.objectContaining({ id: "worker-open-pr" }), - expect.anything(), - sessionWithOpenPR[0]!.pr, ]); metadataSpy.mockRestore(); diff --git a/packages/web/src/__tests__/project-detail-route.test.ts b/packages/web/src/__tests__/project-detail-route.test.ts index 297917714..3f9fd67c0 100644 --- a/packages/web/src/__tests__/project-detail-route.test.ts +++ b/packages/web/src/__tests__/project-detail-route.test.ts @@ -458,6 +458,37 @@ describe("/api/projects/[id]", () => { expect(readFileSync(path.join(repoDir, "agent-orchestrator.yaml"), "utf-8")).toContain("agent: codex"); }); + it("POST repair preserves wrapped defaults so the project can start with its intended agent", async () => { + const repoDir = path.join(tempRoot, "broken-defaults"); + mkdirSync(repoDir, { recursive: true }); + writeFileSync( + path.join(repoDir, "agent-orchestrator.yaml"), + [ + "defaults:", + " agent: codex", + " runtime: tmux", + " workspace: worktree", + "projects:", + " broken-defaults:", + ` path: ${repoDir}`, + " name: Broken Defaults", + "", + ].join("\n"), + ); + const effectiveId = registerProjectInGlobalConfig("broken-defaults", "Broken Defaults", repoDir); + + const { POST } = await import("@/app/api/projects/[id]/route"); + const response = await POST(makeRequest("POST", undefined, effectiveId), { + params: Promise.resolve({ id: effectiveId }), + }); + + expect(response.status).toBe(200); + const localYaml = readFileSync(path.join(repoDir, "agent-orchestrator.yaml"), "utf-8"); + expect(localYaml).toContain("agent: codex"); + expect(localYaml).toContain("runtime: tmux"); + expect(localYaml).toContain("workspace: worktree"); + }); + it("POST repairs wrapped local .yml configs in place", async () => { const repoDir = path.join(tempRoot, "broken-yml"); mkdirSync(repoDir, { recursive: true }); diff --git a/packages/web/src/__tests__/review-api.test.ts b/packages/web/src/__tests__/review-api.test.ts new file mode 100644 index 000000000..1852ee1e2 --- /dev/null +++ b/packages/web/src/__tests__/review-api.test.ts @@ -0,0 +1,328 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createActivitySignal, + createInitialCanonicalLifecycle, + createCodeReviewStore, + type OrchestratorConfig, + type Session, + type SessionManager, +} from "@aoagents/ao-core"; + +const { mockConfig, mockSessionManager } = vi.hoisted(() => ({ + mockConfig: { + configPath: "/tmp/ao/agent-orchestrator.yaml", + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "codex", workspace: "worktree", notifiers: [] }, + projects: { + app: { + name: "App", + path: "/tmp/app", + defaultBranch: "main", + sessionPrefix: "app", + }, + }, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, + } satisfies OrchestratorConfig, + mockSessionManager: { + get: vi.fn(), + list: vi.fn(), + spawn: vi.fn(), + spawnOrchestrator: vi.fn(), + ensureOrchestrator: vi.fn(), + relaunchOrchestrator: vi.fn(), + restore: vi.fn(), + kill: vi.fn(), + cleanup: vi.fn(), + send: vi.fn(), + claimPR: vi.fn(), + } satisfies SessionManager, +})); + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(async () => ({ + config: mockConfig, + sessionManager: mockSessionManager, + })), +})); + +function makeRequest(url: string, init?: RequestInit): NextRequest { + return new NextRequest( + new URL(url, "http://localhost:3000"), + init as ConstructorParameters[1], + ); +} + +function makeSession(overrides: Partial = {}): Session { + const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2026-05-10T10:00:00.000Z")); + lifecycle.session.state = "idle"; + lifecycle.session.reason = "awaiting_external_review"; + lifecycle.pr.state = "open"; + lifecycle.pr.reason = "review_pending"; + lifecycle.pr.number = 7; + lifecycle.pr.url = "https://github.com/acme/app/pull/7"; + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + + return { + id: "app-1", + projectId: "app", + status: "review_pending", + activity: "idle", + activitySignal: createActivitySignal("valid", { + activity: "idle", + timestamp: new Date("2026-05-10T10:00:00.000Z"), + source: "native", + }), + lifecycle, + branch: "feat/todos", + issueId: null, + pr: { + number: 7, + url: "https://github.com/acme/app/pull/7", + title: "feat: todos", + owner: "acme", + repo: "app", + branch: "feat/todos", + baseBranch: "main", + isDraft: false, + }, + workspacePath: null, + runtimeHandle: { id: "tmux-app-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date("2026-05-10T09:00:00.000Z"), + lastActivityAt: new Date("2026-05-10T10:00:00.000Z"), + metadata: {}, + ...overrides, + }; +} + +import { POST } from "@/app/api/reviews/route"; +import { POST as POST_EXECUTE } from "@/app/api/reviews/execute/route"; +import { GET as GET_FINDINGS } from "@/app/api/reviews/findings/route"; +import { POST as POST_SEND } from "@/app/api/reviews/send/route"; + +let tmpHome: string; +let originalHome: string | undefined; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "ao-web-review-api-")); + originalHome = process.env["HOME"]; + process.env["HOME"] = tmpHome; + createCodeReviewStore("app").deleteAll(); + mockSessionManager.get.mockReset(); + mockSessionManager.get.mockResolvedValue(makeSession()); +}); + +afterEach(() => { + if (originalHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = originalHome; + } + rmSync(tmpHome, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +describe("POST /api/reviews", () => { + it("requests a review run for a worker session", async () => { + const response = await POST( + makeRequest("/api/reviews", { + method: "POST", + body: JSON.stringify({ sessionId: "app-1" }), + }), + ); + + expect(response.status).toBe(201); + const payload = (await response.json()) as { + run: { linkedSessionId: string; reviewerSessionId: string; status: string }; + }; + expect(payload.run).toMatchObject({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + }); + expect(createCodeReviewStore("app").listRuns()).toHaveLength(1); + }); + + it("returns 400 when a session belongs to an unknown project", async () => { + mockSessionManager.get.mockResolvedValueOnce(makeSession({ projectId: "missing-project" })); + + const response = await POST( + makeRequest("/api/reviews", { + method: "POST", + body: JSON.stringify({ sessionId: "app-1" }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "Unknown project for session app-1: missing-project", + }); + }); + + it("returns 400 when a review is requested for an orchestrator session", async () => { + mockSessionManager.get.mockResolvedValueOnce( + makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), + ); + + const response = await POST( + makeRequest("/api/reviews", { + method: "POST", + body: JSON.stringify({ sessionId: "app-orchestrator" }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "Cannot request code review for orchestrator session: app-orchestrator", + }); + }); +}); + +describe("GET /api/reviews/findings", () => { + it("returns stored findings for a review run", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + }); + store.createFinding({ + runId: run.id, + linkedSessionId: "app-1", + severity: "warning", + title: "Missing empty state", + body: "The todo list should render a clear empty state.", + filePath: "src/App.tsx", + startLine: 12, + }); + + const response = await GET_FINDINGS( + makeRequest(`/api/reviews/findings?projectId=app&runId=${run.id}`), + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + run: { id: string }; + findings: Array<{ title: string; filePath: string }>; + }; + expect(payload.run.id).toBe(run.id); + expect(payload.findings).toEqual([ + expect.objectContaining({ + title: "Missing empty state", + filePath: "src/App.tsx", + }), + ]); + }); +}); + +describe("POST /api/reviews/send", () => { + it("sends open findings to the linked worker session", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + prNumber: 7, + }); + const finding = store.createFinding({ + runId: run.id, + linkedSessionId: "app-1", + severity: "warning", + title: "Missing empty state", + body: "The todo list should render a clear empty state.", + filePath: "src/App.tsx", + startLine: 12, + }); + + const response = await POST_SEND( + makeRequest("/api/reviews/send", { + method: "POST", + body: JSON.stringify({ projectId: "app", runId: run.id }), + }), + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + run: { status: string; sentFindingCount: number; openFindingCount: number }; + sentFindingCount: number; + message: string; + }; + expect(payload.sentFindingCount).toBe(1); + expect(payload.run).toMatchObject({ + status: "waiting_update", + sentFindingCount: 1, + openFindingCount: 0, + }); + expect(payload.message).toContain("Missing empty state"); + expect(mockSessionManager.send).toHaveBeenCalledWith( + "app-1", + expect.stringContaining("Missing empty state"), + ); + expect(store.getFinding(finding.id)).toMatchObject({ status: "sent_to_agent" }); + }); + + it("returns 409 when there are no open findings to send", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "clean", + }); + + const response = await POST_SEND( + makeRequest("/api/reviews/send", { + method: "POST", + body: JSON.stringify({ projectId: "app", runId: run.id }), + }), + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "No open review findings to send for app-rev-1.", + }); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/reviews/execute", () => { + it("returns 404 when the review run does not exist", async () => { + const response = await POST_EXECUTE( + makeRequest("/api/reviews/execute", { + method: "POST", + body: JSON.stringify({ projectId: "app", runId: "review-run-missing" }), + }), + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: "Code review run not found: review-run-missing", + }); + }); + + it("returns 409 when the review run is not executable", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "running", + }); + + const response = await POST_EXECUTE( + makeRequest("/api/reviews/execute", { + method: "POST", + body: JSON.stringify({ projectId: "app", runId: run.id }), + }), + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "Code review run app-rev-1 is running, not queued", + }); + }); +}); diff --git a/packages/web/src/app/api/reviews/execute/route.ts b/packages/web/src/app/api/reviews/execute/route.ts new file mode 100644 index 000000000..df9c7cfd0 --- /dev/null +++ b/packages/web/src/app/api/reviews/execute/route.ts @@ -0,0 +1,63 @@ +import { + CodeReviewRunNotExecutableError, + CodeReviewRunNotFoundError, + createShellCodeReviewRunner, + executeCodeReviewRun, + SessionNotFoundError, +} from "@aoagents/ao-core"; +import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability"; +import { getServices } from "@/lib/services"; +import { validateConfiguredProject, validateIdentifier } from "@/lib/validation"; + +export async function POST(request: Request) { + const correlationId = getCorrelationId(request); + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) { + return jsonWithCorrelation({ error: "Invalid JSON body" }, { status: 400 }, correlationId); + } + + const projectIdErr = validateIdentifier(body.projectId, "projectId"); + if (projectIdErr) { + return jsonWithCorrelation({ error: projectIdErr }, { status: 400 }, correlationId); + } + + const runIdErr = validateIdentifier(body.runId, "runId"); + if (runIdErr) { + return jsonWithCorrelation({ error: runIdErr }, { status: 400 }, correlationId); + } + + try { + const { config, sessionManager } = await getServices(); + const projectId = String(body.projectId); + const configuredProjectErr = validateConfiguredProject(config.projects, projectId); + if (configuredProjectErr) { + return jsonWithCorrelation({ error: configuredProjectErr }, { status: 404 }, correlationId); + } + + const command = process.env["AO_CODE_REVIEW_COMMAND"]; + const run = await executeCodeReviewRun( + { + config, + sessionManager, + force: body.force === true, + ...(command ? { runReviewer: createShellCodeReviewRunner(command) } : {}), + }, + { projectId, runId: String(body.runId) }, + ); + + return jsonWithCorrelation({ run }, { status: 200 }, correlationId); + } catch (error) { + if (error instanceof SessionNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewRunNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewRunNotExecutableError) { + return jsonWithCorrelation({ error: error.message }, { status: 409 }, correlationId); + } + + const message = error instanceof Error ? error.message : "Failed to execute review"; + return jsonWithCorrelation({ error: message }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/app/api/reviews/findings/route.ts b/packages/web/src/app/api/reviews/findings/route.ts new file mode 100644 index 000000000..0d0e1050f --- /dev/null +++ b/packages/web/src/app/api/reviews/findings/route.ts @@ -0,0 +1,53 @@ +import { createCodeReviewStore } from "@aoagents/ao-core"; +import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability"; +import { getServices } from "@/lib/services"; +import { validateConfiguredProject, validateIdentifier } from "@/lib/validation"; + +export async function GET(request: Request) { + const correlationId = getCorrelationId(request); + const { searchParams } = new URL(request.url); + const projectId = searchParams.get("projectId"); + const runId = searchParams.get("runId"); + + const projectIdErr = validateIdentifier(projectId, "projectId"); + if (projectIdErr) { + return jsonWithCorrelation({ error: projectIdErr }, { status: 400 }, correlationId); + } + + const runIdErr = validateIdentifier(runId, "runId"); + if (runIdErr) { + return jsonWithCorrelation({ error: runIdErr }, { status: 400 }, correlationId); + } + + try { + const { config } = await getServices(); + const safeProjectId = String(projectId); + const safeRunId = String(runId); + const configuredProjectErr = validateConfiguredProject(config.projects, safeProjectId); + if (configuredProjectErr) { + return jsonWithCorrelation({ error: configuredProjectErr }, { status: 404 }, correlationId); + } + + const store = createCodeReviewStore(safeProjectId); + const run = store.getRun(safeRunId); + if (!run) { + return jsonWithCorrelation( + { error: `Review run not found: ${safeRunId}` }, + { status: 404 }, + correlationId, + ); + } + + return jsonWithCorrelation( + { + run, + findings: store.listFindings({ runId: safeRunId }), + }, + { status: 200 }, + correlationId, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load review findings"; + return jsonWithCorrelation({ error: message }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/app/api/reviews/route.ts b/packages/web/src/app/api/reviews/route.ts new file mode 100644 index 000000000..e5592be41 --- /dev/null +++ b/packages/web/src/app/api/reviews/route.ts @@ -0,0 +1,87 @@ +import { + CodeReviewInvalidSessionError, + SessionNotFoundError, + triggerCodeReviewForSession, +} from "@aoagents/ao-core"; +import { getReviewPageData, resolveReviewProjectFilter } from "@/lib/review-page-data"; +import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability"; +import { getServices } from "@/lib/services"; +import { stripControlChars, validateIdentifier, validateString } from "@/lib/validation"; + +const MAX_REVIEW_SUMMARY_LENGTH = 2_000; + +export async function GET(request: Request) { + const correlationId = getCorrelationId(request); + const { searchParams } = new URL(request.url); + const projectFilter = resolveReviewProjectFilter(searchParams.get("project") ?? undefined); + const pageData = await getReviewPageData(projectFilter); + + if (pageData.dashboardLoadError) { + return jsonWithCorrelation( + { + error: pageData.dashboardLoadError, + runs: pageData.runs, + }, + { status: 500 }, + correlationId, + ); + } + + return jsonWithCorrelation( + { + runs: pageData.runs, + workerOptions: pageData.workerOptions, + orchestrators: pageData.orchestrators, + projectName: pageData.projectName, + selectedProjectId: pageData.selectedProjectId ?? null, + }, + { status: 200 }, + correlationId, + ); +} + +export async function POST(request: Request) { + const correlationId = getCorrelationId(request); + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) { + return jsonWithCorrelation({ error: "Invalid JSON body" }, { status: 400 }, correlationId); + } + + const sessionIdErr = validateIdentifier(body.sessionId, "sessionId"); + if (sessionIdErr) { + return jsonWithCorrelation({ error: sessionIdErr }, { status: 400 }, correlationId); + } + + let summary: string | undefined; + if (body.summary !== undefined) { + const summaryErr = validateString(body.summary, "summary", MAX_REVIEW_SUMMARY_LENGTH); + if (summaryErr) { + return jsonWithCorrelation({ error: summaryErr }, { status: 400 }, correlationId); + } + summary = stripControlChars(String(body.summary)); + } + + try { + const { config, sessionManager } = await getServices(); + const run = await triggerCodeReviewForSession( + { config, sessionManager }, + { + sessionId: String(body.sessionId), + requestedBy: "web", + summary, + }, + ); + + return jsonWithCorrelation({ run }, { status: 201 }, correlationId); + } catch (error) { + if (error instanceof SessionNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewInvalidSessionError) { + return jsonWithCorrelation({ error: error.message }, { status: 400 }, correlationId); + } + + const message = error instanceof Error ? error.message : "Failed to request review"; + return jsonWithCorrelation({ error: message }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/app/api/reviews/send/route.ts b/packages/web/src/app/api/reviews/send/route.ts new file mode 100644 index 000000000..5d87f1cce --- /dev/null +++ b/packages/web/src/app/api/reviews/send/route.ts @@ -0,0 +1,56 @@ +import { + CodeReviewNoOpenFindingsError, + CodeReviewRunNotFoundError, + sendCodeReviewFindingsToAgent, + SessionNotFoundError, +} from "@aoagents/ao-core"; +import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability"; +import { getServices } from "@/lib/services"; +import { validateConfiguredProject, validateIdentifier } from "@/lib/validation"; + +export async function POST(request: Request) { + const correlationId = getCorrelationId(request); + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) { + return jsonWithCorrelation({ error: "Invalid JSON body" }, { status: 400 }, correlationId); + } + + const projectIdErr = validateIdentifier(body.projectId, "projectId"); + if (projectIdErr) { + return jsonWithCorrelation({ error: projectIdErr }, { status: 400 }, correlationId); + } + + const runIdErr = validateIdentifier(body.runId, "runId"); + if (runIdErr) { + return jsonWithCorrelation({ error: runIdErr }, { status: 400 }, correlationId); + } + + try { + const { config, sessionManager } = await getServices(); + const projectId = String(body.projectId); + const configuredProjectErr = validateConfiguredProject(config.projects, projectId); + if (configuredProjectErr) { + return jsonWithCorrelation({ error: configuredProjectErr }, { status: 404 }, correlationId); + } + + const result = await sendCodeReviewFindingsToAgent( + { config, sessionManager }, + { projectId, runId: String(body.runId) }, + ); + + return jsonWithCorrelation(result, { status: 200 }, correlationId); + } catch (error) { + if (error instanceof SessionNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewRunNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewNoOpenFindingsError) { + return jsonWithCorrelation({ error: error.message }, { status: 409 }, correlationId); + } + + const message = error instanceof Error ? error.message : "Failed to send review findings"; + return jsonWithCorrelation({ error: message }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 539740f16..c9645bafd 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -10,7 +10,7 @@ import { import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; import { filterProjectSessions } from "@/lib/project-utils"; import { settlesWithin } from "@/lib/async-utils"; -import { isDashboardSessionTerminal, type DashboardOrchestratorLink } from "@/lib/types"; +import { type DashboardOrchestratorLink } from "@/lib/types"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; @@ -136,8 +136,8 @@ export async function GET(request: Request) { let dashboardSessions = workerSessions.map(sessionToDashboard); if (activeOnly) { - const activeIndices = dashboardSessions - .map((session, index) => (!isDashboardSessionTerminal(session) ? index : -1)) + const activeIndices = workerSessions + .map((session, index) => (!isTerminalSession(session) ? index : -1)) .filter((index) => index !== -1); workerSessions = activeIndices.map((index) => workerSessions[index]); dashboardSessions = activeIndices.map((index) => dashboardSessions[index]); diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index e47aa6308..1ae049ef9 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1219,6 +1219,41 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { gap: 6px; } +.workspace-mode-switch { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 8px; + border: 1px solid var(--color-border-subtle); + border-radius: 5px; + background: var(--color-bg-primary); + padding: 2px; +} + +.workspace-mode-switch__item { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 3px; + color: var(--color-text-muted); + text-decoration: none; + font-size: 11px; + font-weight: 500; +} + +.workspace-mode-switch__item:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + text-decoration: none; +} + +.workspace-mode-switch__item--active { + background: var(--color-bg-surface); + color: var(--color-text-primary); + box-shadow: inset 0 0 0 1px var(--color-border-subtle); +} + .dashboard-app-btn { display: inline-flex; align-items: center; @@ -2954,6 +2989,13 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 5px 10px 7px; } +.session-card__footer-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + .card__status { flex: 1; min-width: 0; @@ -3031,6 +3073,27 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { color: var(--color-accent-amber); } +.session-card__review-control { + font-size: 10.5px; + font-family: var(--font-sans); + font-weight: 600; + padding: 2px 8px 2px 6px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); + background: color-mix(in srgb, var(--color-accent) 5%, transparent); + color: var(--color-accent); +} + +.session-card__review-control:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + border-color: color-mix(in srgb, var(--color-accent) 36%, transparent); +} + +.session-card__review-control:disabled { + cursor: wait; + opacity: 0.72; +} + .btn--danger { width: 26px; height: 26px; @@ -5253,6 +5316,491 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { border-radius: 0; } +/* ── Review workbench ─────────────────────────────────────────────── */ + +.review-dashboard-main { + overflow-y: auto; + padding: 18px 18px 20px; +} + +.review-main-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 16px; + border-bottom: 1px solid var(--color-border-subtle); + padding-bottom: 14px; +} + +.review-kanban-board { + grid-template-columns: repeat(var(--kanban-column-count), minmax(250px, 1fr)); + min-width: 1750px; +} + +.review-kanban-column { + min-height: 420px; +} + +.review-column-hint { + margin-top: 5px; + min-height: 28px; + font-size: 10.5px; + line-height: 1.35; + color: var(--color-text-tertiary); +} + +.review-new-menu { + position: relative; +} + +.review-new-menu__popover { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 30; + width: min(320px, calc(100vw - 48px)); + max-height: 360px; + overflow-y: auto; + border: 1px solid var(--color-border-default); + border-radius: 6px; + background: var(--color-bg-surface); + box-shadow: 0 16px 36px rgb(0 0 0 / 0.26); + padding: 5px; +} + +.review-new-menu__item { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + gap: 3px; + border-radius: 4px; + padding: 8px; + text-align: left; + color: var(--color-text-secondary); +} + +.review-new-menu__item:hover:not(:disabled) { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.review-new-menu__item:disabled { + cursor: wait; + opacity: 0.65; +} + +.review-new-menu__item-title, +.review-new-menu__item-meta { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-new-menu__item-title { + font-size: 12px; + font-weight: 600; +} + +.review-new-menu__item-meta { + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-text-muted); +} + +.review-column-dot[data-review-column="queued"] { + background: var(--color-text-tertiary); +} + +.review-column-dot[data-review-column="reviewing"] { + background: var(--color-status-review); +} + +.review-column-dot[data-review-column="triage"] { + background: var(--color-status-attention); +} + +.review-column-dot[data-review-column="waiting"] { + background: var(--color-accent); +} + +.review-column-dot[data-review-column="clean"] { + background: var(--color-status-ready); +} + +.review-column-dot[data-review-column="failed"] { + background: var(--color-status-error); +} + +.review-column-dot[data-review-column="outdated"] { + background: var(--color-text-muted); +} + +.review-card { + min-height: 166px; + border-left-color: var(--color-border-default); +} + +.review-card[data-review-status="running"] { + border-left-color: var(--color-status-review); +} + +.review-card[data-review-status="needs_triage"] { + border-left-color: var(--color-status-attention); +} + +.review-card[data-review-status="sent_to_agent"], +.review-card[data-review-status="waiting_update"] { + border-left-color: var(--color-accent); +} + +.review-card[data-review-status="clean"] { + border-left-color: var(--color-status-ready); +} + +.review-card[data-review-status="failed"], +.review-card[data-review-status="cancelled"] { + border-left-color: var(--color-status-error); +} + +.review-card[data-review-status="outdated"] { + border-left-color: var(--color-text-muted); + opacity: 0.78; +} + +.review-card .card__id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-card .card__alerts { + margin-top: auto; +} + +.review-card .session-card__footer { + margin-top: 0; +} + +.review-card .session-card__footer-actions { + gap: 5px; +} + +.review-card .session-card__footer-actions .session-card__control { + height: 24px; + padding-right: 7px; + padding-left: 7px; +} + +.review-card__disabled-control { + cursor: default; + opacity: 0.58; +} + +.review-card__disabled-control:hover { + border-color: var(--color-border-default); + background: var(--color-bg-elevated); + color: var(--color-text-secondary); +} + +.review-card__finding-alert .alert-row__text button { + display: block; + width: 100%; + min-width: 0; + overflow: hidden; + color: inherit; + font: inherit; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-card__finding-alert .alert-row__text button:hover { + color: var(--color-text-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.review-detail-backdrop { + position: fixed; + inset: 0; + z-index: 80; + background: rgba(0, 0, 0, 0.42); +} + +.review-detail-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 81; + display: flex; + width: min(440px, calc(100vw - 18px)); + flex-direction: column; + border-left: 1px solid var(--color-border-default); + background: var(--color-bg-elevated); + box-shadow: -18px 0 42px rgba(0, 0, 0, 0.28); +} + +.review-detail-panel__header { + display: flex; + align-items: flex-start; + gap: 16px; + justify-content: space-between; + border-bottom: 1px solid var(--color-border-subtle); + padding: 18px 18px 14px; +} + +.review-detail-panel__eyebrow { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.04em; + color: var(--color-text-muted); +} + +.review-detail-panel__title { + margin-top: 4px; + font-size: 16px; + font-weight: 600; + line-height: 1.35; + color: var(--color-text-primary); +} + +.review-detail-panel__close { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border-default); + border-radius: 4px; + color: var(--color-text-secondary); + font-size: 18px; + line-height: 1; +} + +.review-detail-panel__close:hover { + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.review-detail-panel__meta, +.review-detail-panel__actions, +.review-detail-panel__summary { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 12px 18px 0; +} + +.review-detail-panel__meta span, +.review-detail-panel__actions a { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border-subtle); + border-radius: 4px; + background: var(--color-bg-subtle); + padding: 3px 7px; + font-size: 10.5px; + color: var(--color-text-secondary); +} + +.review-detail-panel__actions a { + border-color: color-mix(in srgb, var(--color-accent) 22%, transparent); + background: color-mix(in srgb, var(--color-accent) 5%, transparent); + color: var(--color-accent); + font-weight: 600; + text-decoration: none; +} + +.review-detail-panel__actions a:hover { + border-color: color-mix(in srgb, var(--color-accent) 36%, transparent); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); +} + +.review-detail-panel__notice { + margin: 12px 18px 0; + border: 1px solid color-mix(in srgb, var(--color-status-attention) 30%, transparent); + border-radius: 5px; + background: color-mix(in srgb, var(--color-status-attention) 8%, transparent); + padding: 9px 10px; + color: var(--color-text-secondary); + font-size: 11.5px; + line-height: 1.45; +} + +.review-detail-panel__summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-top: 16px; +} + +.review-detail-panel__summary-item { + border: 1px solid var(--color-border-subtle); + border-radius: 5px; + background: var(--color-bg-subtle); + padding: 8px; +} + +.review-detail-panel__summary-item span { + display: block; + font-size: 9.5px; + letter-spacing: 0.05em; + color: var(--color-text-muted); + text-transform: uppercase; +} + +.review-detail-panel__summary-item strong { + display: block; + margin-top: 4px; + overflow: hidden; + color: var(--color-text-primary); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-detail-panel__content { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding: 16px 18px 18px; +} + +.review-detail-panel__empty, +.review-detail-panel__error { + border: 1px dashed var(--color-border-default); + border-radius: 6px; + padding: 18px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.45; +} + +.review-detail-panel__error { + border-color: color-mix(in srgb, var(--color-status-error) 35%, transparent); + color: var(--color-status-error); +} + +.review-detail-finding { + border: 1px solid var(--color-border-subtle); + border-left: 3px solid var(--color-text-muted); + border-radius: 6px; + background: var(--card-bg); + padding: 11px 12px 12px; +} + +.review-detail-finding[data-severity="warning"] { + border-left-color: var(--color-status-attention); +} + +.review-detail-finding[data-severity="error"] { + border-left-color: var(--color-status-error); +} + +.review-detail-finding[data-severity="info"] { + border-left-color: var(--color-accent); +} + +.review-detail-finding__header { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.review-detail-finding__header span { + border: 1px solid var(--color-border-subtle); + border-radius: 3px; + padding: 2px 6px; + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--color-text-muted); + text-transform: lowercase; +} + +.review-detail-finding h3 { + color: var(--color-text-primary); + font-size: 12.5px; + font-weight: 600; + line-height: 1.35; +} + +.review-detail-finding code { + display: inline-block; + max-width: 100%; + overflow: hidden; + margin-top: 7px; + border: 1px solid var(--color-border-subtle); + border-radius: 3px; + background: var(--color-bg-subtle); + padding: 2px 5px; + color: var(--color-text-secondary); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-detail-finding p { + margin-top: 9px; + color: var(--color-text-secondary); + font-size: 11.5px; + line-height: 1.5; +} + +.review-empty-state { + margin: 36px auto 0; + max-width: 460px; + border: 1px dashed var(--color-border-default); + border-radius: 7px; + padding: 28px; + text-align: center; + color: var(--color-text-secondary); +} + +.review-empty-state__title { + font-size: 15px; + font-weight: 600; + color: var(--color-text-primary); +} + +.review-empty-state__body { + margin-top: 7px; + font-size: 12px; + line-height: 1.55; + color: var(--color-text-muted); +} + +.review-empty-state__link { + margin-top: 14px; + display: inline-flex; + border: 1px solid var(--color-border-default); + border-radius: 5px; + padding: 7px 10px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + text-decoration: none; +} + +.review-empty-state__link:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + text-decoration: none; +} + +@media (max-width: 767px) { + .review-kanban-board { + min-width: 0; + } +} + /* ── Done / Terminated collapsible bar ────────────────────────────────── */ .done-bar__toggle { diff --git a/packages/web/src/app/review/page.tsx b/packages/web/src/app/review/page.tsx new file mode 100644 index 000000000..e1b7af9d6 --- /dev/null +++ b/packages/web/src/app/review/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { ReviewDashboard } from "@/components/ReviewDashboard"; +import { + getReviewPageData, + getReviewProjectName, + resolveReviewProjectFilter, +} from "@/lib/review-page-data"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(props: { + searchParams: Promise<{ project?: string }>; +}): Promise { + const searchParams = await props.searchParams; + const projectFilter = resolveReviewProjectFilter(searchParams.project); + const projectName = getReviewProjectName(projectFilter); + return { title: { absolute: `ao | ${projectName} Reviews` } }; +} + +export default async function ReviewRoute(props: { searchParams: Promise<{ project?: string }> }) { + const searchParams = await props.searchParams; + const projectFilter = resolveReviewProjectFilter(searchParams.project); + const pageData = await getReviewPageData(projectFilter); + + return ( + + ); +} diff --git a/packages/web/src/app/reviews/page.tsx b/packages/web/src/app/reviews/page.tsx new file mode 100644 index 000000000..090a5b64d --- /dev/null +++ b/packages/web/src/app/reviews/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default async function ReviewsAliasRoute(props: { + searchParams: Promise<{ project?: string }>; +}) { + const searchParams = await props.searchParams; + const suffix = searchParams.project ? `?project=${encodeURIComponent(searchParams.project)}` : ""; + redirect(`/review${suffix}`); +} diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index 6fa1990e9..b7e7b87d1 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -291,17 +291,12 @@ describe("SessionPage project polling", () => { const { default: SessionPage } = await import("./page"); - render( - - - , - ); + render(); await flushAsyncWork(); expect(screen.getByText("Session not found")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument(); expect(screen.queryByTestId("session-detail")).not.toBeInTheDocument(); - expect(screen.getByTestId("route-error")).toHaveTextContent("NEXT_NOT_FOUND"); }); it("renders an inline error state instead of throwing the route away", async () => { diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index e68d4ddaf..422bedd23 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -1,11 +1,7 @@ "use client"; import { memo, useEffect, useState } from "react"; -import { - type DashboardSession, - type AttentionLevel, - isPRMergeReady, -} from "@/lib/types"; +import { type DashboardSession, type AttentionLevel, isPRMergeReady } from "@/lib/types"; import { SessionCard } from "./SessionCard"; import { getSessionTitle } from "@/lib/format"; import { projectSessionPath } from "@/lib/routes"; @@ -17,6 +13,7 @@ interface AttentionZoneProps { onKill?: (sessionId: string) => void; onMerge?: (prNumber: number) => void; onRestore?: (sessionId: string) => void; + onReview?: (sessionId: string) => Promise | void; /** Accordion mode: whether this section is collapsed (mobile only) */ collapsed?: boolean; /** Accordion mode: called when the header is tapped to toggle */ @@ -82,6 +79,7 @@ function AttentionZoneView({ onKill, onMerge, onRestore, + onReview, collapsed, onToggle, compactMobile, @@ -121,7 +119,9 @@ function AttentionZoneView({ {config.label} {sessions.length} - +
@@ -143,6 +143,7 @@ function AttentionZoneView({ onKill={onKill} onMerge={onMerge} onRestore={onRestore} + onReview={onReview} /> ), )} @@ -187,6 +188,7 @@ function AttentionZoneView({ onKill={onKill} onMerge={onMerge} onRestore={onRestore} + onReview={onReview} /> ))}
@@ -205,6 +207,7 @@ function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZon prev.onKill === next.onKill && prev.onMerge === next.onMerge && prev.onRestore === next.onRestore && + prev.onReview === next.onReview && prev.compactMobile === next.compactMobile && prev.onPreview === next.onPreview && prev.resetKey === next.resetKey && @@ -239,11 +242,7 @@ function MobileSessionRow({ aria-label={`Open ${getSessionTitle(session)}`} >
-
@@ -253,7 +252,7 @@ function MobileSessionRow({
diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index ff646762a..1c9ee86f5 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -27,7 +27,7 @@ import { CopyDebugBundleButton } from "./CopyDebugBundleButton"; import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext"; import { ProjectSidebar } from "./ProjectSidebar"; import { isOrchestratorSession } from "@aoagents/ao-core/types"; -import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; +import { projectDashboardPath, projectReviewPath, projectSessionPath } from "@/lib/routes"; import { BottomSheet } from "./BottomSheet"; interface DashboardProps { @@ -244,6 +244,8 @@ function DashboardInner({ sessionsRef.current = sessions; const allProjectsView = projects.length > 1 && projectId === undefined; + const codingHref = projectId ? projectDashboardPath(projectId) : "/?project=all"; + const reviewHref = projectReviewPath(projectId); const currentProjectOrchestrator = useMemo( () => projectId @@ -463,6 +465,32 @@ function DashboardInner({ [showToast], ); + const handleRequestReview = useCallback( + async (sessionId: string) => { + try { + const res = await fetch("/api/reviews", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + const data = (await res.json().catch(() => null)) as { error?: string } | null; + if (!res.ok) { + throw new Error(data?.error ?? "Failed to request review"); + } + + const session = sessionsRef.current.find((entry) => entry.id === sessionId); + showToast("Review run requested", "success"); + routerRef.current.push(projectReviewPath(session?.projectId ?? projectId)); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to request review"; + console.error(`Failed to request review for ${sessionId}:`, error); + showToast(`Review failed: ${message}`, "error"); + throw error; + } + }, + [projectId, showToast], + ); + const handleSpawnOrchestrator = async (project: ProjectInfo) => { setSpawningProjectIds((current) => current.includes(project.id) ? current : [...current, project.id], @@ -589,6 +617,18 @@ function DashboardInner({
{headerProjectLabel} +
{!allProjectsView && projectSessions.length > 0 ? (
@@ -751,6 +791,7 @@ function DashboardInner({ onKill={handleKill} onMerge={handleMerge} onRestore={handleRestore} + onReview={handleRequestReview} compactMobile={isMobile} collapsed={isMobile && collapsedZones.has(level)} onToggle={isMobile ? handleZoneToggle : undefined} diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index c6b29a7f6..24c21691f 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -9,7 +9,7 @@ import { getAttentionLevel, type DashboardSession } from "@/lib/types"; import { isOrchestratorSession } from "@aoagents/ao-core/types"; import { getSessionTitle, humanizeBranch } from "@/lib/format"; import { usePopoverClamp } from "@/hooks/usePopoverClamp"; -import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; +import { projectDashboardPath, projectReviewPath, projectSessionPath } from "@/lib/routes"; import { ThemeToggle } from "./ThemeToggle"; import { AddProjectModal } from "./AddProjectModal"; import { ProjectSettingsModal } from "./ProjectSettingsModal"; @@ -846,6 +846,31 @@ function ProjectSidebarInner({ ) : null} + {!isDegraded ? ( + { + e.stopPropagation(); + onMobileClose?.(); + }} + className="project-sidebar__proj-action" + aria-label={`Open ${project.name} reviews`} + title="Reviews" + > + + + + + + ) : null} + {!isDegraded && orchestratorLink && ( = { + queued: "Review work requested but not executing yet.", + reviewing: "A reviewer is reading a snapshot.", + triage: "Findings need a human decision.", + waiting: "Feedback is with the coding worker.", + clean: "No open AO findings remain.", + failed: "Reviewer runs that need retry or inspection.", + outdated: "Runs superseded by newer worker commits.", +}; + +const SUPERSEDABLE_REVIEW_STATUSES = new Set([ + "queued", + "needs_triage", + "sent_to_agent", + "waiting_update", + "clean", +]); + +function formatRelativeTime(value: string): string { + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return value; + const diffMs = Date.now() - timestamp; + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHours = Math.floor(diffMin / 60); + if (diffHours < 24) return `${diffHours}h ago`; + return `${Math.floor(diffHours / 24)}d ago`; +} + +function formatStatus(value: string): string { + return value.replaceAll("_", " "); +} + +function formatFindingLocation(finding: CodeReviewFinding): string | null { + if (!finding.filePath) return null; + if (finding.startLine === undefined) return finding.filePath; + if (finding.endLine !== undefined && finding.endLine !== finding.startLine) { + return `${finding.filePath}:${finding.startLine}-${finding.endLine}`; + } + return `${finding.filePath}:${finding.startLine}`; +} + +function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +function canSendFeedbackToWorker(run: DashboardReviewRun): boolean { + if (!run.workerHasRuntime) return false; + if (run.workerActivity === "exited") return false; + return run.workerRuntimeState !== "missing" && run.workerRuntimeState !== "exited"; +} + +function getWorkerAvailabilityLabel(run: DashboardReviewRun): string { + if (!run.workerHasRuntime) return "no runtime"; + if (run.workerActivity === "exited") return "exited"; + if (run.workerRuntimeState === "missing") return "runtime missing"; + if (run.workerRuntimeState === "exited") return "runtime exited"; + return run.workerActivity ?? run.workerStatus ?? "worker"; +} + +function mergeOrchestrators( + current: DashboardOrchestratorLink[], + incoming: DashboardOrchestratorLink[], +): DashboardOrchestratorLink[] { + const merged = new Map(current.map((orchestrator) => [orchestrator.projectId, orchestrator])); + for (const orchestrator of incoming) { + merged.set(orchestrator.projectId, orchestrator); + } + return Array.from(merged.values()); +} + +function markSupersededReviewRuns( + current: DashboardReviewRun[], + nextRun: DashboardReviewRun, +): DashboardReviewRun[] { + if (!nextRun.targetSha) return current; + + return current.map((run) => { + if (run.linkedSessionId !== nextRun.linkedSessionId) return run; + if (run.id === nextRun.id) return run; + if (!run.targetSha || run.targetSha === nextRun.targetSha) return run; + if (!SUPERSEDABLE_REVIEW_STATUSES.has(run.status)) return run; + return { ...run, status: "outdated" }; + }); +} + +function ReviewDashboardInner({ + runs = EMPTY_RUNS, + sidebarSessions = EMPTY_SESSIONS, + orchestrators = EMPTY_ORCHESTRATORS, + workerOptions = EMPTY_WORKERS, + projectId, + projectName, + projects, + dashboardLoadError, +}: ReviewDashboardProps) { + const { showToast } = useToast(); + const router = useRouter(); + const menuRef = useRef(null); + const [reviewRuns, setReviewRuns] = useState(runs); + const [activeOrchestrators, setActiveOrchestrators] = + useState(orchestrators); + const [requestingSessionId, setRequestingSessionId] = useState(null); + const [executingRunIds, setExecutingRunIds] = useState>(() => new Set()); + const [sendingRunIds, setSendingRunIds] = useState>(() => new Set()); + const [restoringOrchestratorId, setRestoringOrchestratorId] = useState(null); + const [newReviewMenuOpen, setNewReviewMenuOpen] = useState(false); + const [reviewDetails, setReviewDetails] = useState(null); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const isMobile = useMediaQuery(MOBILE_BREAKPOINT); + + useEffect(() => { + setReviewRuns(runs); + }, [runs]); + + useEffect(() => { + setActiveOrchestrators((current) => mergeOrchestrators(current, orchestrators)); + }, [orchestrators]); + + useEffect(() => { + if (!newReviewMenuOpen) return; + const handlePointer = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setNewReviewMenuOpen(false); + } + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setNewReviewMenuOpen(false); + } + }; + document.addEventListener("mousedown", handlePointer); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handlePointer); + document.removeEventListener("keydown", handleKey); + }; + }, [newReviewMenuOpen]); + + useEffect(() => { + if (!reviewDetails) return; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setReviewDetails(null); + } + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [reviewDetails]); + + const grouped = useMemo(() => { + const columns: Record = { + queued: [], + reviewing: [], + triage: [], + waiting: [], + clean: [], + failed: [], + outdated: [], + }; + for (const run of reviewRuns) { + columns[getReviewBoardColumn(run)].push(run); + } + return columns; + }, [reviewRuns]); + + const allProjectsView = !projectId; + const openFindingCount = reviewRuns.reduce((sum, run) => sum + run.openFindingCount, 0); + const activeRunCount = reviewRuns.filter((run) => + ["queued", "preparing", "running", "needs_triage", "sent_to_agent", "waiting_update"].includes( + run.status, + ), + ).length; + const currentProjectOrchestrator = projectId + ? (activeOrchestrators.find((orchestrator) => orchestrator.projectId === projectId) ?? null) + : null; + const orchestratorHref = currentProjectOrchestrator + ? projectSessionPath(currentProjectOrchestrator.projectId, currentProjectOrchestrator.id) + : null; + const visibleWorkerOptions = projectId + ? workerOptions.filter((worker) => worker.projectId === projectId) + : workerOptions; + const codingHref = projectId ? projectDashboardPath(projectId) : "/?project=all"; + const reviewHref = projectReviewPath(projectId); + const headerProjectLabel = projectName ?? (allProjectsView ? "All projects" : "Reviews"); + + const handleToggleSidebar = () => { + if (isMobile) { + setMobileMenuOpen((current) => !current); + } else { + setSidebarCollapsed((current) => !current); + } + }; + + const handleRequestReview = async (worker: ReviewWorkerOption) => { + if (requestingSessionId) return; + setRequestingSessionId(worker.id); + try { + const response = await fetch("/api/reviews", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: worker.id }), + }); + const data = (await response.json().catch(() => null)) as { + run?: DashboardReviewRun; + error?: string; + } | null; + if (!response.ok || !data?.run) { + throw new Error(data?.error ?? "Failed to request review"); + } + + const nextRun: DashboardReviewRun = { + ...data.run, + projectName: worker.projectName, + workerTitle: worker.title, + workerBranch: worker.branch, + workerPrUrl: worker.prUrl ?? data.run.prUrl ?? null, + workerStatus: worker.status, + workerActivity: worker.activity, + workerRuntimeState: worker.runtimeState, + workerHasRuntime: worker.hasRuntime, + }; + setReviewRuns((current) => [ + nextRun, + ...markSupersededReviewRuns( + current.filter((run) => run.id !== nextRun.id), + nextRun, + ), + ]); + setNewReviewMenuOpen(false); + showToast("Review run requested", "success"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to request review"; + showToast(`Review failed: ${message}`, "error"); + } finally { + setRequestingSessionId(null); + } + }; + + const handleExecuteRun = async (run: DashboardReviewRun) => { + if (executingRunIds.has(run.id)) return; + setExecutingRunIds((current) => { + const next = new Set(current); + next.add(run.id); + return next; + }); + setReviewRuns((current) => + current.map((entry) => (entry.id === run.id ? { ...entry, status: "running" } : entry)), + ); + try { + const response = await fetch("/api/reviews/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectId: run.projectId, + runId: run.id, + force: run.status === "failed", + }), + }); + const data = (await response.json().catch(() => null)) as { + run?: DashboardReviewRun; + error?: string; + } | null; + if (!response.ok || !data?.run) { + throw new Error(data?.error ?? "Failed to execute review"); + } + + setReviewRuns((current) => + current.map((entry) => + entry.id === run.id + ? { + ...entry, + ...data.run, + projectName: entry.projectName, + workerTitle: entry.workerTitle, + workerBranch: entry.workerBranch, + workerPrUrl: entry.workerPrUrl, + workerStatus: entry.workerStatus, + workerActivity: entry.workerActivity, + workerRuntimeState: entry.workerRuntimeState, + workerHasRuntime: entry.workerHasRuntime, + } + : entry, + ), + ); + if (data.run.status === "failed") { + showToast( + `Review failed: ${data.run.terminationReason ?? "Reviewer execution failed"}`, + "error", + ); + return; + } + showToast( + data.run.openFindingCount > 0 ? "Review findings ready" : "Review completed clean", + "success", + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to execute review"; + setReviewRuns((current) => + current.map((entry) => (entry.id === run.id ? { ...entry, status: "failed" } : entry)), + ); + showToast(`Review failed: ${message}`, "error"); + } finally { + setExecutingRunIds((current) => { + const next = new Set(current); + next.delete(run.id); + return next; + }); + } + }; + + const mergeRunUpdate = (run: DashboardReviewRun, nextRun: DashboardReviewRun) => ({ + ...run, + ...nextRun, + projectName: run.projectName, + workerTitle: run.workerTitle, + workerBranch: run.workerBranch, + workerPrUrl: run.workerPrUrl, + workerStatus: run.workerStatus, + workerActivity: run.workerActivity, + workerRuntimeState: run.workerRuntimeState, + workerHasRuntime: run.workerHasRuntime, + }); + + const handleSendFeedback = async (run: DashboardReviewRun) => { + if (sendingRunIds.has(run.id) || run.openFindingCount === 0) return; + setSendingRunIds((current) => { + const next = new Set(current); + next.add(run.id); + return next; + }); + try { + const response = await fetch("/api/reviews/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: run.projectId, runId: run.id }), + }); + const data = (await response.json().catch(() => null)) as { + run?: DashboardReviewRun; + sentFindingCount?: number; + error?: string; + } | null; + if (!response.ok || !data?.run) { + throw new Error(data?.error ?? "Failed to send review findings"); + } + + setReviewRuns((current) => + current.map((entry) => + entry.id === run.id ? mergeRunUpdate(entry, data.run as DashboardReviewRun) : entry, + ), + ); + setReviewDetails((current) => { + if (!current || current.run.id !== run.id) return current; + const sentAt = new Date().toISOString(); + return { + ...current, + run: mergeRunUpdate(current.run, data.run as DashboardReviewRun), + findings: current.findings.map((finding) => + finding.status === "open" + ? { ...finding, status: "sent_to_agent", sentToAgentAt: sentAt } + : finding, + ), + }; + }); + showToast( + `Sent ${pluralize(data.sentFindingCount ?? 0, "finding")} to ${run.linkedSessionId}`, + "success", + ); + router.push( + projectSessionHashPath(run.projectId, run.linkedSessionId, "#session-terminal-section"), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to send review findings"; + showToast(`Feedback failed: ${message}`, "error"); + } finally { + setSendingRunIds((current) => { + const next = new Set(current); + next.delete(run.id); + return next; + }); + } + }; + + const handleRestoreOrchestrator = async (orchestrator: DashboardOrchestratorLink) => { + if (restoringOrchestratorId) return; + setRestoringOrchestratorId(orchestrator.id); + try { + const response = await fetch(`/api/sessions/${encodeURIComponent(orchestrator.id)}/restore`, { + method: "POST", + }); + const data = (await response.json().catch(() => null)) as { + error?: string; + session?: DashboardSession; + } | null; + if (!response.ok) { + throw new Error(data?.error ?? "Failed to restore orchestrator"); + } + + setActiveOrchestrators((current) => + current.map((entry) => + entry.id === orchestrator.id + ? { + ...entry, + status: data?.session?.status ?? entry.status, + activity: data?.session?.activity ?? entry.activity, + runtimeState: data?.session?.lifecycle?.runtimeState ?? "alive", + hasRuntime: true, + isTerminal: false, + isRestorable: false, + } + : entry, + ), + ); + showToast("Orchestrator restored", "success"); + router.push(projectSessionPath(orchestrator.projectId, orchestrator.id)); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to restore orchestrator"; + showToast(`Restore failed: ${message}`, "error"); + } finally { + setRestoringOrchestratorId(null); + } + }; + + const handleOpenReviewDetails = async (run: DashboardReviewRun) => { + setReviewDetails({ run, findings: [], loading: true, error: null }); + try { + const params = new URLSearchParams({ projectId: run.projectId, runId: run.id }); + const response = await fetch(`/api/reviews/findings?${params.toString()}`); + const data = (await response.json().catch(() => null)) as { + findings?: CodeReviewFinding[]; + error?: string; + } | null; + if (!response.ok || !data?.findings) { + throw new Error(data?.error ?? "Failed to load review findings"); + } + + setReviewDetails((current) => + current?.run.id === run.id + ? { ...current, findings: data.findings ?? [], loading: false, error: null } + : current, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load review findings"; + setReviewDetails((current) => + current?.run.id === run.id + ? { ...current, findings: [], loading: false, error: message } + : current, + ); + } + }; + + return ( + +
+
+ +
+
+
+ +
+
+ setSidebarCollapsed((current) => !current)} + onMobileClose={() => setMobileMenuOpen(false)} + /> +
+ {mobileMenuOpen ? ( +
setMobileMenuOpen(false)} /> + ) : null} + +
+
+
+

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

+

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

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

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

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

{COLUMN_HINTS[column]}

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

{title}

+
+ +
+ +
+

{secondaryText}

+
+ +
+

+ {truthLine} +

+
+ + {run.openFindingCount > 0 ? ( +
+
+ + + + + +
+
+ ) : null} + +
+ + {status} · updated {formatRelativeTime(run.updatedAt)} + +
+ {canExecute ? ( + + ) : null} + + Worker + + {feedbackAvailable ? ( + + ) : ( + + {getWorkerAvailabilityLabel(run)} + + )} +
+
+
+
+ ); +} + +function ReviewDetailsDrawer({ + state, + onClose, + onOpenWorker, + isSending, + onSendFeedback, +}: { + state: ReviewDetailsState; + onClose: () => void; + onOpenWorker: () => void; + isSending: boolean; + onSendFeedback: (run: DashboardReviewRun) => void; +}) { + const { run, findings, loading, error } = state; + const workerHref = projectDashboardSessionPath(run.projectId, run.linkedSessionId); + const feedbackHref = projectSessionHashPath( + run.projectId, + run.linkedSessionId, + "#session-terminal-section", + ); + const openFindings = findings.filter((finding) => finding.status === "open"); + const feedbackAvailable = canSendFeedbackToWorker(run); + + return ( + <> +
+ + + ); +} diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 063dfda70..9132075fb 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -33,6 +33,7 @@ interface SessionCardProps { onKill?: (sessionId: string) => void; onMerge?: (prNumber: number) => void; onRestore?: (sessionId: string) => void; + onReview?: (sessionId: string) => Promise | void; } /** @@ -126,12 +127,20 @@ function getDoneStatusInfo(session: DashboardSession): { }; } -function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) { +function SessionCardView({ + session, + onSend, + onKill, + onMerge, + onRestore, + onReview, +}: SessionCardProps) { const [expanded, setExpanded] = useState(false); const [sendingAction, setSendingAction] = useState(null); const [failedAction, setFailedAction] = useState(null); const [sendingQuickReply, setSendingQuickReply] = useState(null); const [sentQuickReply, setSentQuickReply] = useState(null); + const [requestingReview, setRequestingReview] = useState(false); const [killConfirming, setKillConfirming] = useState(false); const [replyText, setReplyText] = useState(""); const actionTimerRef = useRef | null>(null); @@ -259,6 +268,18 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio onKill?.(session.id); }; + const handleReviewClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (requestingReview || !onReview) return; + + setRequestingReview(true); + try { + await Promise.resolve(onReview(session.id)); + } finally { + setRequestingReview(false); + } + }; + /* ── Done card variant ──────────────────────────────────────────── */ if (isDone) { const statusInfo = getDoneStatusInfo(session); @@ -827,44 +848,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio {isReadyToMerge && pr ? ( - - ) : ( - !isTerminal && ( - + ) : null} + +
+ ) : ( + !isTerminal && ( +
+ {onReview ? ( + + ) : null} + +
) )}
@@ -892,7 +959,8 @@ function areSessionCardPropsEqual(prev: SessionCardProps, next: SessionCardProps prev.onSend === next.onSend && prev.onKill === next.onKill && prev.onMerge === next.onMerge && - prev.onRestore === next.onRestore + prev.onRestore === next.onRestore && + prev.onReview === next.onReview ); } @@ -918,8 +986,8 @@ interface Alert { type: "ci" | "changes" | "review" | "conflict" | "comment"; icon: React.ReactNode; label: string; - url: string; count?: number; + url: string; notified?: boolean; actionLabel?: string; actionMessage?: string; diff --git a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx index 731b9487e..27f8cbb48 100644 --- a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx @@ -100,6 +100,30 @@ describe("Dashboard project overview cards", () => { ); }); + it("renders the same Coding/Reviews switch in project-scoped dashboard headers", () => { + render( + , + ); + + expect(screen.getByRole("link", { name: "Coding" })).toHaveAttribute( + "href", + "/projects/my-app", + ); + expect(screen.getByRole("link", { name: "Coding" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "Reviews" })).toHaveAttribute( + "href", + "/review?project=my-app", + ); + }); + it("renders a header spawn action when the project has no orchestrator yet", () => { render( ({ + useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/review", +})); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ + resolvedTheme: "light", + setTheme: vi.fn(), + }), +})); + +function makeRun(overrides: Partial): DashboardReviewRun { + return { + id: "review-run-1", + projectId: "my-app", + projectName: "My App", + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + createdAt: "2026-05-10T10:00:00.000Z", + updatedAt: "2026-05-10T10:01:00.000Z", + findingCount: 2, + openFindingCount: 1, + dismissedFindingCount: 1, + sentFindingCount: 0, + resolvedFindingCount: 0, + workerTitle: "Add todo filters", + workerBranch: "feat/todo-filters", + workerPrUrl: "https://github.com/acme/todo/pull/7", + workerStatus: "review_pending", + workerActivity: "idle", + workerRuntimeState: "alive", + workerHasRuntime: true, + ...overrides, + }; +} + +describe("ReviewDashboard", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders review runs in review-specific columns", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "My App Reviews" })).toBeInTheDocument(); + expect(screen.getByText("Triage")).toBeInTheDocument(); + expect(screen.getByText("Reviewing")).toBeInTheDocument(); + expect(screen.getByText("Add todo filters")).toBeInTheDocument(); + expect(screen.getByText("Persist completed todos")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /1 open finding/i })).toBeInTheDocument(); + const workerLinks = screen.getAllByRole("link", { name: "Worker" }); + expect( + workerLinks.some((link) => link.getAttribute("href") === "/projects/my-app?session=app-1"), + ).toBe(true); + }); + + it("renders an empty state when there are no review runs", () => { + render( + , + ); + + expect(screen.getByText("No review runs yet")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Back to coding dashboard" })).toHaveAttribute( + "href", + "/projects/my-app", + ); + }); + + it("can execute multiple queued review runs without a board-wide lock", async () => { + const fetchMock = vi.fn( + () => + new Promise(() => { + // Keep requests pending so the test can assert simultaneous in-flight runs. + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render( + , + ); + + const runButtons = screen.getAllByRole("button", { name: "Run" }); + fireEvent.click(runButtons[0]); + await screen.findByRole("button", { name: "Running" }); + + fireEvent.click(runButtons[1]); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + }); + + it("surfaces a completed failed review run as a failure", async () => { + const fetchMock = vi.fn(async () => + Response.json({ + run: makeRun({ + id: "review-run-1", + reviewerSessionId: "app-rev-1", + status: "failed", + findingCount: 0, + openFindingCount: 0, + dismissedFindingCount: 0, + terminationReason: "Codex review failed: invalid arguments", + }), + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Run" })); + + expect( + await screen.findByText(/Review failed: Codex review failed: invalid arguments/i), + ).toBeInTheDocument(); + expect(screen.queryByText("Review completed clean")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx b/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx index 5ff28639a..680106c70 100644 --- a/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx +++ b/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionCard } from "../SessionCard"; import { makePR, makeSession } from "../../__tests__/helpers"; @@ -135,4 +135,15 @@ describe("SessionCard diff coverage", () => { "kanban-card-enter", ); }); + + it("requests a review from active worker cards", async () => { + const onReview = vi.fn(async () => {}); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Request review" })); + + await waitFor(() => { + expect(onReview).toHaveBeenCalledWith("reviewable-1"); + }); + }); }); diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index d92e80e6a..7f310bb56 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -297,6 +297,60 @@ describe("SessionDetail desktop layout", () => { ); }); + it("does not open a blank terminal when activity exited but lifecycle still reports alive", () => { + render( + , + ); + + expect(screen.getByRole("region", { name: "Session ended summary" })).toBeInTheDocument(); + expect(screen.getByText("Terminal ended")).toBeInTheDocument(); + expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument(); + expect( + within(screen.getByRole("banner")).getByRole("button", { name: "Restore" }), + ).toBeInTheDocument(); + }); + it("shows restore for restorable orchestrator sessions", () => { render( { + return { status }; +} + +describe("getReviewBoardColumn", () => { + it("maps reviewer run statuses into review board columns", () => { + expect(getReviewBoardColumn(makeRun("queued"))).toBe("queued"); + expect(getReviewBoardColumn(makeRun("preparing"))).toBe("queued"); + expect(getReviewBoardColumn(makeRun("running"))).toBe("reviewing"); + expect(getReviewBoardColumn(makeRun("needs_triage"))).toBe("triage"); + expect(getReviewBoardColumn(makeRun("sent_to_agent"))).toBe("waiting"); + expect(getReviewBoardColumn(makeRun("waiting_update"))).toBe("waiting"); + expect(getReviewBoardColumn(makeRun("clean"))).toBe("clean"); + expect(getReviewBoardColumn(makeRun("failed"))).toBe("failed"); + expect(getReviewBoardColumn(makeRun("cancelled"))).toBe("failed"); + expect(getReviewBoardColumn(makeRun("outdated"))).toBe("outdated"); + }); +}); diff --git a/packages/web/src/lib/__tests__/types.test.ts b/packages/web/src/lib/__tests__/types.test.ts index 04b6036a1..350eee852 100644 --- a/packages/web/src/lib/__tests__/types.test.ts +++ b/packages/web/src/lib/__tests__/types.test.ts @@ -12,7 +12,9 @@ import { TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES, isDashboardSessionDone, + isDashboardRuntimeEnded, isDashboardSessionRestorable, + isDashboardSessionTerminal, type DashboardSession, type DashboardPR, } from "../types"; @@ -264,6 +266,49 @@ describe("getAttentionLevel", () => { }); describe("restore affordances", () => { + it("treats exited activity as terminal even when lifecycle runtime is still alive", () => { + const session = createSession({ + status: "review_pending", + activity: "exited", + lifecycle: { + sessionState: "idle", + sessionReason: "awaiting_external_review", + prState: "open", + prReason: "review_pending", + runtimeState: "alive", + runtimeReason: "process_running", + session: { + state: "idle", + reason: "awaiting_external_review", + label: "idle", + reasonLabel: "awaiting external review", + }, + pr: { + state: "open", + reason: "review_pending", + label: "open", + reasonLabel: "review pending", + }, + runtime: { + state: "alive", + reason: "process_running", + label: "alive", + reasonLabel: "process running", + }, + legacyStatus: "review_pending", + evidence: null, + detectingAttempts: 0, + detectingEscalatedAt: null, + summary: "Waiting for review", + guidance: null, + }, + }); + + expect(isDashboardSessionTerminal(session)).toBe(true); + expect(isDashboardRuntimeEnded(session)).toBe(true); + expect(isDashboardSessionRestorable(session)).toBe(true); + }); + it("should not mark a running merged session as restorable (runtime still alive)", () => { const session = createSession({ status: "merged", diff --git a/packages/web/src/lib/review-page-data.ts b/packages/web/src/lib/review-page-data.ts new file mode 100644 index 000000000..1260d84c2 --- /dev/null +++ b/packages/web/src/lib/review-page-data.ts @@ -0,0 +1,178 @@ +import "server-only"; + +import { + createCodeReviewStore, + isOrchestratorSession, + isRestorable, + isTerminalSession, + markOutdatedCodeReviewRunsForSession, +} from "@aoagents/ao-core"; +import { getServices } from "@/lib/services"; +import { + getAllProjects, + getPrimaryProjectId, + getProjectName, + type ProjectInfo, +} from "@/lib/project-name"; +import type { DashboardReviewRun, ReviewWorkerOption } from "@/lib/review-types"; +import { listDashboardOrchestrators, sessionToDashboard } from "@/lib/serialize"; +import type { DashboardOrchestratorLink, DashboardSession } from "@/lib/types"; + +interface ReviewPageData { + runs: DashboardReviewRun[]; + sidebarSessions: DashboardSession[]; + orchestrators: DashboardOrchestratorLink[]; + workerOptions: ReviewWorkerOption[]; + projectName: string; + projects: ProjectInfo[]; + selectedProjectId?: string; + dashboardLoadError?: string; +} + +function formatReviewLoadError(err: unknown): string { + if (err instanceof Error && err.message.trim()) { + return err.message.split(/\r?\n/)[0]?.trim() || "Failed to load review data."; + } + return "Failed to load review data."; +} + +export function getReviewProjectName(projectFilter: string | undefined): string { + if (projectFilter === "all") return "All Projects"; + const projects = getAllProjects(); + if (projectFilter) { + const selectedProject = projects.find((project) => project.id === projectFilter); + if (selectedProject) return selectedProject.name; + } + return getProjectName(); +} + +export function resolveReviewProjectFilter(project?: string): string { + if (project === "all") return "all"; + const projects = getAllProjects(); + if (project && projects.some((entry) => entry.id === project)) { + return project; + } + return getPrimaryProjectId(); +} + +export async function getReviewPageData(project?: string): Promise { + const projectFilter = resolveReviewProjectFilter(project); + const pageData: ReviewPageData = { + runs: [], + sidebarSessions: [], + orchestrators: [], + workerOptions: [], + projectName: getReviewProjectName(projectFilter), + projects: getAllProjects(), + selectedProjectId: projectFilter === "all" ? undefined : projectFilter, + }; + + try { + const { config, sessionManager } = await getServices(); + const projectIds = + projectFilter === "all" + ? Object.keys(config.projects) + : config.projects[projectFilter] + ? [projectFilter] + : []; + const allSessions = await sessionManager.listCached(); + const visibleSessions = allSessions.filter((session) => projectIds.includes(session.projectId)); + const allSessionPrefixes = Object.entries(config.projects).map( + ([projectId, project]) => project.sessionPrefix ?? projectId, + ); + const workerSessionsById = new Map( + visibleSessions + .filter( + (session) => + !isOrchestratorSession( + session, + config.projects[session.projectId]?.sessionPrefix ?? session.projectId, + allSessionPrefixes, + ), + ) + .map((session) => [session.id, session]), + ); + const workerSessions = [...workerSessionsById.values()]; + + pageData.sidebarSessions = visibleSessions.map(sessionToDashboard); + const visibleSessionsById = new Map(visibleSessions.map((session) => [session.id, session])); + pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects).map( + (orchestrator) => { + const session = visibleSessionsById.get(orchestrator.id); + return { + ...orchestrator, + status: session?.status ?? null, + activity: session?.activity ?? null, + runtimeState: session?.lifecycle.runtime.state ?? null, + hasRuntime: session?.runtimeHandle !== null && session?.runtimeHandle !== undefined, + isTerminal: session ? isTerminalSession(session) : false, + isRestorable: session ? isRestorable(session) : false, + }; + }, + ); + pageData.workerOptions = workerSessions.map((session) => { + const project = config.projects[session.projectId]; + const title = + session.metadata["displayName"] ?? + session.metadata["issueTitle"] ?? + session.metadata["pinnedSummary"] ?? + session.agentInfo?.summary ?? + session.branch ?? + session.id; + return { + id: session.id, + projectId: session.projectId, + projectName: project?.name ?? session.projectId, + title, + branch: session.branch ?? null, + status: session.status, + activity: session.activity ?? null, + runtimeState: session.lifecycle.runtime.state, + hasRuntime: session.runtimeHandle !== null && session.runtimeHandle !== undefined, + prNumber: session.pr?.number ?? null, + prUrl: session.pr?.url ?? null, + }; + }); + + const runs: DashboardReviewRun[] = []; + + for (const projectId of projectIds) { + const project = config.projects[projectId]; + if (!project) continue; + const store = createCodeReviewStore(projectId); + const projectWorkers = workerSessions.filter((session) => session.projectId === projectId); + + for (const worker of projectWorkers) { + await markOutdatedCodeReviewRunsForSession({ store, session: worker }); + } + + runs.push( + ...store.listRunSummaries().map((run) => { + const worker = workerSessionsById.get(run.linkedSessionId); + return { + ...run, + projectName: project.name, + workerTitle: + worker?.metadata["displayName"] ?? + worker?.metadata["issueTitle"] ?? + worker?.metadata["pinnedSummary"] ?? + worker?.agentInfo?.summary ?? + null, + workerBranch: worker?.branch ?? null, + workerPrUrl: worker?.pr?.url ?? run.prUrl ?? null, + workerStatus: worker?.status ?? null, + workerActivity: worker?.activity ?? null, + workerRuntimeState: worker?.lifecycle.runtime.state ?? null, + workerHasRuntime: worker?.runtimeHandle !== null && worker?.runtimeHandle !== undefined, + }; + }), + ); + } + + pageData.runs = runs; + } catch (err) { + pageData.dashboardLoadError = formatReviewLoadError(err); + } + + return pageData; +} diff --git a/packages/web/src/lib/review-types.ts b/packages/web/src/lib/review-types.ts new file mode 100644 index 000000000..64af3640e --- /dev/null +++ b/packages/web/src/lib/review-types.ts @@ -0,0 +1,77 @@ +import type { CodeReviewRunSummary } from "@aoagents/ao-core"; + +export type ReviewBoardColumn = + | "queued" + | "reviewing" + | "triage" + | "waiting" + | "clean" + | "failed" + | "outdated"; + +export interface DashboardReviewRun extends CodeReviewRunSummary { + projectName: string; + workerTitle: string | null; + workerBranch: string | null; + workerPrUrl: string | null; + workerStatus: string | null; + workerActivity: string | null; + workerRuntimeState: string | null; + workerHasRuntime: boolean; +} + +export interface ReviewWorkerOption { + id: string; + projectId: string; + projectName: string; + title: string; + branch: string | null; + status: string; + activity: string | null; + runtimeState: string | null; + hasRuntime: boolean; + prNumber: number | null; + prUrl: string | null; +} + +export const REVIEW_BOARD_COLUMNS: ReviewBoardColumn[] = [ + "queued", + "reviewing", + "triage", + "waiting", + "clean", + "failed", + "outdated", +]; + +export const REVIEW_COLUMN_LABELS: Record = { + queued: "Queued", + reviewing: "Reviewing", + triage: "Triage", + waiting: "Waiting", + clean: "Clean", + failed: "Failed", + outdated: "Outdated", +}; + +export function getReviewBoardColumn(run: Pick): ReviewBoardColumn { + switch (run.status) { + case "queued": + case "preparing": + return "queued"; + case "running": + return "reviewing"; + case "needs_triage": + return "triage"; + case "sent_to_agent": + case "waiting_update": + return "waiting"; + case "clean": + return "clean"; + case "failed": + case "cancelled": + return "failed"; + case "outdated": + return "outdated"; + } +} diff --git a/packages/web/src/lib/routes.ts b/packages/web/src/lib/routes.ts index 22862ea99..44bfb8e35 100644 --- a/packages/web/src/lib/routes.ts +++ b/packages/web/src/lib/routes.ts @@ -2,6 +2,14 @@ export function projectDashboardPath(projectId: string): string { return `/projects/${encodeURIComponent(projectId)}`; } +export function projectDashboardSessionPath(projectId: string, sessionId: string): string { + return `${projectDashboardPath(projectId)}?session=${encodeURIComponent(sessionId)}`; +} + +export function projectReviewPath(projectId: string | undefined): string { + return projectId ? `/review?project=${encodeURIComponent(projectId)}` : "/review?project=all"; +} + export function projectSessionPath(projectId: string, sessionId: string): string { return `${projectDashboardPath(projectId)}/sessions/${encodeURIComponent(sessionId)}`; } diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 85f93d225..82f7daad2 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -250,6 +250,12 @@ export interface DashboardOrchestratorLink { id: string; projectId: string; projectName: string; + status?: string | null; + activity?: string | null; + runtimeState?: string | null; + hasRuntime?: boolean; + isTerminal?: boolean; + isRestorable?: boolean; } /** @@ -403,30 +409,31 @@ export function isDashboardSessionDone(session: DashboardSession): boolean { return session.pr?.state === "merged"; } +function hasTerminalActivity(session: DashboardSession): boolean { + return session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity); +} + export function isDashboardSessionTerminal(session: DashboardSession): boolean { if (session.lifecycle) { return ( isDashboardSessionDone(session) || session.lifecycle.runtimeState === "missing" || - session.lifecycle.runtimeState === "exited" + session.lifecycle.runtimeState === "exited" || + hasTerminalActivity(session) ); } - return ( - TERMINAL_STATUSES.has(session.status) || - (session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity)) - ); + return TERMINAL_STATUSES.has(session.status) || hasTerminalActivity(session); } export function isDashboardRuntimeEnded(session: DashboardSession): boolean { if (session.lifecycle) { return ( - session.lifecycle.runtimeState === "missing" || session.lifecycle.runtimeState === "exited" + session.lifecycle.runtimeState === "missing" || + session.lifecycle.runtimeState === "exited" || + hasTerminalActivity(session) ); } - return ( - TERMINAL_STATUSES.has(session.status) || - (session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity)) - ); + return TERMINAL_STATUSES.has(session.status) || hasTerminalActivity(session); } export function isDashboardSessionRestorable(session: DashboardSession): boolean { @@ -435,8 +442,13 @@ export function isDashboardSessionRestorable(session: DashboardSession): boolean session.lifecycle.sessionState === "done" || isDashboardSessionTerminated(session) || session.lifecycle.runtimeState === "missing" || - session.lifecycle.runtimeState === "exited"; - return terminalByCoreTruth && !NON_RESTORABLE_STATUSES.has(session.status); + session.lifecycle.runtimeState === "exited" || + hasTerminalActivity(session); + return ( + terminalByCoreTruth && + !NON_RESTORABLE_STATUSES.has(session.status) && + session.status !== "merged" + ); } return isDashboardSessionTerminal(session) && !NON_RESTORABLE_STATUSES.has(session.status); } From ee3fb5d33ae7c00c1737aa0807ea90519ba9773f Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 19 May 2026 12:08:35 +0530 Subject: [PATCH 21/26] fix(web): make Done/Terminated dashboard section scrollable (#1923) (#1925) * fix(web): make Done/Terminated dashboard section scrollable (#1923) The collapsible Done/Terminated cards grid had no max-height or overflow handling, so it grew unbounded and was clipped by the ancestor's overflow:hidden once enough terminated sessions accumulated. The regression came from the Warm Terminal design refresh (#927) which added `flex: 1` to `.kanban-board-wrap`, defeating the body-level `overflow-y: auto` that previously allowed natural page scroll past the done section. Mirror the existing `.kanban-column-body` pattern by giving the cards grid its own internal scroll container and matching thin scrollbar styling. Each scrolling region owns its own scroll; the Warm Terminal flex layout is preserved. * style(web): document done-bar__cards max-height magic number Addresses greptile P2 review feedback on PR #1925. The 320px in calc(100vh - 320px) is the assumed chrome above the cards (dashboard header + subhead + body padding + done-bar toggle and margins). Adding a comment makes the implicit ancestor-height contract explicit, and points to the kanban-board rule that uses the same viewport-anchored pattern. --- .changeset/fix-done-bar-scroll.md | 5 +++++ packages/web/src/app/globals.css | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 .changeset/fix-done-bar-scroll.md diff --git a/.changeset/fix-done-bar-scroll.md b/.changeset/fix-done-bar-scroll.md new file mode 100644 index 000000000..edac04f39 --- /dev/null +++ b/.changeset/fix-done-bar-scroll.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-web": patch +--- + +Fix Done/Terminated section on the dashboard not scrolling. The expanded cards grid now has an internal scroll container (mirroring the kanban column body pattern), so older terminated sessions are reachable when many accumulate. diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 1ae049ef9..d85a5c6c0 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -5858,6 +5858,21 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; margin-top: 12px; + /* 320px ≈ dashboard header + subhead + body padding + done-bar toggle/margins. + Mirrors the .kanban-board viewport-anchored height pattern (line 5107). */ + max-height: calc(100vh - 320px); + overflow-y: auto; +} + +.done-bar__cards::-webkit-scrollbar { + width: 4px; +} +.done-bar__cards::-webkit-scrollbar-track { + background: transparent; +} +.done-bar__cards::-webkit-scrollbar-thumb { + background: var(--color-scrollbar); + border-radius: 0; } /* ── Done card (compact grid card) ──────────────────────────────────── */ From 11c07de2582393e2bdddeaf3ecb6e5e7aecd65a5 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 19 May 2026 14:38:24 +0530 Subject: [PATCH 22/26] fix(web): bound project page wrapper so dashboard body scrolls (closes #1923) (#1929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): make dashboard body scroll as one unit (closes #1923) PR #1925 added internal scroll on .done-bar__cards, but the desired UX is page-level scroll — kanban above, Done/Terminated below, scrolling as one unit (the pre-#927 behavior). Internal scroll on the cards container produced a separate scrollable region inside an otherwise locked layout. Root cause: af2af115 (#927, Warm Terminal design refresh) added `flex: 1; min-height: 0` to .kanban-board-wrap. That made the wrap greedily consume all remaining body height, so body content always equalled body height exactly — `.dashboard-main__body { overflow-y: auto }` never triggered. Drop .kanban-board-wrap from the combined flex:1 rule. It now takes its content size (= the inner .kanban-board's fixed `calc(100vh - 240px)`). When done-bar expands below, body content exceeds body height and the existing overflow-y: auto produces natural page scroll. .board-wrapper (empty state + loading skeleton) keeps flex:1 — it relies on filling space to vertically center its CTA. Also revert the now-unneeded .done-bar__cards max-height + overflow-y + scrollbar styling from #1925. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): make
a flex column so body's overflow-y actually scrolls The body-level scroll has been broken since af2af115 (#927). The JSX is
, but neither .dashboard-main nor any Tailwind class on
sets display: flex. So .dashboard-main__body's `flex: 1` and `overflow-y: auto` never functioned — body simply took content height, never had overflow to scroll, and
's overflow: hidden clipped it. Pre-regression markup was a
with `overflow-y-auto` directly on it (the dashboard container itself scrolled). The af2af115 refactor moved overflow to a nested body and assumed main was a flex column — but never declared it. Add `flex flex-col` to
in both Dashboard.tsx and the projects/[projectId]/loading.tsx skeleton. Body's flex:1 now correctly fills
's height, overflow-y: auto activates when body content exceeds, and the kanban-board-wrap fix from f7f843cd lets that overflow actually happen. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): bound project page wrapper + lock kanban/done from flex-shrink The real clipper was .dashboard-shell--desktop (height 900px, overflow: hidden), not anything inside Dashboard. The project page wrapper (
) was a non-flex block with min-height: 100vh, so .dashboard-main-- desktop grew to its content height (~1411px) instead of being bounded by the shell's 900px. As a result .dashboard-main__body had overflow-y: auto but also grew to content height (~1308px), so it never overflowed and no scrollbar appeared anywhere. Verified in devtools: with this fix .dashboard-main__body clientHeight is 797 and scrollHeight is 1308 — body becomes the single vertical scroll container, kanban above and Done section below, scrolling as one unit. Changes: 1. /projects/[projectId]/page.tsx wrapper: 'flex-1 min-h-screen' -> 'flex min-h-0 min-w-0 flex-1'. Makes the wrapper a bounded flex item so .dashboard-main--desktop inherits a constrained height from the shell row instead of growing past it. 2. globals.css: flex-shrink: 0 on .kanban-board-wrap and .done-bar. Prevents body's flex column from crushing the kanban to make room for the expanded done section. Both children now assert their content size; body content exceeds body height; overflow-y: auto activates. Root cause credit to harshitsinghbhandari — found via devtools after my three prior attempts (#1925 internal scroll on cards, plus the two prior commits on this branch) all addressed the wrong layer of the layout tree. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): add flex-col to project page wrapper + regression test Follow-up to 110a4225 — the wrapper was 'flex min-h-0 min-w-0 flex-1 ...' but missing flex-col. Because Dashboard renders multiple siblings when inside ProjectLayoutClient (UpdateBanner, ConnectionBar, mainPanel, bottomSheet), any visible UpdateBanner becomes a horizontal flex sibling next to the dashboard shell instead of stacking above it. ConnectionBar is fixed-positioned, bottomSheet is overlay — but UpdateBanner is in normal flow and exposes the row-vs-column bug whenever it's visible. Add flex-col so siblings stack vertically. Also add a regression test in page.test.tsx that the Dashboard parent has the bounded-flex classes (flex, min-h-0, min-w-0, flex-1) and no longer has min-h-screen, so future refactors can't silently re-break the dashboard's scroll container. Credit to harshitsinghbhandari for catching the missing flex-col and writing the test. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): wrap loading skeleton in same bounded flex container Addresses greptile P1 review feedback on PR #1929. The project page (page.tsx) wraps in
so .dashboard-main--desktop inherits the project shell's 900px constraint instead of growing past it. The loading skeleton (loading.tsx) renders .dashboard-main--desktop directly as the root, landing in the same layout slot WITHOUT that constraint — so during a slow load the skeleton would still overflow the shell and reproduce the original clip behavior. Wrap the skeleton in the same outer bounded flex container as page.tsx for layout parity. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/fix-done-bar-scroll.md | 2 +- packages/web/src/app/globals.css | 21 ++---- .../src/app/projects/[projectId]/loading.tsx | 72 ++++++++++--------- .../app/projects/[projectId]/page.test.tsx | 27 +++++++ .../web/src/app/projects/[projectId]/page.tsx | 2 +- packages/web/src/components/Dashboard.tsx | 2 +- 6 files changed, 74 insertions(+), 52 deletions(-) diff --git a/.changeset/fix-done-bar-scroll.md b/.changeset/fix-done-bar-scroll.md index edac04f39..f049da4a8 100644 --- a/.changeset/fix-done-bar-scroll.md +++ b/.changeset/fix-done-bar-scroll.md @@ -2,4 +2,4 @@ "@aoagents/ao-web": patch --- -Fix Done/Terminated section on the dashboard not scrolling. The expanded cards grid now has an internal scroll container (mirroring the kanban column body pattern), so older terminated sessions are reachable when many accumulate. +Fix Done/Terminated section on the dashboard not scrolling. The dashboard body now scrolls as a single page — kanban above, Done/Terminated below — so older terminated sessions are reachable when many accumulate. Restores the pre-Warm-Terminal-refresh behavior by dropping the `flex: 1` that was making `.kanban-board-wrap` greedily consume all remaining body height and defeat the body's `overflow-y: auto`. diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index d85a5c6c0..f7e239966 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1545,7 +1545,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 0 16px 16px; } -.dashboard-main__body .kanban-board-wrap, .dashboard-main__body .board-wrapper { flex: 1; min-height: 0; @@ -5185,6 +5184,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { position: relative; margin-top: 14px; overflow-x: auto; + flex-shrink: 0; } .board-section-head { @@ -5803,6 +5803,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { /* ── Done / Terminated collapsible bar ────────────────────────────────── */ +.done-bar { + flex-shrink: 0; +} + .done-bar__toggle { display: flex; align-items: center; @@ -5858,21 +5862,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; margin-top: 12px; - /* 320px ≈ dashboard header + subhead + body padding + done-bar toggle/margins. - Mirrors the .kanban-board viewport-anchored height pattern (line 5107). */ - max-height: calc(100vh - 320px); - overflow-y: auto; -} - -.done-bar__cards::-webkit-scrollbar { - width: 4px; -} -.done-bar__cards::-webkit-scrollbar-track { - background: transparent; -} -.done-bar__cards::-webkit-scrollbar-thumb { - background: var(--color-scrollbar); - border-radius: 0; } /* ── Done card (compact grid card) ──────────────────────────────────── */ diff --git a/packages/web/src/app/projects/[projectId]/loading.tsx b/packages/web/src/app/projects/[projectId]/loading.tsx index ffeee105c..7df4da536 100644 --- a/packages/web/src/app/projects/[projectId]/loading.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.tsx @@ -1,40 +1,46 @@ export default function ProjectRouteLoading() { return ( -
- - -
-
+
); } diff --git a/packages/web/src/app/projects/[projectId]/page.test.tsx b/packages/web/src/app/projects/[projectId]/page.test.tsx index bb1ab40f6..8ba4b4d21 100644 --- a/packages/web/src/app/projects/[projectId]/page.test.tsx +++ b/packages/web/src/app/projects/[projectId]/page.test.tsx @@ -36,6 +36,33 @@ vi.mock("@/components/Dashboard", () => ({ import ProjectPage from "./page"; describe("ProjectPage", () => { + it("renders the dashboard inside a bounded flex item owned by the project shell", async () => { + hoisted.getProjectRouteDataMock.mockResolvedValue({ + projectId: "project-1", + project: { id: "project-1" }, + projects: [{ id: "project-1", name: "Project 1" }], + degradedProject: null, + }); + hoisted.getDashboardPageDataMock.mockResolvedValue({ + sessions: [], + selectedProjectId: "project-1", + projectName: "Project 1", + projects: [{ id: "project-1", name: "Project 1" }], + orchestrators: [], + attentionZones: "simple", + }); + + render(await ProjectPage({ params: Promise.resolve({ projectId: "project-1" }) })); + + expect(screen.getByTestId("dashboard").parentElement).toHaveClass( + "flex", + "min-h-0", + "min-w-0", + "flex-1", + ); + expect(screen.getByTestId("dashboard").parentElement).not.toHaveClass("min-h-screen"); + }); + it("renders degraded project state when the project is degraded", async () => { hoisted.getProjectRouteDataMock.mockResolvedValue({ projectId: "broken", diff --git a/packages/web/src/app/projects/[projectId]/page.tsx b/packages/web/src/app/projects/[projectId]/page.tsx index 39d317391..92582e82a 100644 --- a/packages/web/src/app/projects/[projectId]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/page.tsx @@ -29,7 +29,7 @@ export default async function ProjectPage(props: { const pageData = await getDashboardPageData(projectId); return ( -
+
-
+

Dashboard

From 298057044f3f7d36524785a60c90fe4381f88698 Mon Sep 17 00:00:00 2001 From: Adil Shaikh <106678504+whoisasx@users.noreply.github.com> Date: Tue, 19 May 2026 14:48:40 +0530 Subject: [PATCH 23/26] feat(notifier): make notifier system robust with manual harness and desktop setup (#1736) * fix(notifier-desktop): use terminal-notifier on macOS for click-to-open support On macOS, when terminal-notifier is installed (brew install terminal-notifier), desktop notifications now open the dashboard URL when clicked instead of opening Script Editor. Falls back to osascript when terminal-notifier is not available. New config option `dashboardUrl` controls the click-through URL: notifiers.desktop.dashboardUrl: "http://localhost:8080" Refs #1579 * feat(cli): add manual notifier test harness * feat(cli): add native desktop notifier setup * fix(cli): handle denied desktop notification permission * fix(notifier): support composio actions api * fix(notifier): use composio entity execution * feat(notifier): add composio setup flows * Fix notifier setup flows * feat: add dashboard notifier * Make notifier payloads semantic v3 * chore: use ao-agent as composio default user * Improve notifier setup and rich notifications * Improve desktop notification UX * Update AO notifier app icon * Remove redundant dashboard notification actions * Potential fix for pull request finding 'CodeQL / Client-side cross-site scripting' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Fix code scanning notifier alerts * Address notifier bot review comments * Address remaining notifier bot reviews * Update notifier integration assertions after merge * Fix notifier test fallout after merge * Address notifier PR review comments * Fix notifier bot follow-up comments * Add notification delivery observability * chore: add notifier logging screenshot * fix: address notifier logging ci failures --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .changeset/config.json | 2 + .changeset/notifier-release-links.md | 14 + .issue-assets/1736-notifier-logging.png | Bin 0 -> 1394911 bytes docs/CLI.md | 4 + docs/observability.md | 12 +- .../cli/__tests__/commands/doctor.test.ts | 16 + .../cli/__tests__/commands/notify.test.ts | 304 ++ packages/cli/__tests__/commands/setup.test.ts | 3895 +++++++++++++- .../cli/__tests__/lib/notify-test.test.ts | 304 ++ packages/cli/__tests__/program.test.ts | 5 + packages/cli/package.json | 8 +- packages/cli/src/commands/doctor.ts | 62 +- packages/cli/src/commands/notify.ts | 197 + packages/cli/src/commands/plugin.ts | 73 +- packages/cli/src/commands/setup.ts | 900 ++-- packages/cli/src/lib/composio-setup.ts | 4531 +++++++++++++++++ packages/cli/src/lib/config-instruction.ts | 48 +- packages/cli/src/lib/dashboard-setup.ts | 291 ++ packages/cli/src/lib/desktop-setup.ts | 725 +++ packages/cli/src/lib/discord-setup.ts | 753 +++ packages/cli/src/lib/notifier-routing.ts | 205 + packages/cli/src/lib/notify-test.ts | 827 +++ packages/cli/src/lib/openclaw-setup.ts | 889 ++++ packages/cli/src/lib/slack-setup.ts | 628 +++ packages/cli/src/lib/webhook-setup.ts | 536 ++ packages/cli/src/program.ts | 2 + packages/core/__tests__/config.test.ts | 4 + .../src/__tests__/config-validation.test.ts | 58 + .../__tests__/dashboard-notifications.test.ts | 146 + .../lifecycle-manager-instrumentation.test.ts | 4 +- .../src/__tests__/lifecycle-manager.test.ts | 218 +- .../src/__tests__/notification-data.test.ts | 168 + .../core/src/__tests__/observability.test.ts | 99 + .../src/__tests__/plugin-registry.test.ts | 64 +- packages/core/src/activity-events.ts | 3 + packages/core/src/config.ts | 10 + packages/core/src/dashboard-notifications.ts | 198 + packages/core/src/global-config.ts | 13 + packages/core/src/index.ts | 61 + packages/core/src/lifecycle-manager.ts | 181 +- packages/core/src/notification-data.ts | 391 ++ .../core/src/notification-observability.ts | 184 + packages/core/src/observability.ts | 49 +- packages/core/src/plugin-registry.ts | 46 +- packages/core/src/types.ts | 13 + .../src/notifier-composio.integration.test.ts | 151 +- .../src/notifier-desktop.integration.test.ts | 12 +- .../src/notifier-openclaw.integration.test.ts | 17 +- .../src/notifier-slack.integration.test.ts | 115 +- .../src/tracker-linear.integration.test.ts | 35 +- packages/notifier-macos/assets/AppIcon.svg | 42 + packages/notifier-macos/package.json | 40 + packages/notifier-macos/scripts/build.mjs | 250 + packages/notifier-macos/scripts/notarize.mjs | 53 + packages/notifier-macos/scripts/sign.mjs | 26 + packages/notifier-macos/src/AONotifier.swift | 394 ++ .../plugins/notifier-composio/package.json | 12 +- .../notifier-composio/src/index.test.ts | 665 ++- .../plugins/notifier-composio/src/index.ts | 1597 +++++- .../plugins/notifier-dashboard/package.json | 44 + .../notifier-dashboard/src/index.test.ts | 126 + .../plugins/notifier-dashboard/src/index.ts | 53 + .../plugins/notifier-dashboard/tsconfig.json | 8 + .../notifier-desktop/src/index.test.ts | 301 +- .../plugins/notifier-desktop/src/index.ts | 496 +- .../notifier-discord/src/index.test.ts | 112 +- .../plugins/notifier-discord/src/index.ts | 364 +- packages/plugins/notifier-openclaw/README.md | 23 +- .../notifier-openclaw/src/index.test.ts | 161 +- .../plugins/notifier-openclaw/src/index.ts | 197 +- .../plugins/notifier-slack/src/index.test.ts | 165 +- packages/plugins/notifier-slack/src/index.ts | 364 +- .../server/__tests__/mux-websocket.test.ts | 151 +- packages/web/server/mux-websocket.ts | 203 +- packages/web/src/app/globals.css | 419 ++ packages/web/src/components/Dashboard.tsx | 2 + .../DashboardNotificationButton.tsx | 486 ++ .../src/components/SessionDetailHeader.tsx | 35 +- .../DashboardNotificationButton.test.tsx | 251 + packages/web/src/lib/mux-protocol.ts | 20 +- packages/web/src/providers/MuxProvider.tsx | 55 +- .../providers/__tests__/MuxProvider.test.tsx | 75 + pnpm-lock.yaml | 857 +--- schema/config.schema.json | 18 + website/content/docs/cli.mdx | 52 +- website/content/docs/plugins/index.mdx | 1 + .../docs/plugins/notifiers/composio.mdx | 148 +- .../docs/plugins/notifiers/dashboard.mdx | 36 + .../docs/plugins/notifiers/desktop.mdx | 28 +- .../docs/plugins/notifiers/discord.mdx | 77 +- .../content/docs/plugins/notifiers/index.mdx | 36 +- .../content/docs/plugins/notifiers/meta.json | 2 +- .../docs/plugins/notifiers/openclaw.mdx | 54 +- .../content/docs/plugins/notifiers/slack.mdx | 62 +- .../docs/plugins/notifiers/webhook.mdx | 110 +- 95 files changed, 23863 insertions(+), 2279 deletions(-) create mode 100644 .changeset/notifier-release-links.md create mode 100644 .issue-assets/1736-notifier-logging.png create mode 100644 packages/cli/__tests__/commands/notify.test.ts create mode 100644 packages/cli/__tests__/lib/notify-test.test.ts create mode 100644 packages/cli/src/commands/notify.ts create mode 100644 packages/cli/src/lib/composio-setup.ts create mode 100644 packages/cli/src/lib/dashboard-setup.ts create mode 100644 packages/cli/src/lib/desktop-setup.ts create mode 100644 packages/cli/src/lib/discord-setup.ts create mode 100644 packages/cli/src/lib/notifier-routing.ts create mode 100644 packages/cli/src/lib/notify-test.ts create mode 100644 packages/cli/src/lib/openclaw-setup.ts create mode 100644 packages/cli/src/lib/slack-setup.ts create mode 100644 packages/cli/src/lib/webhook-setup.ts create mode 100644 packages/core/src/__tests__/dashboard-notifications.test.ts create mode 100644 packages/core/src/__tests__/notification-data.test.ts create mode 100644 packages/core/src/dashboard-notifications.ts create mode 100644 packages/core/src/notification-data.ts create mode 100644 packages/core/src/notification-observability.ts create mode 100644 packages/notifier-macos/assets/AppIcon.svg create mode 100644 packages/notifier-macos/package.json create mode 100644 packages/notifier-macos/scripts/build.mjs create mode 100644 packages/notifier-macos/scripts/notarize.mjs create mode 100644 packages/notifier-macos/scripts/sign.mjs create mode 100644 packages/notifier-macos/src/AONotifier.swift create mode 100644 packages/plugins/notifier-dashboard/package.json create mode 100644 packages/plugins/notifier-dashboard/src/index.test.ts create mode 100644 packages/plugins/notifier-dashboard/src/index.ts create mode 100644 packages/plugins/notifier-dashboard/tsconfig.json create mode 100644 packages/web/src/components/DashboardNotificationButton.tsx create mode 100644 packages/web/src/components/__tests__/DashboardNotificationButton.test.tsx create mode 100644 website/content/docs/plugins/notifiers/dashboard.mdx 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 0000000000000000000000000000000000000000..e6c09b5b19d495c45230bef1e1765d3278b47110 GIT binary patch literal 1394911 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 z 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

J3 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%0Ab(T_#nYT{9d5O%*w1 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 )B9<2^H_mgS<^`cue4Q8Kr-=+hUQ%A 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%0A2+K$%-N~! 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

p3BY{WF85O`|WF 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

Ete&FYb7=Q1IF+s(Di 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

)3#9a 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ⅅ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%0DONeTRn(W(S2YI{OM0gD5(B8EdK=CES{4F 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≫z|z`x>qv@`uT#U88HvET7NKj1%1>XaO?$c=u} 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 UXGexSfDH6YM+2^oQ*#XeAM*bXu>b%7 literal 0 HcmV?d00001 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 ` +

+ `; +} + +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({