diff --git a/packages/engine/src/__tests__/ntfy-provider.test.ts b/packages/engine/src/__tests__/ntfy-provider.test.ts index 7694e2d03..f9dfbe9d6 100644 --- a/packages/engine/src/__tests__/ntfy-provider.test.ts +++ b/packages/engine/src/__tests__/ntfy-provider.test.ts @@ -47,6 +47,7 @@ describe("NtfyNotificationProvider", () => { ["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"], ["message:agent-to-agent", "Triage Bot → Executor Bot", "Triage Bot messaged Executor Bot: preview text", "default"], ["message:room", "#Incident Room — Triage Bot", "Triage Bot in #Incident Room: preview text", "default"], + ["oauth-token-expired", "OAuth token expired", "Your OpenAI Codex OAuth token has expired", "high"], ])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => { await provider.sendNotification(event as any, { taskId: "FN-1", @@ -63,6 +64,8 @@ describe("NtfyNotificationProvider", () => { messageId: "msg-1", roomId: "room-1", roomName: "Incident Room", + providerId: "openai-codex", + providerName: "OpenAI Codex", }, }); @@ -93,6 +96,7 @@ describe("NtfyNotificationProvider", () => { expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true); expect(provider.isEventSupported("message:agent-to-agent" as any)).toBe(true); expect(provider.isEventSupported("message:room" as any)).toBe(true); + expect(provider.isEventSupported("oauth-token-expired" as any)).toBe(true); expect(provider.isEventSupported("custom-event" as any)).toBe(false); }); diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index f751d9679..79ccaf6a6 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -3,7 +3,7 @@ import { ProjectEngine } from "../project-engine.js"; import { runtimeLog } from "../logger.js"; import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js"; import { NtfyNotifier } from "../notifier.js"; -import { NotificationService } from "../notification/index.js"; +import { NotificationService, OAuthExpiryMonitor } from "../notification/index.js"; const mocks = vi.hoisted(() => ({ syncInsightExtractionAutomation: vi.fn(), @@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({ notifierNotifyGridlock: vi.fn(), notificationServiceStart: vi.fn(async () => undefined), notificationServiceStop: vi.fn(), + oauthExpiryMonitorStart: vi.fn(async () => undefined), + oauthExpiryMonitorStop: vi.fn(), runtimeConfigurePrMonitoring: vi.fn(), prHandlerCreateFollowUpTask: vi.fn(async () => undefined), })); @@ -92,6 +94,18 @@ vi.mock("../notification/index.js", () => ({ start: mocks.notificationServiceStart, stop: mocks.notificationServiceStop, })), + OAuthExpiryMonitor: vi.fn().mockImplementation(() => ({ + start: mocks.oauthExpiryMonitorStart, + stop: mocks.oauthExpiryMonitorStop, + })), +})); + +vi.mock("../auth-storage.js", () => ({ + createFusionAuthStorage: vi.fn(() => ({ + reload: vi.fn(), + getOAuthProviders: vi.fn(() => []), + get: vi.fn(() => undefined), + })), })); vi.mock("../runtimes/in-process-runtime.js", () => ({ @@ -244,6 +258,8 @@ beforeEach(() => { mocks.notifierNotifyGridlock.mockClear(); mocks.notificationServiceStart.mockClear(); mocks.notificationServiceStop.mockClear(); + mocks.oauthExpiryMonitorStart.mockClear(); + mocks.oauthExpiryMonitorStop.mockClear(); mocks.execFile.mockImplementation(( _file: string, @@ -277,14 +293,17 @@ describe("ProjectEngine notification ownership wiring", () => { await engine.start(); expect(NotificationService).toHaveBeenCalledTimes(1); + expect(OAuthExpiryMonitor).toHaveBeenCalledTimes(1); expect(NtfyNotifier).toHaveBeenCalledTimes(1); const notifierCtorArgs = vi.mocked(NtfyNotifier).mock.calls[0]; expect(notifierCtorArgs?.[2]).toBe(vi.mocked(NotificationService).mock.results[0]?.value); expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1); + expect(mocks.oauthExpiryMonitorStart).toHaveBeenCalledTimes(1); expect(mocks.notifierStart).toHaveBeenCalledTimes(1); await engine.stop(); + expect(mocks.oauthExpiryMonitorStop).toHaveBeenCalledTimes(1); expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1); expect(mocks.notifierStop).toHaveBeenCalledTimes(1); }); @@ -298,12 +317,26 @@ describe("ProjectEngine notification ownership wiring", () => { // Root cause guard: if ProjectEngine.start is called more than once, it should not // wire a second NotificationService/NtfyNotifier pair for the same store. expect(NotificationService).toHaveBeenCalledTimes(1); + expect(OAuthExpiryMonitor).toHaveBeenCalledTimes(1); expect(NtfyNotifier).toHaveBeenCalledTimes(1); expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1); + expect(mocks.oauthExpiryMonitorStart).toHaveBeenCalledTimes(1); expect(mocks.notifierStart).toHaveBeenCalledTimes(1); await engine.stop(); }); + + it("does not create OAuth expiry monitor when notifier is skipped", async () => { + const engine = createEngine({ skipNotifier: true, projectId: "proj_for_notifier" }); + + await engine.start(); + + expect(NotificationService).not.toHaveBeenCalled(); + expect(OAuthExpiryMonitor).not.toHaveBeenCalled(); + expect(NtfyNotifier).not.toHaveBeenCalled(); + + await engine.stop(); + }); }); describe("ProjectEngine accessors", () => { diff --git a/packages/engine/src/notification/ntfy-provider.ts b/packages/engine/src/notification/ntfy-provider.ts index 54291dfb8..873356a0e 100644 --- a/packages/engine/src/notification/ntfy-provider.ts +++ b/packages/engine/src/notification/ntfy-provider.ts @@ -40,7 +40,8 @@ type SupportedNtfyEvent = | "fallback-used" | "message:agent-to-user" | "message:agent-to-agent" - | "message:room"; + | "message:room" + | "oauth-token-expired"; const SUPPORTED_EVENTS = new Set([ "in-review", @@ -53,6 +54,7 @@ const SUPPORTED_EVENTS = new Set([ "message:agent-to-user", "message:agent-to-agent", "message:room", + "oauth-token-expired", ]); export function resolveParticipantLabel( @@ -169,13 +171,20 @@ export class NtfyNotificationProvider implements NotificationProvider { messageId, view: "rooms", }) - : buildNtfyClickUrl({ - dashboardHost: this.config.dashboardHost, - projectId: this.config.projectId, - taskId: payload.taskId, - messageId, - view: "mailbox", - }); + : event === "oauth-token-expired" + ? undefined + : buildNtfyClickUrl({ + dashboardHost: this.config.dashboardHost, + projectId: this.config.projectId, + taskId: payload.taskId, + messageId, + view: "mailbox", + }); + + const providerId = typeof payload.metadata?.providerId === "string" ? payload.metadata.providerId : "provider"; + const providerName = typeof payload.metadata?.providerName === "string" + ? payload.metadata.providerName + : providerId; const contentByEvent: Record = { "in-review": { @@ -228,6 +237,11 @@ export class NtfyNotificationProvider implements NotificationProvider { message: `${roomSenderLabel} in #${roomLabel}: ${preview}`, priority: "default", }, + "oauth-token-expired": { + title: "OAuth token expired", + message: `Your ${providerName} OAuth token has expired — please re-authenticate`, + priority: "high", + }, }; const content = contentByEvent[event as SupportedNtfyEvent]; diff --git a/packages/engine/src/notification/webhook-provider.ts b/packages/engine/src/notification/webhook-provider.ts index 12bd599e0..45f1c6ccd 100644 --- a/packages/engine/src/notification/webhook-provider.ts +++ b/packages/engine/src/notification/webhook-provider.ts @@ -191,6 +191,11 @@ export class WebhookNotificationProvider implements NotificationProvider { const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)"; return `In #${roomName}: ${senderLabel}: ${preview}`; } + case "oauth-token-expired": { + const providerId = typeof payload.metadata?.providerId === "string" ? payload.metadata.providerId : "provider"; + const providerName = typeof payload.metadata?.providerName === "string" ? payload.metadata.providerName : providerId; + return `Your ${providerName} OAuth token has expired — please re-authenticate`; + } default: return `Event "${event}" for task ${identifier}`; } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 1fa0e18de..638c24df0 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -18,9 +18,10 @@ import type { ProjectRuntimeConfig } from "./project-runtime.js"; import { PrMonitor } from "./pr-monitor.js"; import { PrCommentHandler } from "./pr-comment-handler.js"; import { NtfyNotifier } from "./notifier.js"; -import { NotificationService } from "./notification/index.js"; +import { NotificationService, OAuthExpiryMonitor } from "./notification/index.js"; import type { NotificationChatStore } from "./notification/notification-service.js"; import { GridlockDetector } from "./gridlock-detector.js"; +import { createFusionAuthStorage } from "./auth-storage.js"; import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js"; @@ -152,6 +153,7 @@ export class ProjectEngine { private prCommentHandler?: PrCommentHandler; private notifier?: NtfyNotifier; private notificationService?: NotificationService; + private oauthExpiryMonitor?: OAuthExpiryMonitor; private gridlockDetector?: GridlockDetector; private cronRunner?: CronRunner; private automationStore?: AutomationStoreType; @@ -327,6 +329,11 @@ export class ProjectEngine { agentNameResolver, }); await this.notificationService.start(); + this.oauthExpiryMonitor = new OAuthExpiryMonitor({ + authStorage: createFusionAuthStorage(), + notificationService: this.notificationService, + }); + await this.oauthExpiryMonitor.start(); // Backward-compatibility shim for gridlock notifications. this.notifier = new NtfyNotifier( @@ -546,6 +553,7 @@ export class ProjectEngine { } // Stop auxiliary subsystems + this.oauthExpiryMonitor?.stop(); this.notificationService?.stop(); this.notifier?.stop(); this.gridlockDetector?.stop();