From 24e89e334adce4171c776ad6e0c92d00dd149a9c Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 07:48:19 +0800 Subject: [PATCH] feat: add priority levels and SLA deadlines with escalation alerts --- .../main/agent-actions/handlers/items.ts | 84 ++++++++- .../main/agent-actions/handlers/snapshot.ts | 31 ++++ .../main/agent-actions/register-actions.ts | 4 + src/electron/main/audit/audit-log.ts | 13 ++ src/electron/main/db/client.ts | 42 +++++ .../main/db/migrations/001_priority_sla.sql | 5 + .../notifications/notification-manager.ts | 28 +++ src/electron/main/orm/schema/constants.ts | 14 +- src/electron/main/orm/schema/workflow.ts | 6 + .../runtime/desktop-runtime-controller.ts | 18 ++ src/electron/main/sla/sla-monitor.ts | 160 ++++++++++++++++++ .../app/hooks/use-workflow-persistence.ts | 35 +++- src/renderer/app/store/types.ts | 8 +- src/renderer/app/store/workflow-slice.ts | 50 +++++- .../workflow/components/WorkflowBoard.tsx | 108 +++++++++++- .../workflow/hooks/use-sla-countdown.ts | 21 +++ .../workflow/model/workflow-presenters.ts | 17 +- .../features/workflow/model/workflow-seed.ts | 6 + src/renderer/features/workflow/types.ts | 23 ++- .../handlers/items-priority-sla.test.ts | 123 ++++++++++++++ .../handlers/mutation-notes.test.ts | 1 + .../agent-actions/handlers/snapshot.test.ts | 1 + .../agent-actions/handlers/validators.test.ts | 1 + .../main/runtime/agent-runtime.test.ts | 1 + .../desktop-runtime-controller.test.ts | 36 ++++ .../src/electron/main/sla/sla-monitor.test.ts | 121 +++++++++++++ .../workflow/workflow-coordinator.test.ts | 1 + .../workflow/components/ItemCard.test.tsx | 81 +++++++++ .../workflow/components/Lane.test.tsx | 50 ++++++ .../components/WorkflowBoard.test.tsx | 66 +++++++- .../components/WorkflowItemInspector.test.tsx | 1 + .../workflow/hooks/use-sla-countdown.test.ts | 48 ++++++ .../unit/src/shared/workflow/activity.test.ts | 1 + 33 files changed, 1177 insertions(+), 28 deletions(-) create mode 100644 src/electron/main/audit/audit-log.ts create mode 100644 src/electron/main/db/migrations/001_priority_sla.sql create mode 100644 src/electron/main/notifications/notification-manager.ts create mode 100644 src/electron/main/sla/sla-monitor.ts create mode 100644 src/renderer/features/workflow/hooks/use-sla-countdown.ts create mode 100644 tests/unit/src/electron/main/agent-actions/handlers/items-priority-sla.test.ts create mode 100644 tests/unit/src/electron/main/sla/sla-monitor.test.ts create mode 100644 tests/unit/src/renderer/features/workflow/components/ItemCard.test.tsx create mode 100644 tests/unit/src/renderer/features/workflow/components/Lane.test.tsx create mode 100644 tests/unit/src/renderer/features/workflow/hooks/use-sla-countdown.test.ts diff --git a/src/electron/main/agent-actions/handlers/items.ts b/src/electron/main/agent-actions/handlers/items.ts index 9418bec..e12c358 100644 --- a/src/electron/main/agent-actions/handlers/items.ts +++ b/src/electron/main/agent-actions/handlers/items.ts @@ -17,12 +17,14 @@ import { compareItems, createWorkflowEvent, findItem, + isWorkflowItemPriority, isWorkflowItemStatus, prependWorkflowEvents, readWorkflowSnapshot, reindexProjectStatusGroup, touchProject, workflowItemStatuses, + workflowItemPriorities, writeWorkflowSnapshot, type WorkflowItem, } from './snapshot'; @@ -40,6 +42,23 @@ import { } from './validators'; import { ToolHandlerError, type RegisteredTool } from './types'; +/** Reads optional SLA deadline input. */ +function readSlaDeadlineMs(value: unknown): number | null | undefined { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return null; + } + + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new ToolHandlerError('validation-error', 'slaDeadlineMs must be a positive millisecond timestamp or null.'); + } + + return Math.trunc(value); +} + /** Lists item tools. */ export const itemTools: RegisteredTool[] = [ { @@ -68,7 +87,14 @@ export const itemTools: RegisteredTool[] = [ { brief: optionalStringSchema, note: optionalStringSchema, + priority: { + enum: [...workflowItemPriorities], + type: 'string', + }, projectId: optionalStringSchema, + slaDeadlineMs: { + type: 'number', + }, status: optionalStringSchema, title: stringSchema, }, @@ -82,6 +108,8 @@ export const itemTools: RegisteredTool[] = [ const title = requireString(args.title, 'title'); const brief = optionalString(args.brief) ?? ''; const note = optionalString(args.note); + const priority = isWorkflowItemPriority(args.priority) ? args.priority : 'medium'; + const slaDeadlineMs = readSlaDeadlineMs(args.slaDeadlineMs); const status = optionalString(args.status) ?? 'inbox'; const now = Date.now(); const itemId = createId('item'); @@ -99,9 +127,11 @@ export const itemTools: RegisteredTool[] = [ brief, createdAt: now, id: itemId, + priority, primaryAgentId: null, projectId, scheduledTaskId: null, + ...(typeof slaDeadlineMs === 'number' ? { slaDeadlineMs } : {}), sortOrder: snapshot.items.filter((item) => item.projectId === projectId && item.status === status).length, status, tasks: createDefaultTasks(now), @@ -136,6 +166,11 @@ export const itemTools: RegisteredTool[] = [ itemId: stringSchema, note: optionalStringSchema, primaryAgentId: { type: ['string', 'null'] }, + priority: { + enum: [...workflowItemPriorities], + type: 'string', + }, + slaDeadlineMs: { type: ['number', 'null'] }, title: optionalStringSchema, }, ['itemId'], @@ -147,10 +182,13 @@ export const itemTools: RegisteredTool[] = [ const item = findItem(snapshot, requireString(args.itemId, 'itemId')); const note = optionalString(args.note); const title = optionalString(args.title); + const slaDeadlineMs = readSlaDeadlineMs(args.slaDeadlineMs); const now = Date.now(); 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); @@ -201,7 +239,51 @@ export const itemTools: RegisteredTool[] = [ } } - if (!touchesDetails && !touchesAssignment) { + if (touchesPriority) { + if (!isWorkflowItemPriority(args.priority)) { + throw new ToolHandlerError( + 'validation-error', + `priority must be one of: ${workflowItemPriorities.join(', ')}.`, + ); + } + + if (item.priority !== args.priority) { + item.priority = args.priority; + prependWorkflowEvents(item, [ + ...(note ? [createWorkflowEvent('note', note, now, agentContext.agentName)] : []), + createWorkflowEvent( + 'item.priority_changed', + `Priority changed to ${args.priority}.`, + now, + agentContext.agentName, + ), + ]); + } + } + + if (touchesSla) { + if (slaDeadlineMs === null) { + if (typeof item.slaDeadlineMs === 'number') { + delete item.slaDeadlineMs; + delete item.slaWarnedAt; + delete item.slaBreachedAt; + prependWorkflowEvents(item, [ + ...(note ? [createWorkflowEvent('note', note, now, agentContext.agentName)] : []), + createWorkflowEvent('item.sla_cleared', 'SLA deadline cleared.', now, agentContext.agentName), + ]); + } + } else if (typeof slaDeadlineMs === 'number') { + item.slaDeadlineMs = slaDeadlineMs; + delete item.slaWarnedAt; + delete item.slaBreachedAt; + prependWorkflowEvents(item, [ + ...(note ? [createWorkflowEvent('note', note, now, agentContext.agentName)] : []), + createWorkflowEvent('item.sla_set', `SLA deadline set to ${new Date(slaDeadlineMs).toISOString()}.`, now, agentContext.agentName), + ]); + } + } + + if (!touchesDetails && !touchesAssignment && !touchesPriority && !touchesSla) { return { item: presentItem(snapshot, item) }; } diff --git a/src/electron/main/agent-actions/handlers/snapshot.ts b/src/electron/main/agent-actions/handlers/snapshot.ts index 7736757..2622202 100644 --- a/src/electron/main/agent-actions/handlers/snapshot.ts +++ b/src/electron/main/agent-actions/handlers/snapshot.ts @@ -14,6 +14,10 @@ import { ToolHandlerError } from './types'; export const workflowItemStatuses = ['inbox', 'ready', 'active', 'review', 'acceptance', 'done'] as const; /** Workflow item status. */ export type WorkflowItemStatus = (typeof workflowItemStatuses)[number]; +/** Workflow item priorities constant. */ +export const workflowItemPriorities = ['critical', 'high', 'medium', 'low'] as const; +/** Workflow item priority. */ +export type ItemPriority = (typeof workflowItemPriorities)[number]; /** Workflow snapshot. */ export interface WorkflowSnapshot { @@ -37,9 +41,13 @@ export interface WorkflowItem { brief: string; createdAt: number; id: string; + priority: ItemPriority; primaryAgentId: string | null; projectId: string; scheduledTaskId: string | null; + slaBreachedAt?: number; + slaDeadlineMs?: number; + slaWarnedAt?: number; sortOrder: number; status: string; tasks: WorkflowTask[]; @@ -160,7 +168,11 @@ export function cloneWorkflowSnapshot(snapshot: WorkflowSnapshot): WorkflowSnaps typeof item.artifactFolderName === 'string' && item.artifactFolderName.trim() ? item.artifactFolderName.trim() : createArtifactFolderName(item.title, item.id), + priority: isWorkflowItemPriority(item.priority) ? item.priority : 'medium', scheduledTaskId: item.scheduledTaskId ?? null, + ...(typeof item.slaBreachedAt === 'number' ? { slaBreachedAt: item.slaBreachedAt } : {}), + ...(typeof item.slaDeadlineMs === 'number' ? { slaDeadlineMs: 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 })), @@ -189,6 +201,20 @@ export function normalizeWorkflowSnapshot(snapshot: WorkflowSnapshot): void { typeof item.artifactFolderName === 'string' && item.artifactFolderName.trim() ? item.artifactFolderName.trim() : createArtifactFolderName(item.title, item.id); + item.priority = isWorkflowItemPriority(item.priority) ? item.priority : 'medium'; + + if (typeof item.slaDeadlineMs !== 'number') { + delete item.slaDeadlineMs; + delete item.slaWarnedAt; + delete item.slaBreachedAt; + } else { + if (typeof item.slaWarnedAt !== 'number') { + delete item.slaWarnedAt; + } + if (typeof item.slaBreachedAt !== 'number') { + delete item.slaBreachedAt; + } + } } for (const project of snapshot.projects) { @@ -228,6 +254,11 @@ export function isWorkflowItemStatus(value: string): value is WorkflowItemStatus return workflowItemStatuses.includes(value as WorkflowItemStatus); } +/** Returns whether the value is a workflow item priority. */ +export function isWorkflowItemPriority(value: unknown): value is ItemPriority { + return typeof value === 'string' && workflowItemPriorities.includes(value as ItemPriority); +} + /** Compares items. */ export function compareItems(left: WorkflowItem, right: WorkflowItem): number { if (left.sortOrder !== right.sortOrder) { diff --git a/src/electron/main/agent-actions/register-actions.ts b/src/electron/main/agent-actions/register-actions.ts index c87bb7e..3c08962 100644 --- a/src/electron/main/agent-actions/register-actions.ts +++ b/src/electron/main/agent-actions/register-actions.ts @@ -135,7 +135,9 @@ export function registerDuneActions( title: z.string().describe('Work item title'), brief: z.string().optional().describe('Work item brief/description'), note: z.string().optional().describe('Optional note to add to workflow history with this creation.'), + priority: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('Priority (default: medium).'), projectId: z.string().optional().describe('Project ID. Omit for current project.'), + slaDeadlineMs: z.number().optional().describe('SLA deadline as a Unix millisecond timestamp.'), status: z.enum(['inbox', 'ready']).optional().describe('Initial status (default: inbox)'), }); reg('workflow.items.update', { @@ -145,6 +147,8 @@ export function registerDuneActions( note: z.string().optional().describe('Optional note to add to workflow history with this update.'), primaryAgentId: z.string().nullable().optional() .describe('Agent ID to assign, or null to unassign. Cannot change while item is done.'), + priority: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('New priority.'), + slaDeadlineMs: z.number().nullable().optional().describe('SLA deadline as a Unix millisecond timestamp, or null to clear.'), }); reg('workflow.items.move', { itemId: z.string().describe('Work item ID'), diff --git a/src/electron/main/audit/audit-log.ts b/src/electron/main/audit/audit-log.ts new file mode 100644 index 0000000..525d824 --- /dev/null +++ b/src/electron/main/audit/audit-log.ts @@ -0,0 +1,13 @@ +// Audit event type registry for workflow item events. + +/** Audit event types emitted by work item priority and SLA flows. */ +export const auditEventTypes = [ + 'item.priority_changed', + 'item.sla_set', + 'item.sla_cleared', + 'item.sla_warning', + 'item.sla_breached', +] as const; + +/** Audit event type. */ +export type AuditEventType = (typeof auditEventTypes)[number]; diff --git a/src/electron/main/db/client.ts b/src/electron/main/db/client.ts index f45511b..4a2f431 100644 --- a/src/electron/main/db/client.ts +++ b/src/electron/main/db/client.ts @@ -23,9 +23,51 @@ export function openDuneDatabase(databasePath: string) { return sqlite; } +/** Applies lightweight app-managed SQLite migrations. */ +export function runDuneMigrations(sqlite: Database.Database) { + sqlite.exec('CREATE TABLE IF NOT EXISTS dune_migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)'); + + const migrationId = '001_priority_sla'; + const exists = sqlite + .prepare('SELECT 1 FROM dune_migrations WHERE id = ?') + .get(migrationId); + + if (exists) { + return; + } + + const columns = sqlite.prepare('PRAGMA table_info(workflow_items)').all() as Array<{ name: string }>; + if (columns.length === 0) { + return; + } + + const columnNames = new Set(columns.map((column) => column.name)); + + sqlite.transaction(() => { + if (!columnNames.has('priority')) { + sqlite.exec(`ALTER TABLE workflow_items ADD COLUMN priority TEXT NOT NULL DEFAULT 'medium' + CHECK (priority IN ('critical','high','medium','low'))`); + } + if (!columnNames.has('sla_deadline_ms')) { + sqlite.exec('ALTER TABLE workflow_items ADD COLUMN sla_deadline_ms INTEGER'); + } + if (!columnNames.has('sla_warned_at')) { + sqlite.exec('ALTER TABLE workflow_items ADD COLUMN sla_warned_at INTEGER'); + } + if (!columnNames.has('sla_breached_at')) { + sqlite.exec('ALTER TABLE workflow_items ADD COLUMN sla_breached_at INTEGER'); + } + + sqlite + .prepare('INSERT INTO dune_migrations (id, applied_at) VALUES (?, ?)') + .run(migrationId, Date.now()); + })(); +} + /** Creates the Drizzle database wrapper and exposes the raw client for migrations. */ export function createDuneDatabase(databasePath: string) { const sqlite = openDuneDatabase(databasePath); + runDuneMigrations(sqlite); return { db: drizzle(sqlite, { schema: duneSchema }), diff --git a/src/electron/main/db/migrations/001_priority_sla.sql b/src/electron/main/db/migrations/001_priority_sla.sql new file mode 100644 index 0000000..1b1c20c --- /dev/null +++ b/src/electron/main/db/migrations/001_priority_sla.sql @@ -0,0 +1,5 @@ +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; diff --git a/src/electron/main/notifications/notification-manager.ts b/src/electron/main/notifications/notification-manager.ts new file mode 100644 index 0000000..8cf18c6 --- /dev/null +++ b/src/electron/main/notifications/notification-manager.ts @@ -0,0 +1,28 @@ +// Desktop notification trigger helpers. + +import { Notification } from 'electron'; + +/** Notification trigger types emitted by Dune. */ +export type NotificationTriggerType = 'sla_warning' | 'sla_breach'; + +/** Notification payload. */ +export interface NotificationPayload { + body: string; + itemId: string; + title: string; + trigger: NotificationTriggerType; +} + +/** Shows desktop notifications for workflow triggers. */ +export class NotificationManager { + notify(payload: NotificationPayload): void { + if (!Notification.isSupported()) { + return; + } + + new Notification({ + body: payload.body, + title: payload.title, + }).show(); + } +} diff --git a/src/electron/main/orm/schema/constants.ts b/src/electron/main/orm/schema/constants.ts index 4f80ca8..41ec32e 100644 --- a/src/electron/main/orm/schema/constants.ts +++ b/src/electron/main/orm/schema/constants.ts @@ -7,7 +7,19 @@ export const messageRoles = ['assistant', 'system', 'user'] as const; export const messageStatuses = ['complete', 'streaming'] as const; export const modelAuthTypes = ['api-key', 'oauth-token'] as const; export const networkProxyModes = ['direct', 'manual', 'system'] as const; -export const workflowEventKinds = ['assignment', 'feedback', 'item', 'note', 'task'] as const; +export const workflowEventKinds = [ + 'assignment', + 'feedback', + 'item', + 'item.priority_changed', + 'item.sla_breached', + 'item.sla_cleared', + 'item.sla_set', + 'item.sla_warning', + 'note', + 'task', +] as const; +export const workflowItemPriorities = ['critical', 'high', 'medium', 'low'] as const; export const workflowItemStatuses = ['inbox', 'ready', 'active', 'review', 'acceptance', 'done'] as const; export const workflowProjectFilters = ['all', 'assigned', 'blocked', 'review'] as const; export const workflowProjectViews = ['board', 'agents', 'activity'] as const; diff --git a/src/electron/main/orm/schema/workflow.ts b/src/electron/main/orm/schema/workflow.ts index 80002a7..0c6264c 100644 --- a/src/electron/main/orm/schema/workflow.ts +++ b/src/electron/main/orm/schema/workflow.ts @@ -4,6 +4,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import type { WorkflowEventKind, + ItemPriority, WorkflowItemActivitySummary, WorkflowItemStatus, WorkflowProjectFilter, @@ -15,6 +16,7 @@ import type { PersistedWorkflowItemActivityArchive } from '@/shared/workflow/act import { GLOBAL_STATE_ROW_ID, workflowEventKinds, + workflowItemPriorities, workflowItemStatuses, workflowProjectFilters, workflowProjectViews, @@ -41,11 +43,15 @@ export const workflowItems = sqliteTable( brief: text('brief').notNull(), createdAt: integer('created_at').notNull().$type(), id: text('id').primaryKey(), + priority: text('priority', { enum: workflowItemPriorities }).$type().notNull().default('medium'), primaryAgentId: text('primary_agent_id'), projectId: text('project_id') .notNull() .references(() => workflowProjects.id, { onDelete: 'cascade' }), scheduledTaskId: text('scheduled_task_id'), + slaBreachedAt: integer('sla_breached_at').$type(), + slaDeadlineMs: integer('sla_deadline_ms').$type(), + slaWarnedAt: integer('sla_warned_at').$type(), sortOrder: integer('sort_order').notNull(), status: text('status', { enum: workflowItemStatuses }).$type().notNull(), title: text('title').notNull(), diff --git a/src/electron/main/runtime/desktop-runtime-controller.ts b/src/electron/main/runtime/desktop-runtime-controller.ts index 11a0f8d..982e098 100644 --- a/src/electron/main/runtime/desktop-runtime-controller.ts +++ b/src/electron/main/runtime/desktop-runtime-controller.ts @@ -6,6 +6,8 @@ import type { AgentServiceSnapshot, } from '@/shared/agents/agent-runtime'; import { createMockAgentRuntime } from '@/renderer/features/agents/services/mock-agent-service'; +import { NotificationManager } from '@/electron/main/notifications/notification-manager'; +import { SlaMonitor } from '@/electron/main/sla/sla-monitor'; import type { AgentDefinition, RunIsolatedResearchInput, @@ -34,6 +36,7 @@ type RealRuntime = ActiveRuntime & { export interface DesktopRuntimeControllerOptions extends AgentRuntimeOptions { createRealRuntime?: (options: DesktopRuntimeControllerOptions) => RealRuntime; + createSlaMonitor?: (options: DesktopRuntimeControllerOptions) => Pick; } /** Coordinates desktop runtime. */ @@ -52,12 +55,23 @@ export class DesktopRuntimeController { private shutdownPromise: Promise | null = null; + private readonly slaMonitor: Pick | null = null; + constructor(options: DesktopRuntimeControllerOptions) { this.runtimeRoot = resolveAgentLiteRuntimeRoot(options.homeDir); this.runtimeOptions = options; this.createRealRuntime = options.createRealRuntime ?? ((runtimeOptions) => new AgentRuntime(runtimeOptions)); + this.slaMonitor = + options.createSlaMonitor?.(options) ?? + (options.actionServices + ? new SlaMonitor({ + notificationManager: new NotificationManager(), + onWorkflowChanged: options.actionServices.onWorkflowChanged, + workflowStore: options.actionServices.workflowStore, + }) + : null); this.activeRuntime = createMockAgentRuntime({ message: 'Starting Dune runtime.', mode: 'mock-fallback', @@ -69,6 +83,8 @@ export class DesktopRuntimeController { /** Starts desktop runtime. */ async start() { + this.slaMonitor?.start(); + try { const host = this.createRealRuntime(this.runtimeOptions); await host.start(); @@ -208,6 +224,8 @@ export class DesktopRuntimeController { } this.shutdownPromise = (async () => { + this.slaMonitor?.stop(); + this.activeRuntimeUnsubscribe?.(); this.activeRuntimeUnsubscribe = null; diff --git a/src/electron/main/sla/sla-monitor.ts b/src/electron/main/sla/sla-monitor.ts new file mode 100644 index 0000000..c7e7567 --- /dev/null +++ b/src/electron/main/sla/sla-monitor.ts @@ -0,0 +1,160 @@ +// Workflow SLA deadline monitor. + +import type { AppStorage } from '@/electron/main/storage'; +import { + createWorkflowEvent, + recordWorkflowItemEvents, +} from '@/electron/main/agent-actions/handlers/snapshot'; +import type { + WorkflowItem, + WorkflowSnapshot, +} from '@/electron/main/agent-actions/handlers/snapshot'; +import type { + NotificationManager, + NotificationTriggerType, +} from '@/electron/main/notifications/notification-manager'; +import { isPlainObject } from '@/shared/is-record'; + +const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; +const WARNING_WINDOW_MS = 2 * 60 * 60 * 1000; + +interface SlaMonitorOptions { + clearInterval?: typeof globalThis.clearInterval; + intervalMs?: number; + notificationManager?: Pick; + now?: () => number; + onWorkflowChanged: () => void; + setInterval?: typeof globalThis.setInterval; + workflowStore: AppStorage; +} + +/** Monitors active work items for upcoming and breached SLA deadlines. */ +export class SlaMonitor { + private intervalHandle: ReturnType | null = null; + + private readonly clearIntervalFn: typeof globalThis.clearInterval; + + private readonly intervalMs: number; + + private readonly notificationManager: Pick | undefined; + + private readonly now: () => number; + + private readonly onWorkflowChanged: () => void; + + private readonly setIntervalFn: typeof globalThis.setInterval; + + private readonly workflowStore: AppStorage; + + constructor(options: SlaMonitorOptions) { + this.clearIntervalFn = options.clearInterval ?? globalThis.clearInterval; + this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; + this.notificationManager = options.notificationManager; + this.now = options.now ?? Date.now; + this.onWorkflowChanged = options.onWorkflowChanged; + this.setIntervalFn = options.setInterval ?? globalThis.setInterval; + this.workflowStore = options.workflowStore; + } + + start(): void { + if (this.intervalHandle) { + return; + } + + this.intervalHandle = this.setIntervalFn(() => { + void this.runOnce(); + }, this.intervalMs); + void this.runOnce(); + } + + stop(): void { + if (!this.intervalHandle) { + return; + } + + this.clearIntervalFn(this.intervalHandle); + this.intervalHandle = null; + } + + async runOnce(): Promise { + const snapshot = await this.workflowStore.get('snapshot'); + + if (!isWorkflowSnapshotLike(snapshot)) { + return; + } + + const now = this.now(); + let dirty = false; + + for (const item of snapshot.items) { + const trigger = this.getTrigger(item, now); + + if (!trigger) { + continue; + } + + if (trigger === 'sla_warning') { + item.slaWarnedAt = now; + recordWorkflowItemEvents( + snapshot, + item, + [createWorkflowEvent('item.sla_warning', `SLA warning: "${item.title}" is due soon.`, now, 'Dune')], + now, + ); + this.notify(trigger, item, 'SLA deadline approaching'); + } else { + item.slaBreachedAt = now; + recordWorkflowItemEvents( + snapshot, + item, + [createWorkflowEvent('item.sla_breached', `SLA breached: "${item.title}" missed its deadline.`, now, 'Dune')], + now, + ); + this.notify(trigger, item, 'SLA deadline breached'); + } + + dirty = true; + } + + if (!dirty) { + return; + } + + await this.workflowStore.set('snapshot', snapshot); + this.onWorkflowChanged(); + } + + private getTrigger(item: WorkflowItem, now: number): NotificationTriggerType | null { + if ( + (item.status !== 'active' && item.status !== 'review') || + typeof item.slaDeadlineMs !== 'number' + ) { + return null; + } + + const msLeft = item.slaDeadlineMs - now; + + if (msLeft <= 0 && typeof item.slaBreachedAt !== 'number') { + return 'sla_breach'; + } + + if (msLeft > 0 && msLeft <= WARNING_WINDOW_MS && typeof item.slaWarnedAt !== 'number') { + return 'sla_warning'; + } + + return null; + } + + private notify(trigger: NotificationTriggerType, item: WorkflowItem, title: string): void { + this.notificationManager?.notify({ + body: item.title, + itemId: item.id, + title, + trigger, + }); + } +} + +function isWorkflowSnapshotLike(value: unknown): value is WorkflowSnapshot { + return isPlainObject(value) && Array.isArray(value.items) && Array.isArray(value.projects); +} diff --git a/src/renderer/app/hooks/use-workflow-persistence.ts b/src/renderer/app/hooks/use-workflow-persistence.ts index 82097de..ba29e6b 100644 --- a/src/renderer/app/hooks/use-workflow-persistence.ts +++ b/src/renderer/app/hooks/use-workflow-persistence.ts @@ -7,6 +7,7 @@ import { useAppStore } from '@/renderer/app/store/use-app-store'; import { createEmptyWorkflowSnapshot } from '@/renderer/features/workflow/model/workflow-seed'; import { workflowItemStatuses, + type ItemPriority, workflowProjectFilters, workflowProjectViews, workflowTaskStatuses, @@ -24,6 +25,7 @@ import { isPlainObject } from '@/shared/is-record'; const STORE_NAME = 'workflow'; const STORE_KEY = 'snapshot'; +const workflowItemPriorities = ['critical', 'high', 'medium', 'low'] as const; /** Normalizes task. */ function normalizeTask(value: unknown): WorkflowTask | null { @@ -89,15 +91,18 @@ function normalizeEvent(value: unknown): WorkflowEvent | null { return null; } - const kind = value.kind === 'assignment' - ? 'assignment' - : value.kind === 'feedback' - ? 'feedback' - : value.kind === 'task' - ? 'task' - : value.kind === 'note' - ? 'note' - : 'item'; + const kind = + value.kind === 'assignment' || + value.kind === 'feedback' || + value.kind === 'item.priority_changed' || + value.kind === 'item.sla_breached' || + value.kind === 'item.sla_cleared' || + value.kind === 'item.sla_set' || + value.kind === 'item.sla_warning' || + value.kind === 'note' || + value.kind === 'task' + ? value.kind + : 'item'; return { ...(typeof value.actor === 'string' && value.actor.trim() @@ -205,6 +210,10 @@ function normalizeCurrentSnapshot(value: unknown): WorkflowSnapshot | null { : createWorkflowItemActivitySummary({ totalEventCount: workflowEvents.length, }); + const priority = workflowItemPriorities.includes(item.priority as ItemPriority) + ? (item.priority as ItemPriority) + : 'medium'; + const slaDeadlineMs = typeof item.slaDeadlineMs === 'number' ? item.slaDeadlineMs : undefined; return [{ activity, @@ -215,6 +224,7 @@ function normalizeCurrentSnapshot(value: unknown): WorkflowSnapshot | null { brief: item.brief, createdAt: item.createdAt, id: item.id, + priority, primaryAgentId: typeof item.primaryAgentId === 'string' || item.primaryAgentId === null ? item.primaryAgentId @@ -222,6 +232,13 @@ function normalizeCurrentSnapshot(value: unknown): WorkflowSnapshot | null { projectId: item.projectId, scheduledTaskId: typeof item.scheduledTaskId === 'string' ? item.scheduledTaskId : null, + ...(typeof item.slaBreachedAt === 'number' && slaDeadlineMs !== undefined + ? { slaBreachedAt: item.slaBreachedAt } + : {}), + ...(slaDeadlineMs !== undefined ? { slaDeadlineMs } : {}), + ...(typeof item.slaWarnedAt === 'number' && slaDeadlineMs !== undefined + ? { slaWarnedAt: item.slaWarnedAt } + : {}), sortOrder: item.sortOrder, status: workflowItemStatuses.includes(item.status as (typeof workflowItemStatuses)[number]) ? (item.status as WorkflowSnapshot['items'][number]['status']) diff --git a/src/renderer/app/store/types.ts b/src/renderer/app/store/types.ts index ea9f659..9594d2c 100644 --- a/src/renderer/app/store/types.ts +++ b/src/renderer/app/store/types.ts @@ -21,6 +21,7 @@ import type { } from '@/renderer/features/settings/types'; import type { WorkflowItem, + ItemPriority, WorkflowItemStatus, WorkflowProjectActivitySummary, WorkflowProjectFilter, @@ -118,7 +119,9 @@ export interface WorkflowActions { input: { brief: string; note?: string; + priority?: ItemPriority; projectId: string; + slaDeadlineMs?: number; status: WorkflowItemStatus; title: string; }, @@ -145,7 +148,7 @@ export interface WorkflowActions { ) => void; updateItem: ( itemId: string, - input: { brief?: string; note?: string; title?: string }, + input: { brief?: string; note?: string; priority?: ItemPriority; slaDeadlineMs?: number | null; title?: string }, ) => void; updateTask: ( itemId: string, @@ -221,13 +224,16 @@ export interface WorkflowSessionState { completedTaskCount: number; hasBlockedTasks: boolean; id: string; + priority: ItemPriority; primaryAgentId: string | null; primaryAgentName: string | null; specialStateLabel: string | null; + slaDeadlineMs?: number; status: WorkflowItemStatus; statusLabel: string; title: string; totalTaskCount: number; + updatedAt: number; updatedLabel: string; }>; isWorkflowHydrated: boolean; diff --git a/src/renderer/app/store/workflow-slice.ts b/src/renderer/app/store/workflow-slice.ts index b9d2e63..9bc20ad 100644 --- a/src/renderer/app/store/workflow-slice.ts +++ b/src/renderer/app/store/workflow-slice.ts @@ -13,6 +13,7 @@ import { import type { WorkflowEventKind, WorkflowItem, + ItemPriority, WorkflowItemStatus, WorkflowProjectFilter, WorkflowProjectView, @@ -29,6 +30,12 @@ import { import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; const defaultProjectColors = ['#A86D46', '#7A8B5D', '#4F7A78', '#9D6A71', '#6C69A6'] as const; +const priorityRank: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; /** Creates item event. */ function createItemEvent( @@ -72,9 +79,9 @@ function sortItemsByProjectStatus(items: WorkflowItem[]) { for (const group of grouped.values()) { group .sort((left, right) => - left.sortOrder === right.sortOrder - ? left.updatedAt - right.updatedAt - : left.sortOrder - right.sortOrder, + priorityRank[left.priority] === priorityRank[right.priority] + ? right.updatedAt - left.updatedAt + : priorityRank[left.priority] - priorityRank[right.priority], ) .forEach((item, index) => { item.sortOrder = index; @@ -489,9 +496,11 @@ export function createWorkflowSlice( brief, createdAt: updatedAt, id: itemId, + priority: input.priority ?? 'medium', primaryAgentId: null, projectId: input.projectId, scheduledTaskId: null, + ...(typeof input.slaDeadlineMs === 'number' ? { slaDeadlineMs: input.slaDeadlineMs } : {}), sortOrder: getProjectItems( state.items.filter((item) => item.status === input.status), input.projectId, @@ -794,22 +803,51 @@ export function createWorkflowSlice( const title = input.title?.trim(); const brief = input.brief?.trim(); - const nextItem = { + const priority = input.priority; + const hasPriorityChange = priority !== undefined && priority !== item.priority; + const hasSlaChange = input.slaDeadlineMs !== undefined; + const nextSlaDeadlineMs = input.slaDeadlineMs; + const nextItem: WorkflowItem = { ...item, ...(title ? { title } : {}), ...(brief !== undefined ? { brief } : {}), + ...(priority ? { priority } : {}), updatedAt, }; - if (title === undefined && brief === undefined) { + if (title === undefined && brief === undefined && !hasPriorityChange && !hasSlaChange) { return item; } + if (hasSlaChange) { + if (typeof nextSlaDeadlineMs === 'number') { + nextItem.slaDeadlineMs = nextSlaDeadlineMs; + } else { + delete nextItem.slaDeadlineMs; + } + + delete nextItem.slaWarnedAt; + delete nextItem.slaBreachedAt; + } + return appendItemEvents( nextItem, [ ...(normalizedNote ? [{ description: normalizedNote, kind: 'note' as const }] : []), - { description: 'Work item details were updated.', kind: 'item' as const }, + ...(title !== undefined || brief !== undefined + ? [{ description: 'Work item details were updated.', kind: 'item' as const }] + : []), + ...(hasPriorityChange + ? [{ description: `Priority changed to ${priority}.`, kind: 'item.priority_changed' as const }] + : []), + ...(hasSlaChange + ? [{ + description: typeof nextSlaDeadlineMs === 'number' + ? `SLA deadline set to ${new Date(nextSlaDeadlineMs).toISOString()}.` + : 'SLA deadline cleared.', + kind: typeof nextSlaDeadlineMs === 'number' ? 'item.sla_set' as const : 'item.sla_cleared' as const, + }] + : []), ], updatedAt, ); diff --git a/src/renderer/features/workflow/components/WorkflowBoard.tsx b/src/renderer/features/workflow/components/WorkflowBoard.tsx index 7ae36f7..6e514d4 100644 --- a/src/renderer/features/workflow/components/WorkflowBoard.tsx +++ b/src/renderer/features/workflow/components/WorkflowBoard.tsx @@ -22,10 +22,12 @@ import { import { CSS } from '@dnd-kit/utilities'; import { GripVertical } from 'lucide-react'; +import { useSlaCountdown } from '@/renderer/features/workflow/hooks/use-sla-countdown'; import { workflowItemStatusLabels, } from '@/renderer/features/workflow/model/workflow-presenters'; import type { + ItemPriority, WorkflowItemStatus, WorkflowItemSummary, } from '@/renderer/features/workflow/types'; @@ -39,6 +41,18 @@ const workflowColumns: WorkflowItemStatus[] = [ 'acceptance', 'done', ]; +const priorityRank: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; +const priorityStyles: Record = { + critical: 'border-[#ef4444]/30 bg-[#ef4444]/10 text-[#ef4444]', + high: 'border-[#f97316]/30 bg-[#f97316]/10 text-[#f97316]', + medium: 'border-[#3b82f6]/30 bg-[#3b82f6]/10 text-[#3b82f6]', + low: 'border-[#6b7280]/30 bg-[#6b7280]/10 text-[#6b7280]', +}; /** Workflow board props. */ interface WorkflowBoardProps { @@ -53,11 +67,34 @@ function getColumnItems( items: WorkflowItemSummary[], status: WorkflowItemStatus, ) { - return items.filter((item) => item.status === status); + return items + .filter((item) => item.status === status) + .sort((left, right) => + priorityRank[left.priority] === priorityRank[right.priority] + ? right.updatedAt - left.updatedAt + : priorityRank[left.priority] - priorityRank[right.priority], + ); +} + +/** Formats a compact SLA countdown label. */ +function formatSlaLabel(msLeft: number) { + const absMs = Math.abs(msLeft); + const minutes = Math.max(1, Math.ceil(absMs / 60_000)); + + if (minutes < 60) { + return `${minutes}m`; + } + + const hours = Math.ceil(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + + return `${Math.ceil(hours / 24)}d`; } /** Renders the item card UI. */ -function ItemCard({ +export function ItemCard({ active, item, listeners, @@ -68,6 +105,17 @@ function ItemCard({ listeners?: Record; onSelect: () => void; }) { + const countdown = useSlaCountdown(item.slaDeadlineMs); + const isSlaMet = item.status === 'acceptance' || item.status === 'done'; + const showPriority = item.priority === 'critical' || item.priority === 'high' || typeof item.slaDeadlineMs === 'number'; + const slaLabel = countdown + ? isSlaMet + ? 'SLA met' + : countdown.isBreached + ? `${formatSlaLabel(countdown.msLeft)} late` + : `${formatSlaLabel(countdown.msLeft)} left` + : null; + return (
-

- {item.title} -

+
+ {showPriority ? ( + + {item.priority} + + ) : null} +

+ {item.title} +

+

{item.brief || 'No brief yet.'}

@@ -123,10 +184,45 @@ function ItemCard({ ) : null} {item.specialStateLabel ? ( -
+
{item.specialStateLabel} + {slaLabel ? ( + + {slaLabel} + + ) : null} +
+ ) : slaLabel ? ( +
+ + {slaLabel} +
) : null}
diff --git a/src/renderer/features/workflow/hooks/use-sla-countdown.ts b/src/renderer/features/workflow/hooks/use-sla-countdown.ts new file mode 100644 index 0000000..5989892 --- /dev/null +++ b/src/renderer/features/workflow/hooks/use-sla-countdown.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +/** Returns live SLA countdown state for a deadline timestamp. */ +export function useSlaCountdown(slaDeadlineMs?: number) { + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + if (!slaDeadlineMs) return; + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, [slaDeadlineMs]); + + if (!slaDeadlineMs) return null; + + const msLeft = slaDeadlineMs - now; + return { + isBreached: msLeft <= 0, + isWarning: msLeft > 0 && msLeft <= 2 * 60 * 60 * 1000, + msLeft, + }; +} diff --git a/src/renderer/features/workflow/model/workflow-presenters.ts b/src/renderer/features/workflow/model/workflow-presenters.ts index f9165b7..509a66d 100644 --- a/src/renderer/features/workflow/model/workflow-presenters.ts +++ b/src/renderer/features/workflow/model/workflow-presenters.ts @@ -6,6 +6,7 @@ import { } from '@/renderer/features/agents/model/time'; import type { Agent } from '@/renderer/features/agents/types'; import type { + ItemPriority, WorkflowItem, WorkflowItemStatus, WorkflowItemSummary, @@ -14,6 +15,13 @@ import type { WorkflowTaskStatus, } from '@/renderer/features/workflow/types'; +const priorityRank: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; + /** Defines workflow item status labels. */ export const workflowItemStatusLabels: Record = { acceptance: 'Acceptance', @@ -35,8 +43,8 @@ export const workflowTaskStatusLabels: Record = { /** Compares items. */ function compareItems(left: WorkflowItem, right: WorkflowItem) { - if (left.sortOrder !== right.sortOrder) { - return left.sortOrder - right.sortOrder; + if (priorityRank[left.priority] !== priorityRank[right.priority]) { + return priorityRank[left.priority] - priorityRank[right.priority]; } return right.updatedAt - left.updatedAt; @@ -152,13 +160,18 @@ export function presentWorkflowItemSummary( hasBlockedTasks, id: item.id, isAgentWorking, + priority: item.priority, primaryAgentId: item.primaryAgentId, primaryAgentName: primaryAgent?.name ?? null, + ...(typeof item.slaBreachedAt === 'number' ? { slaBreachedAt: item.slaBreachedAt } : {}), + ...(typeof item.slaDeadlineMs === 'number' ? { slaDeadlineMs: item.slaDeadlineMs } : {}), + ...(typeof item.slaWarnedAt === 'number' ? { slaWarnedAt: item.slaWarnedAt } : {}), specialStateLabel, status: item.status, statusLabel: formatWorkflowItemStatus(item.status), title: item.title, totalTaskCount, + updatedAt: item.updatedAt, updatedLabel: formatAgentTimestamp(item.updatedAt, now), }; } diff --git a/src/renderer/features/workflow/model/workflow-seed.ts b/src/renderer/features/workflow/model/workflow-seed.ts index 00d3b0c..755a4de 100644 --- a/src/renderer/features/workflow/model/workflow-seed.ts +++ b/src/renderer/features/workflow/model/workflow-seed.ts @@ -38,6 +38,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn 'Capture incoming ideas before deciding whether they need an agent or a heavier execution pass.', createdAt: now - 1000 * 60 * 160, id: inboxItemId, + priority: 'medium', primaryAgentId: null, projectId, scheduledTaskId: null, @@ -74,6 +75,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn 'Shape the next brief so an agent can pick it up cleanly once the direction is approved.', createdAt: now - 1000 * 60 * 230, id: readyItemId, + priority: 'medium', primaryAgentId: null, projectId, scheduledTaskId: null, @@ -118,6 +120,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn 'Rewrite the landing story so the positioning and CTA rhythm are easier to understand in one pass.', createdAt: now - 1000 * 60 * 360, id: activeItemId, + priority: 'medium', primaryAgentId: null, projectId, scheduledTaskId: null, @@ -178,6 +181,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn 'Resolve the unanswered questions and the blocked evidence before the piece can be signed off.', createdAt: now - 1000 * 60 * 520, id: reviewItemId, + priority: 'medium', primaryAgentId: null, projectId, scheduledTaskId: null, @@ -222,6 +226,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn 'This draft passed review and now needs a human decision before it can be marked done.', createdAt: now - 1000 * 60 * 610, id: acceptanceItemId, + priority: 'medium', primaryAgentId: null, projectId, scheduledTaskId: null, @@ -274,6 +279,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn 'Keep one completed item around so the board shows the end state without feeling empty on first launch.', createdAt: now - 1000 * 60 * 700, id: doneItemId, + priority: 'medium', primaryAgentId: null, projectId, scheduledTaskId: null, diff --git a/src/renderer/features/workflow/types.ts b/src/renderer/features/workflow/types.ts index 76018cb..b3a0db0 100644 --- a/src/renderer/features/workflow/types.ts +++ b/src/renderer/features/workflow/types.ts @@ -45,7 +45,19 @@ export type WorkflowProjectFilter = (typeof workflowProjectFilters)[number]; /** Workflow project screen shape. */ export type WorkflowProjectScreen = 'main' | 'settings'; /** Workflow event kind shape. */ -export type WorkflowEventKind = 'assignment' | 'feedback' | 'item' | 'note' | 'task'; +export type WorkflowEventKind = + | 'assignment' + | 'feedback' + | 'item' + | 'item.priority_changed' + | 'item.sla_breached' + | 'item.sla_cleared' + | 'item.sla_set' + | 'item.sla_warning' + | 'note' + | 'task'; +/** Workflow item priority shape. */ +export type ItemPriority = 'critical' | 'high' | 'medium' | 'low'; /** Workflow project shape. */ export interface WorkflowProject { @@ -126,9 +138,13 @@ export interface WorkflowItem { brief: string; createdAt: number; id: string; + priority: ItemPriority; primaryAgentId: string | null; projectId: string; scheduledTaskId: string | null; + slaBreachedAt?: number; + slaDeadlineMs?: number; + slaWarnedAt?: number; sortOrder: number; status: WorkflowItemStatus; tasks: WorkflowTask[]; @@ -156,12 +172,17 @@ export interface WorkflowItemSummary { hasBlockedTasks: boolean; id: string; isAgentWorking: boolean; + priority: ItemPriority; primaryAgentId: string | null; primaryAgentName: string | null; + slaBreachedAt?: number; + slaDeadlineMs?: number; + slaWarnedAt?: number; specialStateLabel: string | null; status: WorkflowItemStatus; statusLabel: string; title: string; + updatedAt: number; totalTaskCount: number; updatedLabel: string; } diff --git a/tests/unit/src/electron/main/agent-actions/handlers/items-priority-sla.test.ts b/tests/unit/src/electron/main/agent-actions/handlers/items-priority-sla.test.ts new file mode 100644 index 0000000..901cfac --- /dev/null +++ b/tests/unit/src/electron/main/agent-actions/handlers/items-priority-sla.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { itemTools } from '@/electron/main/agent-actions/handlers/items'; +import type { WorkflowSnapshot } from '@/electron/main/agent-actions/handlers/snapshot'; +import type { ToolServices } from '@/electron/main/agent-actions/handlers/types'; +import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; + +function createSnapshot(): WorkflowSnapshot { + return { + items: [ + { + activity: createWorkflowItemActivitySummary({ totalEventCount: 0 }), + artifactFolderName: 'item-1', + brief: 'Initial brief', + createdAt: 1, + id: 'item-1', + priority: 'medium', + primaryAgentId: null, + projectId: 'project-1', + scheduledTaskId: null, + sortOrder: 0, + status: 'active', + tasks: [], + title: 'Item', + updatedAt: 1, + workProducts: [], + workflowEvents: [], + }, + ], + projects: [ + { + color: '#000000', + createdAt: 1, + description: 'Project', + id: 'project-1', + name: 'Project', + rootPath: null, + updatedAt: 1, + }, + ], + selectedItemId: null, + selectedProjectFilter: 'all', + selectedProjectId: 'project-1', + selectedProjectView: 'board', + }; +} + +function createServices(snapshot: WorkflowSnapshot): ToolServices & { getSnapshot: () => WorkflowSnapshot } { + let currentSnapshot = structuredClone(snapshot); + + return { + agentContext: { + agentId: 'agent-1', + agentName: 'Planner', + ipcContainerDir: '/workspace/extra/dune', + ipcHostDir: '/tmp/dune', + projectId: 'project-1', + }, + getRuntimeController: () => ({ + getSnapshot: () => ({ agents: [] }), + }) as never, + getSnapshot: () => currentSnapshot, + onWorkflowChanged: () => undefined, + workflowStore: { + delete: async () => undefined, + get: async (key: string) => (key === 'snapshot' ? structuredClone(currentSnapshot) as T : null), + keys: async () => ['snapshot'], + set: async (key: string, value: T) => { + if (key === 'snapshot') { + currentSnapshot = structuredClone(value as WorkflowSnapshot); + } + }, + }, + }; +} + +describe('workflow item priority and SLA actions', () => { + it('persists priority and SLA updates with workflow events', async () => { + const services = createServices(createSnapshot()); + const updateHandler = itemTools.find((tool) => tool.definition.name === 'workflow.items.update')!.handler; + const listHandler = itemTools.find((tool) => tool.definition.name === 'workflow.items.list')!.handler; + const deadline = Date.UTC(2026, 0, 1); + + await updateHandler(services, { + itemId: 'item-1', + priority: 'critical', + slaDeadlineMs: deadline, + }); + + const item = services.getSnapshot().items[0]!; + expect(item.priority).toBe('critical'); + expect(item.slaDeadlineMs).toBe(deadline); + expect(item.workflowEvents.map((event) => event.kind)).toEqual([ + 'item.sla_set', + 'item.priority_changed', + ]); + + const result = await listHandler(services, {}) as { items: Array> }; + expect(result.items[0]).toEqual(expect.objectContaining({ + priority: 'critical', + slaDeadlineMs: deadline, + })); + }); + + it('clears SLA deadlines and previous escalation timestamps', async () => { + const services = createServices(createSnapshot()); + services.getSnapshot().items[0]!.slaDeadlineMs = 100; + services.getSnapshot().items[0]!.slaWarnedAt = 50; + services.getSnapshot().items[0]!.slaBreachedAt = 100; + const updateHandler = itemTools.find((tool) => tool.definition.name === 'workflow.items.update')!.handler; + + await updateHandler(services, { + itemId: 'item-1', + slaDeadlineMs: null, + }); + + const item = services.getSnapshot().items[0]!; + expect(item.slaDeadlineMs).toBeUndefined(); + expect(item.slaWarnedAt).toBeUndefined(); + expect(item.slaBreachedAt).toBeUndefined(); + expect(item.workflowEvents[0]?.kind).toBe('item.sla_cleared'); + }); +}); diff --git a/tests/unit/src/electron/main/agent-actions/handlers/mutation-notes.test.ts b/tests/unit/src/electron/main/agent-actions/handlers/mutation-notes.test.ts index a44a034..e439c2e 100644 --- a/tests/unit/src/electron/main/agent-actions/handlers/mutation-notes.test.ts +++ b/tests/unit/src/electron/main/agent-actions/handlers/mutation-notes.test.ts @@ -17,6 +17,7 @@ function createSnapshot(status: string = 'active'): WorkflowSnapshot { brief: 'Initial brief', createdAt: 1, id: 'item-1', + priority: 'medium', primaryAgentId: 'agent-1', projectId: 'project-1', scheduledTaskId: null, diff --git a/tests/unit/src/electron/main/agent-actions/handlers/snapshot.test.ts b/tests/unit/src/electron/main/agent-actions/handlers/snapshot.test.ts index 8b73446..cfca176 100644 --- a/tests/unit/src/electron/main/agent-actions/handlers/snapshot.test.ts +++ b/tests/unit/src/electron/main/agent-actions/handlers/snapshot.test.ts @@ -17,6 +17,7 @@ function createSnapshot(): WorkflowSnapshot { brief: 'Brief', createdAt: 1, id: 'item-1', + priority: 'medium', primaryAgentId: 'agent-1', projectId: 'project-1', scheduledTaskId: null, diff --git a/tests/unit/src/electron/main/agent-actions/handlers/validators.test.ts b/tests/unit/src/electron/main/agent-actions/handlers/validators.test.ts index 1210acf..cda0531 100644 --- a/tests/unit/src/electron/main/agent-actions/handlers/validators.test.ts +++ b/tests/unit/src/electron/main/agent-actions/handlers/validators.test.ts @@ -15,6 +15,7 @@ function createItem(status: WorkflowItem['status']): WorkflowItem { brief: 'Brief', createdAt: 1, id: 'item-1', + priority: 'medium', primaryAgentId: 'agent-1', projectId: 'project-1', scheduledTaskId: null, diff --git a/tests/unit/src/electron/main/runtime/agent-runtime.test.ts b/tests/unit/src/electron/main/runtime/agent-runtime.test.ts index 22a9150..06d279b 100644 --- a/tests/unit/src/electron/main/runtime/agent-runtime.test.ts +++ b/tests/unit/src/electron/main/runtime/agent-runtime.test.ts @@ -2681,6 +2681,7 @@ describe('AgentRuntime', () => { brief: 'Ship the runtime update.', createdAt: 1, id: 'item-1', + priority: 'medium', primaryAgentId: null, projectId: 'project-1', scheduledTaskId: 'task-1', diff --git a/tests/unit/src/electron/main/runtime/desktop-runtime-controller.test.ts b/tests/unit/src/electron/main/runtime/desktop-runtime-controller.test.ts index f31e5e1..12b5d28 100644 --- a/tests/unit/src/electron/main/runtime/desktop-runtime-controller.test.ts +++ b/tests/unit/src/electron/main/runtime/desktop-runtime-controller.test.ts @@ -114,4 +114,40 @@ describe('DesktopRuntimeController', () => { expect(cancelItemAssignment).toHaveBeenCalledWith('agent-1', 'task-123'); expect(taskId).toBe('task-123'); }); + + it('starts and stops the SLA monitor with the desktop runtime', async () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dune-controller-home-')); + tempDirs.push(homeDir); + const startMonitor = vi.fn(); + const stopMonitor = vi.fn(); + let controller!: DesktopRuntimeController; + controller = new DesktopRuntimeController({ + actionServices: { + getRuntimeController: () => controller, + onWorkflowChanged: vi.fn(), + workflowStore: { + delete: async () => undefined, + get: async () => null, + keys: async () => [], + set: async () => undefined, + }, + }, + agentStore: { get: async () => null, set: async () => {} }, + createRealRuntime: () => ({ + ...createMockAgentRuntime(), + start: async () => undefined, + }), + createSlaMonitor: () => ({ + start: startMonitor, + stop: stopMonitor, + }), + homeDir, + }); + + await controller.start(); + await controller.shutdown(); + + expect(startMonitor).toHaveBeenCalledTimes(1); + expect(stopMonitor).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/src/electron/main/sla/sla-monitor.test.ts b/tests/unit/src/electron/main/sla/sla-monitor.test.ts new file mode 100644 index 0000000..bcf6b31 --- /dev/null +++ b/tests/unit/src/electron/main/sla/sla-monitor.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SlaMonitor } from '@/electron/main/sla/sla-monitor'; +import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; +import type { AppStorage } from '@/electron/main/storage'; +import type { WorkflowItem, WorkflowSnapshot } from '@/electron/main/agent-actions/handlers/snapshot'; + +function item(overrides: Partial = {}): WorkflowItem { + return { + activity: createWorkflowItemActivitySummary(), + artifactFolderName: 'item-1', + brief: 'Brief', + createdAt: 1, + id: 'item-1', + priority: 'medium', + primaryAgentId: null, + projectId: 'project-1', + scheduledTaskId: null, + sortOrder: 0, + status: 'active', + tasks: [], + title: 'Item', + updatedAt: 1, + workProducts: [], + workflowEvents: [], + ...overrides, + }; +} + +function snapshot(items: WorkflowItem[]): WorkflowSnapshot { + return { + items, + projects: [ + { + color: '#000', + createdAt: 1, + description: '', + id: 'project-1', + name: 'Project', + rootPath: null, + updatedAt: 1, + }, + ], + selectedItemId: null, + selectedProjectFilter: 'all', + selectedProjectId: 'project-1', + selectedProjectView: 'board', + }; +} + +function store(initial: WorkflowSnapshot): AppStorage & { value: WorkflowSnapshot } { + const storage = { + value: initial, + delete: async () => undefined, + get: async (key: string) => (key === 'snapshot' ? initial as T : null), + keys: async () => [], + set: vi.fn(async (_key: string, value: unknown) => { + initial = value as WorkflowSnapshot; + storage.value = initial; + }), + }; + + return storage; +} + +describe('SlaMonitor', () => { + it('warns within two hours and does not duplicate warnings', async () => { + const workflowStore = store(snapshot([item({ slaDeadlineMs: 2 * 60 * 60 * 1000 })])); + const notify = vi.fn(); + const monitor = new SlaMonitor({ + notificationManager: { notify }, + now: () => 1_000, + onWorkflowChanged: vi.fn(), + workflowStore, + }); + + await monitor.runOnce(); + await monitor.runOnce(); + + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'sla_warning' })); + expect((await workflowStore.get('snapshot'))?.items[0]?.slaWarnedAt).toBe(1_000); + }); + + it('breaches at the deadline and does not duplicate breaches', async () => { + const workflowStore = store(snapshot([item({ slaDeadlineMs: 1_000 })])); + const notify = vi.fn(); + const monitor = new SlaMonitor({ + notificationManager: { notify }, + now: () => 1_000, + onWorkflowChanged: vi.fn(), + workflowStore, + }); + + await monitor.runOnce(); + await monitor.runOnce(); + + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'sla_breach' })); + expect((await workflowStore.get('snapshot'))?.items[0]?.slaBreachedAt).toBe(1_000); + }); + + it('skips acceptance and done items', async () => { + const workflowStore = store(snapshot([ + item({ id: 'done', slaDeadlineMs: 1_000, status: 'done' }), + item({ id: 'acceptance', slaDeadlineMs: 1_000, status: 'acceptance' }), + ])); + const notify = vi.fn(); + const monitor = new SlaMonitor({ + notificationManager: { notify }, + now: () => 2_000, + onWorkflowChanged: vi.fn(), + workflowStore, + }); + + await monitor.runOnce(); + + expect(notify).not.toHaveBeenCalled(); + expect(workflowStore.set).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/src/electron/main/workflow/workflow-coordinator.test.ts b/tests/unit/src/electron/main/workflow/workflow-coordinator.test.ts index ff2b350..996851c 100644 --- a/tests/unit/src/electron/main/workflow/workflow-coordinator.test.ts +++ b/tests/unit/src/electron/main/workflow/workflow-coordinator.test.ts @@ -68,6 +68,7 @@ function createItem(overrides: Record = {}) { activity: createWorkflowItemActivitySummary(), createdAt: 0, id: 'item-1', + priority: 'medium', primaryAgentId: 'agent-1', projectId: 'project-1', scheduledTaskId: null, diff --git a/tests/unit/src/renderer/features/workflow/components/ItemCard.test.tsx b/tests/unit/src/renderer/features/workflow/components/ItemCard.test.tsx new file mode 100644 index 0000000..2a0cd05 --- /dev/null +++ b/tests/unit/src/renderer/features/workflow/components/ItemCard.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ItemCard } from '@/renderer/features/workflow/components/WorkflowBoard'; +import type { WorkflowItemSummary } from '@/renderer/features/workflow/types'; + +function item(overrides: Partial = {}): WorkflowItemSummary { + return { + brief: 'Brief', + completedTaskCount: 0, + currentTaskTitle: null, + hasBlockedTasks: false, + id: 'item-1', + isAgentWorking: false, + priority: 'critical', + primaryAgentId: null, + primaryAgentName: null, + specialStateLabel: null, + status: 'active', + statusLabel: 'Active', + title: 'Important item', + totalTaskCount: 1, + updatedAt: 1, + updatedLabel: 'just now', + ...overrides, + }; +} + +describe('ItemCard SLA and priority badges', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders priority badge colors', () => { + render(); + + expect(screen.getByTestId('priority-badge-critical')).toHaveClass('text-[#ef4444]'); + }); + + it('only renders medium and low priority badges when SLA is set', () => { + const { rerender } = render( + , + ); + + expect(screen.queryByTestId('priority-badge-medium')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByTestId('priority-badge-low')).toHaveClass('text-[#6b7280]'); + }); + + it('renders warning, breached, and met countdown variants', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + + const { rerender } = render( + , + ); + expect(screen.getByTestId('sla-countdown')).toHaveClass('text-yellow-600'); + + rerender( + , + ); + expect(screen.getByTestId('sla-countdown')).toHaveClass('text-red-500'); + + rerender( + , + ); + expect(screen.getByTestId('sla-countdown')).toHaveClass('text-emerald-500'); + }); +}); diff --git a/tests/unit/src/renderer/features/workflow/components/Lane.test.tsx b/tests/unit/src/renderer/features/workflow/components/Lane.test.tsx new file mode 100644 index 0000000..7a715a9 --- /dev/null +++ b/tests/unit/src/renderer/features/workflow/components/Lane.test.tsx @@ -0,0 +1,50 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { WorkflowBoard } from '@/renderer/features/workflow/components/WorkflowBoard'; +import type { ItemPriority, WorkflowItemSummary } from '@/renderer/features/workflow/types'; + +function item(id: string, title: string, priority: ItemPriority, updatedAt: number): WorkflowItemSummary { + return { + brief: '', + completedTaskCount: 0, + currentTaskTitle: null, + hasBlockedTasks: false, + id, + isAgentWorking: false, + priority, + primaryAgentId: null, + primaryAgentName: null, + specialStateLabel: null, + status: 'active', + statusLabel: 'Active', + title, + totalTaskCount: 0, + updatedAt, + updatedLabel: 'now', + }; +} + +describe('workflow lane sorting', () => { + it('sorts by priority and then updatedAt descending', () => { + render( + , + ); + + const titles = within(screen.getByTestId('workflow-column-body-active')) + .getAllByRole('button', { name: /Open/ }) + .map((button) => button.getAttribute('aria-label')); + + expect(titles).toEqual(['Open Critical', 'Open High new', 'Open High old', 'Open Low']); + }); +}); diff --git a/tests/unit/src/renderer/features/workflow/components/WorkflowBoard.test.tsx b/tests/unit/src/renderer/features/workflow/components/WorkflowBoard.test.tsx index 8d752cc..19db836 100644 --- a/tests/unit/src/renderer/features/workflow/components/WorkflowBoard.test.tsx +++ b/tests/unit/src/renderer/features/workflow/components/WorkflowBoard.test.tsx @@ -1,7 +1,7 @@ // Workflow board UI tests. import { render, screen, within } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { WorkflowBoard } from '@/renderer/features/workflow/components/WorkflowBoard'; import type { WorkflowItemSummary } from '@/renderer/features/workflow/types'; @@ -11,6 +11,7 @@ const baseItem = ( id: string, title: string, status: WorkflowItemSummary['status'], + overrides: Partial = {}, ): WorkflowItemSummary => ({ brief: `Brief for ${title}`, completedTaskCount: 0, @@ -18,6 +19,7 @@ const baseItem = ( hasBlockedTasks: false, id, isAgentWorking: false, + priority: 'medium', primaryAgentId: null, primaryAgentName: null, specialStateLabel: @@ -30,10 +32,16 @@ const baseItem = ( statusLabel: status, title, totalTaskCount: 1, + updatedAt: 1, updatedLabel: 'just now', + ...overrides, }); describe('WorkflowBoard', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('lets board columns expand to use wider shells while preserving horizontal overflow on smaller widths', () => { render( { ), ).toBeInTheDocument(); }); + + it('sorts lane items by priority and then most recent update', () => { + render( + , + ); + + const activeColumnBody = screen.getByTestId('workflow-column-body-active'); + const titles = within(activeColumnBody) + .getAllByRole('button', { name: /Open / }) + .map((button) => button.textContent); + + expect(titles).toEqual([ + expect.stringContaining('Critical item'), + expect.stringContaining('Newer high item'), + expect.stringContaining('Older high item'), + expect.stringContaining('Low item'), + ]); + }); + + it('shows priority badges and SLA countdown state on item cards', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + + render( + , + ); + + expect(screen.getByTestId('priority-badge-critical')).toHaveTextContent('critical'); + expect(screen.getByTestId('priority-badge-medium')).toHaveTextContent('medium'); + expect(screen.queryByTestId('priority-badge-low')).not.toBeInTheDocument(); + expect(screen.getByTestId('sla-countdown')).toHaveTextContent('1h left'); + + vi.useRealTimers(); + }); }); diff --git a/tests/unit/src/renderer/features/workflow/components/WorkflowItemInspector.test.tsx b/tests/unit/src/renderer/features/workflow/components/WorkflowItemInspector.test.tsx index d7fc340..d2ba79b 100644 --- a/tests/unit/src/renderer/features/workflow/components/WorkflowItemInspector.test.tsx +++ b/tests/unit/src/renderer/features/workflow/components/WorkflowItemInspector.test.tsx @@ -30,6 +30,7 @@ const item: WorkflowItem & { brief: 'Rewrite the homepage narrative.', createdAt: 1, id: 'item-1', + priority: 'medium', primaryAgentId: null, primaryAgentName: null, projectId: project.id, diff --git a/tests/unit/src/renderer/features/workflow/hooks/use-sla-countdown.test.ts b/tests/unit/src/renderer/features/workflow/hooks/use-sla-countdown.test.ts new file mode 100644 index 0000000..93b6352 --- /dev/null +++ b/tests/unit/src/renderer/features/workflow/hooks/use-sla-countdown.test.ts @@ -0,0 +1,48 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useSlaCountdown } from '@/renderer/features/workflow/hooks/use-sla-countdown'; + +describe('useSlaCountdown', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns null without an SLA deadline', () => { + const { result } = renderHook(() => useSlaCountdown()); + + expect(result.current).toBeNull(); + }); + + it('reports warning state inside the final two hours', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + + const { result } = renderHook(() => useSlaCountdown(1_000 + 60 * 60 * 1000)); + + expect(result.current).toEqual({ + isBreached: false, + isWarning: true, + msLeft: 60 * 60 * 1000, + }); + }); + + it('updates on minute ticks and reports breached deadlines', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + + const { result } = renderHook(() => useSlaCountdown(61_000)); + + expect(result.current?.isBreached).toBe(false); + + act(() => { + vi.advanceTimersByTime(60_000); + }); + + expect(result.current).toMatchObject({ + isBreached: true, + isWarning: false, + }); + expect(result.current?.msLeft).toBeLessThanOrEqual(0); + }); +}); diff --git a/tests/unit/src/shared/workflow/activity.test.ts b/tests/unit/src/shared/workflow/activity.test.ts index 7c87a34..6c3ee0b 100644 --- a/tests/unit/src/shared/workflow/activity.test.ts +++ b/tests/unit/src/shared/workflow/activity.test.ts @@ -14,6 +14,7 @@ function createItem(overrides: Partial = {}): WorkflowItem { brief: 'Brief', createdAt: 1, id: 'item-1', + priority: 'medium', primaryAgentId: null, projectId: 'project-1', scheduledTaskId: null,