From 667ac48aa543d6437fecd551948335010c8cb8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 16:37:07 -0400 Subject: [PATCH] feat(04-01): refactor all notify() call sites to use structured NotificationOptions - Change ipc-support.ts notify() from (title, body) to (options: NotificationOptions) - Wire createNotificationService in bootstrap-application.ts with lazy service graph refs - Update all 12 call sites with entity context, urgency, navigation, and batch keys - Update dependency interfaces in service-graph, worker, orchestration, automation, updater, desktop-shell - No string-based notify() calls remain in src/main/ Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/automation-service.ts | 32 +++++++++++++++++++++---------- src/main/bootstrap-application.ts | 29 ++++++++++++++++++++-------- src/main/desktop-shell-service.ts | 10 ++++++++-- src/main/ipc-support.ts | 12 +++++++++--- src/main/orchestration-service.ts | 11 +++++++++-- src/main/service-graph.ts | 3 ++- src/main/updater-service.ts | 24 +++++++++++++++++++---- src/main/worker-service.ts | 26 +++++++++++++++++++++---- 8 files changed, 113 insertions(+), 34 deletions(-) diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts index d1d6173f..0da9b16a 100644 --- a/src/main/automation-service.ts +++ b/src/main/automation-service.ts @@ -8,6 +8,7 @@ import type { RunStatus, } from "@shared/types"; import { dispatchAgentMessage } from "./message-dispatch"; +import type { NotificationOptions } from "./notification-service"; const WORKFLOW_ACTIONS: ReadonlySet = new Set([ "assign_task", @@ -59,7 +60,7 @@ interface AutomationDependencies { }; emitEvent: (event: DesktopEvent) => void; publishDomainChanged: () => void; - notify: (title: string, body: string) => void; + notify: (options: NotificationOptions) => void; wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; dispatchBrowserAction: (actionId: string, companyId: string) => Promise; } @@ -909,16 +910,21 @@ export function createAutomationService({ }); // Fire OS notification for budget threshold warning const pct = Math.round(utilization * 100); - notify( - "Budget Alert", - `${agent.name} is at ${pct}% of monthly budget ($${agent.spentMonthlyUsd.toFixed(2)} / $${agent.budgetMonthlyUsd.toFixed(2)})`, - ); + notify({ + title: "Budget Warning", + body: `${agent.name} has used ${pct}% of monthly budget`, + urgency: "informational", + navigation: { section: "costs" }, + batchKey: "budget_alerts", + }); if (utilization >= 1.0) { // Fire OS notification for budget exhaustion (agent auto-paused by database) - notify( - "Budget Alert", - `${agent.name} paused -- monthly budget exhausted ($${agent.spentMonthlyUsd.toFixed(2)} / $${agent.budgetMonthlyUsd.toFixed(2)})`, - ); + notify({ + title: "Budget Exhausted", + body: `${agent.name} has reached 100% of monthly budget and is paused`, + urgency: "critical", + navigation: { section: "costs" }, + }); } } } @@ -957,7 +963,13 @@ export function createAutomationService({ }); wakeAgentIfPossible(agent.id, companyId, "assignment"); publishDomainChanged(); - notify("New agent hired", `${agent.name} (${agent.role}) has joined the company.`); + notify({ + title: "Agent Hired", + body: `${agent.name} joined as ${agent.role}`, + urgency: "informational", + navigation: { section: "orgchart", entityId: agent.id }, + batchKey: "agent_hired", + }); } } catch { // Hire follow-up is best-effort; approval state is already committed in storage. diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts index e607b000..a92ce720 100644 --- a/src/main/bootstrap-application.ts +++ b/src/main/bootstrap-application.ts @@ -6,6 +6,8 @@ import type { BrowserManager } from "./browser-manager"; import { prepareHostEnvironment } from "./host-environment-service"; import { createServiceGraph } from "./service-graph"; import { createIpcSupport } from "./ipc-support"; +import { createNotificationService } from "./notification-service"; +import type { NotificationOptions } from "./notification-service"; import type { DesktopEvent } from "@shared/types"; interface BootstrapApplicationDependencies { @@ -73,11 +75,19 @@ export async function bootstrapApplication({ const apiServerRef = { current: null as AgentApiServer | null }; const browserManagerRef = { current: null as BrowserManager | null }; let ipcSupport: ReturnType | null = null; - const notify = (title: string, body: string) => { + let serviceGraph: ReturnType | null = null; + + const notificationService = createNotificationService({ + getMainWindow: () => serviceGraph?.getDesktopShellService()?.getMainWindow() ?? null, + emitEvent: (event: DesktopEvent) => ipcSupport?.emitEvent(event), + logger: log, + }); + + const notify = (options: NotificationOptions) => { if (!ipcSupport) { throw new Error("IPC support is not initialized."); } - ipcSupport.notify(title, body); + ipcSupport.notify(options); }; const emitEvent = (event: DesktopEvent) => { ipcSupport?.emitEvent(event); @@ -86,7 +96,7 @@ export async function bootstrapApplication({ ipcSupport?.publishDomainChanged(); }; - const serviceGraph = createServiceGraph({ + serviceGraph = createServiceGraph({ db, apiServerRef, browserManagerRef, @@ -109,11 +119,12 @@ export async function bootstrapApplication({ isDev, devServerOrigin, logger: log, - getMainWindow: () => serviceGraph.getDesktopShellService()?.getMainWindow() ?? null, + getMainWindow: () => serviceGraph?.getDesktopShellService()?.getMainWindow() ?? null, getDb: () => ({ hasCompany: (companyId: string) => db.hasCompany(companyId), belongsToCompany: (table: string, id: string, companyId: string) => db.belongsToCompany(table as any, id, companyId), }), + notificationService, }); serviceGraph.init(); @@ -242,10 +253,12 @@ export async function bootstrapApplication({ type: "recovery-needed", interruptedRuns, } as any); // DesktopEvent will need extending - notify( - "Interrupted runs detected", - `${interruptedRuns.length} task(s) were interrupted when the app last closed. Check the dashboard for recovery options.` - ); + notify({ + title: "Session Recovered", + body: `${interruptedRuns.length} interrupted task(s) detected from previous session`, + urgency: "informational", + navigation: { section: "overview" }, + }); } } diff --git a/src/main/desktop-shell-service.ts b/src/main/desktop-shell-service.ts index 3d99549c..c0db9818 100644 --- a/src/main/desktop-shell-service.ts +++ b/src/main/desktop-shell-service.ts @@ -15,6 +15,7 @@ import { Tray, } from "electron"; import { attachWindowStatePersistence, loadWindowState } from "./window-state"; +import type { NotificationOptions } from "./notification-service"; interface DesktopShellDependencies { preloadPath: string; @@ -28,7 +29,7 @@ interface DesktopShellDependencies { onCheckForUpdates: () => Promise; onBackupProfile: () => Promise; onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void; - notify: (title: string, body: string) => void; + notify: (options: NotificationOptions) => void; } function isSocialBrowserSession(contents: Electron.WebContents) { @@ -155,7 +156,12 @@ export function createDesktopShellService({ mainWindow?.hide(); if (!backgroundCloseNoticeShown) { backgroundCloseNoticeShown = true; - notify("AgentCompany is still running", "Automation continues in the background until you quit the app."); + notify({ + title: "Running in Background", + body: "AgentCompany is still running in the system tray", + urgency: "informational", + navigation: { section: "overview" }, + }); } }); mainWindow.on("closed", () => { diff --git a/src/main/ipc-support.ts b/src/main/ipc-support.ts index fe37cd4a..0de42a7c 100644 --- a/src/main/ipc-support.ts +++ b/src/main/ipc-support.ts @@ -3,6 +3,7 @@ import { ipcMain, Notification } from "electron"; import { ZodError } from "zod"; import type { DesktopEvent } from "@shared/types"; import type { DesktopResult } from "@shared/contracts"; +import type { NotificationOptions } from "./notification-service"; interface IpcSupportDependencies { isDev: boolean; @@ -15,6 +16,7 @@ interface IpcSupportDependencies { hasCompany: (companyId: string) => boolean; belongsToCompany: (table: string, id: string, companyId: string) => boolean; }; + notificationService?: { notify: (options: NotificationOptions) => void }; } function isTrustedRendererUrl(url: string, isDev: boolean, devServerOrigin: string | null) { @@ -39,6 +41,7 @@ export function createIpcSupport({ logger, getMainWindow, getDb, + notificationService, }: IpcSupportDependencies) { function sanitizeHandlerError(error: unknown) { if (error instanceof ZodError) { @@ -102,9 +105,12 @@ export function createIpcSupport({ emitEvent({ type: "domain-changed" }); } - function notify(title: string, body: string) { - if (Notification.isSupported()) { - new Notification({ title, body }).show(); + function notify(options: NotificationOptions) { + if (notificationService) { + notificationService.notify(options); + } else if (Notification.isSupported()) { + // Fallback for early bootstrap before notification service is wired + new Notification({ title: options.title, body: options.body }).show(); } } diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts index 30b869d3..65c665a5 100644 --- a/src/main/orchestration-service.ts +++ b/src/main/orchestration-service.ts @@ -3,6 +3,7 @@ import type { AppDatabase } from "./database"; import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; import type { DesktopResult } from "@shared/contracts"; import type { ConnectorRecord } from "@shared/types"; +import type { NotificationOptions } from "./notification-service"; type WorkspaceLockMode = "exclusive" | "shared" | "none"; @@ -53,7 +54,7 @@ interface OrchestrationDependencies { error: (...args: unknown[]) => void; }; publishDomainChanged: () => void; - notify: (title: string, body: string) => void; + notify: (options: NotificationOptions) => void; createRunToken: (agentId: string, companyId: string, runId: string) => string; getApiPort: () => number; dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void; @@ -128,7 +129,13 @@ export function createOrchestrationService({ entityId: agent.id, detail: `Budget exhausted at ${agent.spentMonthlyUsd.toFixed(2)} / ${agent.budgetMonthlyUsd.toFixed(2)} USD.`, }); - notify("Agent paused for budget limit", agent.name); + notify({ + title: "Agent Paused", + body: `${agent.name} paused -- budget limit reached`, + urgency: "informational", + navigation: { section: "agents", entityId: agent.id }, + batchKey: "agent_paused", + }); publishDomainChanged(); return true; } diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts index 54ff82d5..a293ea7b 100644 --- a/src/main/service-graph.ts +++ b/src/main/service-graph.ts @@ -12,6 +12,7 @@ import { createWorkerService } from "./worker-service"; import { createAppLifecycleService } from "./app-lifecycle-service"; import type { DesktopEvent, RunRecord } from "@shared/types"; import type { AutomationEventContext } from "./automation-service"; +import type { NotificationOptions } from "./notification-service"; interface ServiceGraphDependencies { db: AppDatabase; @@ -32,7 +33,7 @@ interface ServiceGraphDependencies { }; emitEvent: (event: DesktopEvent) => void; publishDomainChanged: () => void; - notify: (title: string, body: string) => void; + notify: (options: NotificationOptions) => void; createRunToken: (agentId: string, companyId: string, runId: string) => string; seedDefaultAutomationRules: (companyId: string) => void; } diff --git a/src/main/updater-service.ts b/src/main/updater-service.ts index b23c4c9c..d53fd99c 100644 --- a/src/main/updater-service.ts +++ b/src/main/updater-service.ts @@ -1,5 +1,6 @@ import electronUpdater from "electron-updater"; import type { AppDatabase } from "./database"; +import type { NotificationOptions } from "./notification-service"; const { autoUpdater } = electronUpdater; @@ -9,7 +10,7 @@ interface UpdaterServiceDependencies { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void; }; - notify: (title: string, body: string) => void; + notify: (options: NotificationOptions) => void; } export function createUpdaterService({ @@ -22,14 +23,29 @@ export function createUpdaterService({ logger.info("[updater] checking for updates"); }); autoUpdater.on("update-available", (info) => { - notify("Update available", `AgentCompany ${info.version} is available to download.`); + notify({ + title: "Update Available", + body: `Version ${info.version} is ready to install`, + urgency: "informational", + navigation: { section: "settings" }, + }); }); autoUpdater.on("update-not-available", () => { - notify("No updates found", "AgentCompany is already on the latest configured version."); + notify({ + title: "Up to Date", + body: "You are running the latest version", + urgency: "informational", + navigation: { section: "settings" }, + }); }); autoUpdater.on("error", (error) => { logger.error("[updater] error", error); - notify("Update check failed", error instanceof Error ? error.message : String(error)); + notify({ + title: "Update Check Failed", + body: "Could not check for updates. Try again later.", + urgency: "informational", + navigation: { section: "settings" }, + }); }); } diff --git a/src/main/worker-service.ts b/src/main/worker-service.ts index 9a87ee34..a56044d6 100644 --- a/src/main/worker-service.ts +++ b/src/main/worker-service.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { getConnectorDefinition } from "./connectors"; import type { AppDatabase } from "./database"; import type { RunRecord } from "@shared/types"; +import type { NotificationOptions } from "./notification-service"; const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]); export const workerMessageSchema = z.union([ @@ -61,7 +62,7 @@ interface WorkerServiceDependencies { trigger: string; }) => void; processRunCompletion: (run: RunRecord, event: z.infer & { type: "run-finished" }) => void; - notify: (title: string, body: string) => void; + notify: (options: NotificationOptions) => void; } export function createWorkerService({ @@ -284,7 +285,13 @@ export function createWorkerService({ message: event.summary ?? event.errorMessage ?? event.status, }); if ((event.status === "failed" || event.status === "timed_out" || event.status === "interrupted") && task) { - notify("Run attention required", task.title); + notify({ + title: "Task Failed", + body: `${task.title} -- manual intervention needed`, + urgency: "informational", + navigation: { section: "tasks", entityId: task.id }, + batchKey: "failed_runs", + }); } let updatedAgent: ReturnType | null = null; @@ -303,7 +310,13 @@ export function createWorkerService({ entityId: updatedAgent.id, detail: `Budget exhausted at ${updatedAgent.spentMonthlyUsd.toFixed(2)} / ${updatedAgent.budgetMonthlyUsd.toFixed(2)} USD.`, }); - notify("Agent paused for budget limit", updatedAgent.name); + notify({ + title: "Agent Paused", + body: `${updatedAgent.name} paused -- budget limit reached`, + urgency: "informational", + navigation: { section: "agents", entityId: updatedAgent.id }, + batchKey: "agent_paused", + }); publishDomainChanged(); } @@ -351,7 +364,12 @@ export function createWorkerService({ if (!escalated) { // No one to escalate to -- notify user const taskTitle = task?.title ?? run.taskId; - notify("Task failed -- manual intervention needed", `"${taskTitle}" failed ${MAX_RETRY_ATTEMPTS} times and no manager is available to handle it.`); + notify({ + title: "Attention Required", + body: `${taskTitle} needs manual intervention`, + urgency: "critical", + navigation: { section: "tasks", entityId: run.taskId }, + }); logger.error(`[escalation] Task ${run.taskId} failed with no escalation target. User notified.`); } }