Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ import {
MAX_LIVE_WORKFLOW_ITEM_ACTIVITY_EVENTS,
} from '@/shared/workflow/activity';
import { shouldScheduleItemAssignmentTask } from '@/shared/workflow/item-assignment';
import { NotificationManager } from '@/electron/main/notifications/notification-manager';
import { MacOSNotifier } from '@/electron/main/notifications/macos-notifier';
import { TelegramNotifier } from '@/electron/main/notifications/telegram-notifier';
import {
NotificationTrigger,
type NotificationSettingsPatch,
} from '@/electron/main/notifications/types';

if (started) {
app.quit();
Expand All @@ -82,6 +89,7 @@ if (started) {
let mainWindow: BrowserWindow | null = null;
let networkProxyManager: NetworkProxyManager | null = null;
let runtimeController: DesktopRuntimeController | null = null;
let notificationManager: NotificationManager | null = null;
let nudgeScheduled = false;
let nudgeIntervalHandle: ReturnType<typeof setInterval> | null = null;
let taskSweepIntervalHandle: ReturnType<typeof setInterval> | null = null;
Expand Down Expand Up @@ -292,6 +300,7 @@ const quitCoordinator = createQuitCoordinator({
clearInterval(taskSweepIntervalHandle);
taskSweepIntervalHandle = null;
}
notificationManager?.stop();
stopPowerBlocker();
await runtimeController?.shutdown();
},
Expand All @@ -315,6 +324,15 @@ function requireNetworkProxyManager() {
return networkProxyManager;
}

/** Returns notification manager or throws. */
function requireNotificationManager() {
if (!notificationManager) {
throw new Error('Notification manager is unavailable.');
}

return notificationManager;
}

/** Creates window. */
const createWindow = () => {
mainWindow = new BrowserWindow(
Expand Down Expand Up @@ -437,6 +455,59 @@ function recordDuneScheduledTaskEvent(
);
}

function formatCurrency(value: number) {
return `$${value.toFixed(value >= 10 ? 0 : 2).replace(/\.00$/, '')}`;
}

function describeAgentErrorContext(context: string) {
switch (context) {
case 'message-dispatch':
return 'while dispatching a message';
case 'runtime-restart':
return 'while restarting';
case 'runtime-rotate':
return 'while rotating its compacted session';
case 'runtime-start':
return 'while starting';
case 'scheduled-task':
return 'while running a scheduled task';
default:
return 'during runtime work';
}
}

async function notifyWorkflowStatusTransitions(previous: unknown, next: unknown) {
if (!notificationManager || !isWorkflowSnapshotLike(previous) || !isWorkflowSnapshotLike(next)) {
return;
}

const previousItemsById = new Map(previous.items.map((item) => [item.id, item]));

for (const item of next.items) {
const previousItem = previousItemsById.get(item.id);

if (!previousItem || previousItem.status === item.status) {
continue;
}

if (item.status === 'review') {
await notificationManager.notify(NotificationTrigger.ItemReview, {
title: 'Work item moved to review',
body: item.title,
itemId: item.id,
});
}

if (item.status === 'acceptance') {
await notificationManager.notify(NotificationTrigger.ItemAcceptance, {
title: 'Work item moved to acceptance',
body: item.title,
itemId: item.id,
});
}
}
}

void app.whenReady().then(async () => {
const agentLiteHomeDir = process.env.DUNE_AGENTLITE_HOME_DIR;
const duneHomeDir = agentLiteHomeDir ?? os.homedir();
Expand Down Expand Up @@ -723,9 +794,17 @@ void app.whenReady().then(async () => {
await compactWorkflowActivity(value);
}
await stores.workflow.set(key, value);
await notifyWorkflowStatusTransitions(previous, value);
},
} satisfies AppStorage;

notificationManager = new NotificationManager({
getAgents: () => runtimeController?.getSnapshot().agents ?? [],
macosNotifier: new MacOSNotifier(() => mainWindow),
store: stores.settings,
telegramNotifier: new TelegramNotifier(() => runtimeController?.getTelegramBridge() ?? null),
});

/** Resolves store. */
function resolveStore(name: string): AppStorage {
if (name === 'workflow') {
Expand Down Expand Up @@ -791,9 +870,23 @@ void app.whenReady().then(async () => {
agentStore: stores.agents,
bundledAgentDir: path.join(app.getAppPath(), 'agent'),
...(agentLiteHomeDir ? { homeDir: agentLiteHomeDir } : {}),
onAgentError: ({ agentId, agentName, context, error }) => {
void requireNotificationManager().notify(NotificationTrigger.AgentError, {
title: 'Agent error',
body: `${agentName} hit an error ${describeAgentErrorContext(context)}. ${error}`,
itemId: agentId,
});
},
onAgentIdle: (_agentId) => {
void nudgeIdleMainAgents(requireRuntimeController, workflowStore);
},
onBudgetWarning: ({ agentId, agentName, thresholdUsd, totalCostUsd }) => {
void requireNotificationManager().notify(NotificationTrigger.BudgetWarning, {
title: 'Budget warning',
body: `${agentName} crossed the ${formatCurrency(thresholdUsd)} warning threshold at ${formatCurrency(totalCostUsd)}.`,
itemId: agentId,
});
},
onItemActivityChanged: (payload) => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(ipcChannels.itemActivityUpdated, payload);
Expand Down Expand Up @@ -832,6 +925,7 @@ void app.whenReady().then(async () => {
settingsStore: stores.settings,
});
await runtimeController.start();
requireNotificationManager().startIdleCheck();

// Periodic check: nudge idle project-main agents when inbox is empty
nudgeIntervalHandle = setInterval(() => {
Expand Down Expand Up @@ -1023,6 +1117,20 @@ void app.whenReady().then(async () => {
return requireRuntimeController().runIsolatedResearch(agentId, input);
},
);
ipcMain.handle(ipcChannels.getNotificationSettings, async () =>
requireNotificationManager().getSettings(),
);
ipcMain.handle(
ipcChannels.updateNotificationSettings,
async (_event, patch: NotificationSettingsPatch) =>
requireNotificationManager().updateSettings(patch),
);
ipcMain.handle(ipcChannels.getNotificationHistory, async () =>
requireNotificationManager().getHistory(),
);
ipcMain.handle(ipcChannels.clearNotificationHistory, async () =>
requireNotificationManager().clearHistory(),
);

ipcMain.handle(ipcChannels.storageGet, async (_event, store: string, key: string) =>
resolveStore(store).get(key),
Expand Down
57 changes: 57 additions & 0 deletions src/electron/main/notifications/macos-notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// macOS notification wrapper.

import {
BrowserWindow,
Notification,
} from 'electron';

export interface MacOSNotificationPayload {
title: string;
body: string;
}

/** Sends Electron main-process notifications on macOS. */
export class MacOSNotifier {
constructor(
private readonly getMainWindow: () => BrowserWindow | null = () => null,
) {}

/** Sends a system notification when available. */
send(payload: MacOSNotificationPayload): boolean {
if (process.platform !== 'darwin' || !Notification.isSupported()) {
return false;
}

const notification = new Notification({
title: payload.title,
body: payload.body,
});

notification.on('click', () => {
this.focusAppWindow();
});
notification.show();

return true;
}

private focusAppWindow() {
const mainWindow = this.getMainWindow()
?? BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())
?? null;

if (!mainWindow) {
return;
}

if (mainWindow.isMinimized()) {
mainWindow.restore();
}

if (!mainWindow.isVisible()) {
mainWindow.show();
}

mainWindow.focus();
}
}
48 changes: 48 additions & 0 deletions src/electron/main/notifications/notification-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Notification history tests.

import { describe, expect, it } from 'vitest';

import { NotificationHistory } from './notification-history';
import {
NotificationChannel,
NotificationTrigger,
} from './types';

describe('NotificationHistory', () => {
it('keeps only the latest 50 records in reverse chronological order', () => {
const history = new NotificationHistory();

for (let index = 0; index < 55; index += 1) {
history.add({
id: `notification-${index}`,
timestamp: index,
trigger: NotificationTrigger.ItemReview,
channel: NotificationChannel.MacOS,
title: `Title ${index}`,
body: `Body ${index}`,
});
}

const records = history.getAll();

expect(records).toHaveLength(50);
expect(records[0]?.id).toBe('notification-54');
expect(records.at(-1)?.id).toBe('notification-5');
});

it('clears the in-memory log', () => {
const history = new NotificationHistory();

history.add({
id: 'notification-1',
timestamp: 1,
trigger: NotificationTrigger.ItemReview,
channel: NotificationChannel.MacOS,
title: 'Title',
body: 'Body',
});
history.clear();

expect(history.getAll()).toEqual([]);
});
});
25 changes: 25 additions & 0 deletions src/electron/main/notifications/notification-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// In-memory rolling notification history.

import type { NotificationRecord } from './types';

const MAX_HISTORY_ENTRIES = 50;

/** Rolling notification history store. */
export class NotificationHistory {
private records: NotificationRecord[] = [];

/** Adds a record to the top of the history. */
add(record: NotificationRecord) {
this.records = [record, ...this.records].slice(0, MAX_HISTORY_ENTRIES);
}

/** Returns the current history, newest first. */
getAll(): NotificationRecord[] {
return this.records.map((record) => ({ ...record }));
}

/** Clears the current history. */
clear() {
this.records = [];
}
}
Loading