Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
app,
BrowserWindow,
Notification,
session,
} from 'electron';
import fixPath from 'fix-path';
Expand Down Expand Up @@ -102,6 +103,21 @@ void app.whenReady().then(async () => {
};
const workflowCoordinator = createWorkflowCoordinator({
getRuntimeController,
notifySla: (notification) => {
if (!Notification.isSupported()) {
return;
}

const body = notification.type === 'sla_warning' && typeof notification.msLeft === 'number'
? `${Math.ceil(notification.msLeft / 3_600_000)}h ${Math.ceil((notification.msLeft % 3_600_000) / 60_000)}m remaining`
: 'Deadline has passed';
new Notification({
body,
title: notification.type === 'sla_warning'
? `SLA expiring soon: ${notification.itemTitle}`
: `SLA breached: ${notification.itemTitle}`,
}).show();
},
notifyWorkflowChanged: () => {
broadcast(ipcChannels.workflowChanged);
},
Expand Down
62 changes: 61 additions & 1 deletion src/electron/main/agent-actions/handlers/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity';
import { createDefaultTasks } from '@/shared/workflow/default-tasks';
import { createArtifactFolderName } from '@/shared/workflow/project-artifacts';
import { ensureProjectArtifactFolder } from '@/electron/main/workflow/project-artifacts';
import {
isItemPriority,
normalizeSlaDeadlineMs,
} from '@/shared/workflow/priority-sla';

import {
optionalString,
Expand Down Expand Up @@ -100,6 +104,7 @@ export const itemTools: RegisteredTool[] = [
createdAt: now,
id: itemId,
primaryAgentId: null,
priority: 'medium',
projectId,
scheduledTaskId: null,
sortOrder: snapshot.items.filter((item) => item.projectId === projectId && item.status === status).length,
Expand Down Expand Up @@ -136,6 +141,8 @@ export const itemTools: RegisteredTool[] = [
itemId: stringSchema,
note: optionalStringSchema,
primaryAgentId: { type: ['string', 'null'] },
priority: { enum: ['critical', 'high', 'medium', 'low'] },
slaDeadlineMs: { type: ['number', 'null'] },
title: optionalStringSchema,
},
['itemId'],
Expand All @@ -151,6 +158,8 @@ export const itemTools: RegisteredTool[] = [

const touchesDetails = args.title !== undefined || args.brief !== undefined;
const touchesAssignment = args.primaryAgentId !== undefined;
const touchesPriority = args.priority !== undefined;
const touchesSla = args.slaDeadlineMs !== undefined;

if (touchesDetails) {
assertAgentCanEditItem(item);
Expand Down Expand Up @@ -201,7 +210,58 @@ export const itemTools: RegisteredTool[] = [
}
}

if (!touchesDetails && !touchesAssignment) {
if (touchesPriority) {
const nextPriority = args.priority;

if (!isItemPriority(nextPriority)) {
throw new ToolHandlerError('validation-error', 'Priority must be one of: critical, high, medium, low.');
}

if (item.priority !== nextPriority) {
const previousPriority = item.priority;
item.priority = nextPriority;
prependWorkflowEvents(item, [
createWorkflowEvent(
'item',
`Priority changed from ${previousPriority} to ${nextPriority}.`,
now,
agentContext.agentName,
),
]);
}
}

if (touchesSla) {
const nextDeadline = args.slaDeadlineMs === null
? undefined
: normalizeSlaDeadlineMs(args.slaDeadlineMs);

if (args.slaDeadlineMs !== null && nextDeadline === undefined) {
throw new ToolHandlerError('validation-error', 'slaDeadlineMs must be a positive Unix millisecond timestamp or null.');
}

if (item.slaDeadlineMs !== nextDeadline) {
if (nextDeadline === undefined) {
delete item.slaDeadlineMs;
} else {
item.slaDeadlineMs = nextDeadline;
}
item.slaWarnedAt = null;
item.slaBreachedAt = null;
prependWorkflowEvents(item, [
createWorkflowEvent(
'item',
nextDeadline
? `SLA deadline set to ${new Date(nextDeadline).toISOString()}.`
: 'SLA deadline cleared.',
now,
agentContext.agentName,
),
]);
}
}

if (!touchesDetails && !touchesAssignment && !touchesPriority && !touchesSla) {
return { item: presentItem(snapshot, item) };
}

Expand Down
18 changes: 18 additions & 0 deletions src/electron/main/agent-actions/handlers/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
} from '@/shared/workflow/project-artifacts';
import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity';
import type { AppStorage } from '@/electron/main/storage/app-storage';
import {
normalizeItemPriority,
normalizeSlaDeadlineMs,
} from '@/shared/workflow/priority-sla';

import { ToolHandlerError } from './types';

Expand Down Expand Up @@ -38,9 +42,13 @@ export interface WorkflowItem {
createdAt: number;
id: string;
primaryAgentId: string | null;
priority: 'critical' | 'high' | 'medium' | 'low';
projectId: string;
scheduledTaskId: string | null;
sortOrder: number;
slaBreachedAt?: number | null | undefined;
slaDeadlineMs?: number | undefined;
slaWarnedAt?: number | null | undefined;
status: string;
tasks: WorkflowTask[];
title: string;
Expand Down Expand Up @@ -160,7 +168,13 @@ export function cloneWorkflowSnapshot(snapshot: WorkflowSnapshot): WorkflowSnaps
typeof item.artifactFolderName === 'string' && item.artifactFolderName.trim()
? item.artifactFolderName.trim()
: createArtifactFolderName(item.title, item.id),
priority: normalizeItemPriority(item.priority),
scheduledTaskId: item.scheduledTaskId ?? null,
...(typeof item.slaBreachedAt === 'number' ? { slaBreachedAt: item.slaBreachedAt } : {}),
...(normalizeSlaDeadlineMs(item.slaDeadlineMs)
? { slaDeadlineMs: normalizeSlaDeadlineMs(item.slaDeadlineMs) }
: {}),
...(typeof item.slaWarnedAt === 'number' ? { slaWarnedAt: item.slaWarnedAt } : {}),
tasks: item.tasks.map((task) => ({ ...task })),
workProducts: item.workProducts.map((workProduct) => ({ ...workProduct })),
workflowEvents: item.workflowEvents.map((event) => ({ ...event })),
Expand All @@ -185,6 +199,10 @@ export function normalizeWorkflowSnapshot(snapshot: WorkflowSnapshot): void {
for (const item of snapshot.items) {
item.activity = createWorkflowItemActivitySummary(item.activity);
item.status = isWorkflowItemStatus(item.status) ? item.status : 'inbox';
item.priority = normalizeItemPriority(item.priority);
item.slaDeadlineMs = normalizeSlaDeadlineMs(item.slaDeadlineMs);
item.slaWarnedAt = typeof item.slaWarnedAt === 'number' ? item.slaWarnedAt : null;
item.slaBreachedAt = typeof item.slaBreachedAt === 'number' ? item.slaBreachedAt : null;
item.artifactFolderName =
typeof item.artifactFolderName === 'string' && item.artifactFolderName.trim()
? item.artifactFolderName.trim()
Expand Down
6 changes: 6 additions & 0 deletions src/electron/main/db/migrations/006_priority_sla.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ALTER TABLE workflow_items ADD COLUMN priority TEXT NOT NULL DEFAULT 'medium'
CHECK (priority IN ('critical','high','medium','low'));

ALTER TABLE workflow_items ADD COLUMN sla_deadline_ms INTEGER;
ALTER TABLE workflow_items ADD COLUMN sla_warned_at INTEGER;
ALTER TABLE workflow_items ADD COLUMN sla_breached_at INTEGER;
8 changes: 8 additions & 0 deletions src/electron/main/orm/schema/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

import type {
WorkflowEventKind,
ItemPriority,
WorkflowItemActivitySummary,
WorkflowItemStatus,
WorkflowProjectFilter,
Expand Down Expand Up @@ -42,11 +43,18 @@ export const workflowItems = sqliteTable(
createdAt: integer('created_at').notNull().$type<number>(),
id: text('id').primaryKey(),
primaryAgentId: text('primary_agent_id'),
priority: text('priority', { enum: ['critical', 'high', 'medium', 'low'] })
.$type<ItemPriority>()
.notNull()
.default('medium'),
projectId: text('project_id')
.notNull()
.references(() => workflowProjects.id, { onDelete: 'cascade' }),
scheduledTaskId: text('scheduled_task_id'),
sortOrder: integer('sort_order').notNull(),
slaBreachedAt: integer('sla_breached_at').$type<number | null>(),
slaDeadlineMs: integer('sla_deadline_ms').$type<number | null>(),
slaWarnedAt: integer('sla_warned_at').$type<number | null>(),
status: text('status', { enum: workflowItemStatuses }).$type<WorkflowItemStatus>().notNull(),
title: text('title').notNull(),
updatedAt: integer('updated_at').notNull().$type<number>(),
Expand Down
145 changes: 145 additions & 0 deletions src/electron/main/sla/sla-monitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// SLA warning and breach monitor for persisted workflow snapshots.

import type { AppStorage } from '@/electron/main/storage';
import { createId } from '@/shared/id';
import { isPlainObject } from '@/shared/is-record';
import {
SLA_WARNING_WINDOW_MS,
getSlaState,
isSlaTerminalStatus,
} from '@/shared/workflow/priority-sla';

interface SlaMonitorOptions {
clearInterval?: typeof globalThis.clearInterval;
notifySla?: (notification: SlaNotification) => void;
notifyWorkflowChanged: () => void;
now?: () => number;
setInterval?: typeof globalThis.setInterval;
workflowStore: AppStorage;
}

export interface SlaNotification {
itemId: string;
itemTitle: string;
msLeft?: number;
type: 'sla_warning' | 'sla_breach';
}

interface SlaWorkflowSnapshot {
items: Array<Record<string, unknown>>;
projects: Array<Record<string, unknown>>;
}

function isSlaWorkflowSnapshot(value: unknown): value is SlaWorkflowSnapshot {
return isPlainObject(value) && Array.isArray(value.items) && Array.isArray(value.projects);
}

function prependSlaEvent(
item: Record<string, unknown>,
description: string,
now: number,
) {
const workflowEvents = Array.isArray(item.workflowEvents) ? item.workflowEvents : [];

item.workflowEvents = [
{
actor: 'Dune',
createdAt: now,
description,
id: createId('event'),
kind: 'item',
},
...workflowEvents,
];
item.updatedAt = now;
}

/** Creates the periodic SLA monitor. */
export function createSlaMonitor(options: SlaMonitorOptions) {
const clearIntervalFn = options.clearInterval ?? globalThis.clearInterval;
const setIntervalFn = options.setInterval ?? globalThis.setInterval;
const nowFn = options.now ?? Date.now;
let intervalHandle: ReturnType<typeof globalThis.setInterval> | null = null;

async function checkNow() {
const snapshot = await options.workflowStore.get('snapshot');

if (!isSlaWorkflowSnapshot(snapshot)) {
return;
}

const now = nowFn();
let dirty = false;

for (const item of snapshot.items) {
const id = typeof item.id === 'string' ? item.id : null;
const title = typeof item.title === 'string' ? item.title : 'Untitled work item';
const status = typeof item.status === 'string' ? item.status : 'inbox';
const slaDeadlineMs = typeof item.slaDeadlineMs === 'number' ? item.slaDeadlineMs : undefined;

if (!id || !slaDeadlineMs || isSlaTerminalStatus(status)) {
continue;
}

const sla = getSlaState(slaDeadlineMs, status, now);

if (!sla) {
continue;
}

if (sla.isBreached && typeof item.slaBreachedAt !== 'number') {
item.slaBreachedAt = now;
prependSlaEvent(item, 'SLA breached.', now);
options.notifySla?.({ itemId: id, itemTitle: title, type: 'sla_breach' });
dirty = true;
continue;
}

if (
sla.msLeft > 0 &&
sla.msLeft <= SLA_WARNING_WINDOW_MS &&
typeof item.slaWarnedAt !== 'number'
) {
item.slaWarnedAt = now;
prependSlaEvent(item, `SLA warning: ${Math.ceil(sla.msLeft / 60_000)} minutes remaining.`, now);
options.notifySla?.({
itemId: id,
itemTitle: title,
msLeft: sla.msLeft,
type: 'sla_warning',
});
dirty = true;
}
}

if (dirty) {
await options.workflowStore.set('snapshot', snapshot);
options.notifyWorkflowChanged();
}
}

function start() {
if (intervalHandle) {
return;
}

intervalHandle = setIntervalFn(() => {
void checkNow();
}, 5 * 60_000);
}

function stop() {
if (!intervalHandle) {
return;
}

clearIntervalFn(intervalHandle);
intervalHandle = null;
}

return {
checkNow,
start,
stop,
};
}
Loading