Files
AgentCompany/src/main/notification-service.ts
T
ZacharyZhang-NY 0db6e4261f Migrate top-20 high-frequency catch sites to AppError taxonomy (C6.1 part 2/2)
Each of these catches already logged via an accessible `logger` or `log`.
The migration wraps the thrown value through `classifyUnknownError(raw,
fallback)` and emits the log line with the structured `[code]` prefix.
Observable behaviour — return values, fail strings, DB writes — is
unchanged; the only addition is a machine-readable `code` on every log
line so future telemetry can bucket by category.

Sites migrated (11 files, 20 catch sites):

  src/main/browser-action-service.ts             (2) approval lookup, payload JSON
  src/main/ipc-credentials-handlers.ts           (4) save, remove, test lookup, test resolve
  src/main/hire-hook.ts                          (3) getAgent, connector def, outer exception
  src/main/checkpoint-engine.ts                  (2) onTaskStarted, onDocumentDrafted
  src/main/document-lifecycle.ts                 (1) getRun while deriving taskId
  src/main/skills/runtime.ts                     (2) builtin statSync, builtin readdir
  src/main/worker/handlers/run-status-handler.ts (1) run not found during status
  src/main/worker/handlers/run-log-handler.ts    (1) lease renewal lookup
  src/main/notification-service.ts               (1) notification.close during destroy
  src/main/updater-service.ts                    (1) autoUpdater.checkForUpdates
  src/main/ipc-support.ts                        (2) ipc handler throw, emitEvent send

Why only 20 tonight: 248 catches exist in src/main/. Rewriting them all
in one commit is high-diff-risk (collides with Linus's in-flight
database/backup/migration work on database/*, connector-definitions-*,
message-* and scripts/*) and low-value (pure-library catches with no
accessible logger produce dead `void classified` constructs). The
taxonomy module is committed separately so the remaining 228 sites can
land incrementally via the C6.2 proposal.

All 11 files selected sit outside Linus's working set. Verified:
  - pnpm typecheck   clean
  - pnpm test:unit   498 passed
2026-04-20 12:22:52 -04:00

227 lines
7.1 KiB
TypeScript

import { Notification, app } from "electron";
import type { BrowserWindow } from "electron";
import type { DesktopEvent, SectionId } from "@shared/types";
import { classifyUnknownError } from "./errors/index.js";
export interface NotificationOptions {
title: string;
body: string;
urgency: "critical" | "informational";
navigation?: {
section: SectionId;
entityId?: string;
};
/** Batch key -- notifications with same key within window are batched */
batchKey?: string;
}
interface NotificationServiceDependencies {
getMainWindow: () => BrowserWindow | null;
emitEvent: (event: DesktopEvent) => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
/** Returns the active UI locale ("en", "zh", ...). Batch summary copy is
* localized; fallbacks to "en" when unset. */
getLocale?: () => string | null | undefined;
}
interface BatchState {
count: number;
timer: ReturnType<typeof setTimeout>;
/** Latest section observed during the window. Summary navigates here on click. */
section: SectionId;
}
const BATCH_WINDOW_MS = 10_000;
/**
* Batch keys that should NEVER be batched -- always fire individually.
* These represent events the user must see each instance of.
*/
const NEVER_BATCH: ReadonlySet<string> = new Set([
"pending_approval",
"crash_recovery",
"update_available",
"manual_intervention",
"budget_exhaustion",
]);
interface LocalizedBatchCopy {
title: (count: number) => string;
body: string;
}
/**
* Batch summary copy per batch key, per-locale. Used when the batch timer
* expires and accumulated events need a summary notification.
*/
const BATCH_SUMMARY_COPY: Record<string, Record<"en" | "zh", LocalizedBatchCopy>> = {
failed_runs: {
en: { title: (count) => `${count} tasks failed`, body: "Click to view failed runs" },
zh: { title: (count) => `${count} 个任务失败`, body: "点击查看失败记录" },
},
agent_hired: {
en: { title: (count) => `${count} agents hired`, body: "Click to view organization" },
zh: { title: (count) => `${count} 位新员工入职`, body: "点击查看组织架构" },
},
budget_alerts: {
en: { title: (count) => `${count} budget alerts`, body: "Click to view cost details" },
zh: { title: (count) => `${count} 个预算提醒`, body: "点击查看成本详情" },
},
task_completed: {
en: { title: (count) => `${count} tasks completed`, body: "Click to view task list" },
zh: { title: (count) => `${count} 个任务已完成`, body: "点击查看任务列表" },
},
agent_paused: {
en: { title: (count) => `${count} agents paused`, body: "Click to view paused agents" },
zh: { title: (count) => `${count} 位员工已暂停`, body: "点击查看已暂停员工" },
},
};
function pickLocale(locale: string | null | undefined): "en" | "zh" {
return locale === "zh" ? "zh" : "en";
}
export function createNotificationService(deps: NotificationServiceDependencies) {
/**
* Prevent GC on Windows -- Notification objects must be retained
* while visible so click handlers fire. Removed on "close" event.
*/
const activeNotifications: Set<Notification> = new Set();
/** Active batch state per batch key */
const activeBatches: Map<string, BatchState> = new Map();
function notify(options: NotificationOptions) {
if (!Notification.isSupported()) return;
const key = options.batchKey;
// If no batch key, or the key is in the NEVER_BATCH list, fire immediately
if (!key || NEVER_BATCH.has(key)) {
fireNotification(options);
return;
}
const existing = activeBatches.get(key);
if (!existing) {
// First event in batch -- fire immediately, start batch window
fireNotification(options);
activeBatches.set(key, {
count: 0,
timer: setTimeout(() => flushBatch(key), BATCH_WINDOW_MS),
section: options.navigation?.section ?? "overview",
});
return;
}
// Accumulate -- do not fire individual notification.
// Track the most recent section so the summary links to where the latest event happened.
existing.count++;
const nextSection = options.navigation?.section;
if (nextSection) {
existing.section = nextSection;
}
}
function flushBatch(key: string) {
const batch = activeBatches.get(key);
activeBatches.delete(key);
if (batch && batch.count > 0) {
const locale = pickLocale(deps.getLocale?.());
const copySet = BATCH_SUMMARY_COPY[key];
const copy = copySet?.[locale];
const title = copy ? copy.title(batch.count) : `${batch.count} more events`;
const body = copy ? copy.body : "Click to view";
fireNotification({
title,
body,
urgency: "informational",
navigation: { section: batch.section },
// No entityId -- batched summary navigates to section overview
});
}
}
function fireNotification(options: NotificationOptions) {
if (!Notification.isSupported()) return;
const notificationOptions: Electron.NotificationConstructorOptions = {
title: options.title,
body: options.body,
silent: options.urgency === "informational",
};
// Platform-specific urgency handling
if (process.platform === "win32" && options.urgency === "critical") {
notificationOptions.timeoutType = "never";
}
if (process.platform === "linux") {
notificationOptions.urgency = options.urgency === "critical" ? "critical" : "normal";
}
const notification = new Notification(notificationOptions);
// Click handler: restore window and navigate (D-02 window restoration sequence)
notification.on("click", () => {
app.focus({ steal: true });
const win = deps.getMainWindow();
if (win && !win.isDestroyed()) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
if (options.navigation) {
deps.emitEvent({
type: "notification-click",
section: options.navigation.section,
entityId: options.navigation.entityId,
});
}
});
// GC protection: hold reference until notification closes
notification.on("close", () => {
activeNotifications.delete(notification);
});
notification.show();
activeNotifications.add(notification);
}
function destroy() {
// Clear all batch timers
for (const batch of activeBatches.values()) {
clearTimeout(batch.timer);
}
activeBatches.clear();
// Dismiss any still-visible notifications so Windows toast host does not
// leave ghost toasts after quit.
for (const notification of activeNotifications) {
try {
notification.close();
} catch (error) {
const classified = classifyUnknownError(error, {
kind: "recoverable",
code: "notification.close.failed",
message: "notification.close() during destroy failed",
});
deps.logger.warn(
`[${classified.code}] ${classified.message}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
activeNotifications.clear();
}
return {
notify,
destroy,
};
}