diff --git a/src/electron/main.ts b/src/electron/main.ts index 93eea0d..a3a5f9d 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -3,6 +3,7 @@ import { app, BrowserWindow, + Notification, session, } from 'electron'; import fixPath from 'fix-path'; @@ -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); }, diff --git a/src/electron/main/agent-actions/handlers/items.ts b/src/electron/main/agent-actions/handlers/items.ts index 9418bec..c43e370 100644 --- a/src/electron/main/agent-actions/handlers/items.ts +++ b/src/electron/main/agent-actions/handlers/items.ts @@ -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, @@ -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, @@ -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'], @@ -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); @@ -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) }; } diff --git a/src/electron/main/agent-actions/handlers/snapshot.ts b/src/electron/main/agent-actions/handlers/snapshot.ts index 7736757..ae11a67 100644 --- a/src/electron/main/agent-actions/handlers/snapshot.ts +++ b/src/electron/main/agent-actions/handlers/snapshot.ts @@ -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'; @@ -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; @@ -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 })), @@ -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() diff --git a/src/electron/main/db/migrations/006_priority_sla.sql b/src/electron/main/db/migrations/006_priority_sla.sql new file mode 100644 index 0000000..9a557a6 --- /dev/null +++ b/src/electron/main/db/migrations/006_priority_sla.sql @@ -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; diff --git a/src/electron/main/orm/schema/workflow.ts b/src/electron/main/orm/schema/workflow.ts index 80002a7..9282eea 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, @@ -42,11 +43,18 @@ export const workflowItems = sqliteTable( createdAt: integer('created_at').notNull().$type(), id: text('id').primaryKey(), primaryAgentId: text('primary_agent_id'), + priority: text('priority', { enum: ['critical', 'high', 'medium', 'low'] }) + .$type() + .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(), + slaDeadlineMs: integer('sla_deadline_ms').$type(), + slaWarnedAt: integer('sla_warned_at').$type(), status: text('status', { enum: workflowItemStatuses }).$type().notNull(), title: text('title').notNull(), updatedAt: integer('updated_at').notNull().$type(), diff --git a/src/electron/main/sla/sla-monitor.ts b/src/electron/main/sla/sla-monitor.ts new file mode 100644 index 0000000..c398523 --- /dev/null +++ b/src/electron/main/sla/sla-monitor.ts @@ -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>; + projects: Array>; +} + +function isSlaWorkflowSnapshot(value: unknown): value is SlaWorkflowSnapshot { + return isPlainObject(value) && Array.isArray(value.items) && Array.isArray(value.projects); +} + +function prependSlaEvent( + item: Record, + 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 | 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, + }; +} diff --git a/src/electron/main/workflow/workflow-coordinator.ts b/src/electron/main/workflow/workflow-coordinator.ts index 0733b30..fbe2a4b 100644 --- a/src/electron/main/workflow/workflow-coordinator.ts +++ b/src/electron/main/workflow/workflow-coordinator.ts @@ -23,6 +23,7 @@ import { getWorkflowItemActivityArchiveItemId, } from '@/shared/workflow/activity'; import { shouldScheduleItemAssignmentTask } from '@/shared/workflow/item-assignment'; +import { createSlaMonitor } from '@/electron/main/sla/sla-monitor'; type WorkflowRuntimeController = Pick< DesktopRuntimeController, @@ -36,6 +37,7 @@ interface WorkflowCoordinatorOptions { clearInterval?: typeof globalThis.clearInterval; clearTimeout?: typeof globalThis.clearTimeout; getRuntimeController: () => WorkflowRuntimeController | null; + notifySla?: Parameters[0]['notifySla']; notifyWorkflowChanged: () => void; setInterval?: typeof globalThis.setInterval; setTimeout?: typeof globalThis.setTimeout; @@ -431,6 +433,7 @@ export function createWorkflowCoordinator(options: WorkflowCoordinatorOptions) { createdAt: now, id: `item-auto-${now}`, primaryAgentId: agent.id, + priority: 'medium', projectId: agent.projectId, scheduledTaskId: null, sortOrder: 0, @@ -510,6 +513,14 @@ export function createWorkflowCoordinator(options: WorkflowCoordinatorOptions) { }, } satisfies AppStorage; + const slaMonitor = createSlaMonitor({ + notifyWorkflowChanged: options.notifyWorkflowChanged, + ...(options.notifySla ? { notifySla: options.notifySla } : {}), + setInterval: setIntervalFn, + clearInterval: clearIntervalFn, + workflowStore, + }); + function onWorkflowChanged() { options.notifyWorkflowChanged(); if (nudgeScheduled) { @@ -540,7 +551,9 @@ export function createWorkflowCoordinator(options: WorkflowCoordinatorOptions) { taskSweepIntervalHandle = setIntervalFn(() => { void sweepItemAssignmentTasks(); }, 120_000); + slaMonitor.start(); void sweepItemAssignmentTasks(); + void slaMonitor.checkNow(); } function stop() { @@ -561,6 +574,8 @@ export function createWorkflowCoordinator(options: WorkflowCoordinatorOptions) { clearIntervalFn(taskSweepIntervalHandle); taskSweepIntervalHandle = null; } + + slaMonitor.stop(); } return { diff --git a/src/renderer/app/hooks/use-workflow-persistence.ts b/src/renderer/app/hooks/use-workflow-persistence.ts index 82097de..dccc30b 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, + workflowItemPriorities, workflowProjectFilters, workflowProjectViews, workflowTaskStatuses, @@ -21,6 +22,10 @@ import { } from '@/shared/workflow/project-artifacts'; import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; import { isPlainObject } from '@/shared/is-record'; +import { + normalizeItemPriority, + normalizeSlaDeadlineMs, +} from '@/shared/workflow/priority-sla'; const STORE_NAME = 'workflow'; const STORE_KEY = 'snapshot'; @@ -219,10 +224,18 @@ function normalizeCurrentSnapshot(value: unknown): WorkflowSnapshot | null { typeof item.primaryAgentId === 'string' || item.primaryAgentId === null ? item.primaryAgentId : null, + priority: workflowItemPriorities.includes(item.priority as (typeof workflowItemPriorities)[number]) + ? normalizeItemPriority(item.priority) + : 'medium', projectId: item.projectId, scheduledTaskId: typeof item.scheduledTaskId === 'string' ? item.scheduledTaskId : null, sortOrder: item.sortOrder, + ...(typeof item.slaBreachedAt === 'number' ? { slaBreachedAt: item.slaBreachedAt } : {}), + ...(normalizeSlaDeadlineMs(item.slaDeadlineMs) !== undefined + ? { slaDeadlineMs: normalizeSlaDeadlineMs(item.slaDeadlineMs) } + : {}), + ...(typeof item.slaWarnedAt === 'number' ? { slaWarnedAt: item.slaWarnedAt } : {}), status: workflowItemStatuses.includes(item.status as (typeof workflowItemStatuses)[number]) ? (item.status as WorkflowSnapshot['items'][number]['status']) : 'inbox', diff --git a/src/renderer/app/store/types.ts b/src/renderer/app/store/types.ts index ea9f659..e295403 100644 --- a/src/renderer/app/store/types.ts +++ b/src/renderer/app/store/types.ts @@ -145,7 +145,7 @@ export interface WorkflowActions { ) => void; updateItem: ( itemId: string, - input: { brief?: string; note?: string; title?: string }, + input: { brief?: string; note?: string; priority?: WorkflowItem['priority']; slaDeadlineMs?: number | null; title?: string }, ) => void; updateTask: ( itemId: string, diff --git a/src/renderer/app/store/workflow-slice.ts b/src/renderer/app/store/workflow-slice.ts index b9d2e63..fd7b11f 100644 --- a/src/renderer/app/store/workflow-slice.ts +++ b/src/renderer/app/store/workflow-slice.ts @@ -27,6 +27,10 @@ import { normalizeProjectRootPath, } from '@/shared/workflow/project-artifacts'; import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; +import { + normalizeItemPriority, + normalizeSlaDeadlineMs, +} from '@/shared/workflow/priority-sla'; const defaultProjectColors = ['#A86D46', '#7A8B5D', '#4F7A78', '#9D6A71', '#6C69A6'] as const; @@ -490,6 +494,7 @@ export function createWorkflowSlice( createdAt: updatedAt, id: itemId, primaryAgentId: null, + priority: 'medium', projectId: input.projectId, scheduledTaskId: null, sortOrder: getProjectItems( @@ -794,14 +799,50 @@ export function createWorkflowSlice( const title = input.title?.trim(); const brief = input.brief?.trim(); + const nextPriority = + input.priority === undefined + ? item.priority + : normalizeItemPriority(input.priority); + const nextSlaDeadlineMs = + input.slaDeadlineMs === undefined + ? item.slaDeadlineMs + : normalizeSlaDeadlineMs(input.slaDeadlineMs); + const nextEvents: Array<{ description: string; kind: WorkflowEventKind }> = []; + + if (title !== undefined || brief !== undefined) { + nextEvents.push({ description: 'Work item details were updated.', kind: 'item' }); + } + + if (input.priority !== undefined && nextPriority !== item.priority) { + nextEvents.push({ + description: `Priority changed from ${item.priority} to ${nextPriority}.`, + kind: 'item', + }); + } + + if (input.slaDeadlineMs !== undefined && nextSlaDeadlineMs !== item.slaDeadlineMs) { + nextEvents.push({ + description: nextSlaDeadlineMs + ? `SLA deadline set to ${new Date(nextSlaDeadlineMs).toLocaleString()}.` + : 'SLA deadline cleared.', + kind: 'item', + }); + } + const nextItem = { ...item, ...(title ? { title } : {}), ...(brief !== undefined ? { brief } : {}), + priority: nextPriority, + ...(nextSlaDeadlineMs ? { slaDeadlineMs: nextSlaDeadlineMs } : {}), + ...(nextSlaDeadlineMs ? {} : { slaDeadlineMs: undefined }), + ...(input.slaDeadlineMs !== undefined + ? { slaBreachedAt: null, slaWarnedAt: null } + : {}), updatedAt, }; - if (title === undefined && brief === undefined) { + if (nextEvents.length === 0) { return item; } @@ -809,7 +850,7 @@ export function createWorkflowSlice( nextItem, [ ...(normalizedNote ? [{ description: normalizedNote, kind: 'note' as const }] : []), - { description: 'Work item details were updated.', kind: 'item' as const }, + ...nextEvents, ], updatedAt, ); diff --git a/src/renderer/features/workflow/components/WorkflowBoard.tsx b/src/renderer/features/workflow/components/WorkflowBoard.tsx index 7ae36f7..09c5435 100644 --- a/src/renderer/features/workflow/components/WorkflowBoard.tsx +++ b/src/renderer/features/workflow/components/WorkflowBoard.tsx @@ -22,14 +22,17 @@ 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'; import { cn } from '@/renderer/shared/lib/utils'; +import { compareWorkflowPriority } from '@/shared/workflow/priority-sla'; const workflowColumns: WorkflowItemStatus[] = [ 'inbox', @@ -53,7 +56,29 @@ function getColumnItems( items: WorkflowItemSummary[], status: WorkflowItemStatus, ) { - return items.filter((item) => item.status === status); + return items + .filter((item) => item.status === status) + .sort(compareWorkflowPriority); +} + +const priorityBadgeClasses: Record = { + critical: 'border-red-500/25 bg-red-500/10 text-red-500', + high: 'border-orange-500/25 bg-orange-500/10 text-orange-500', + medium: 'border-blue-500/25 bg-blue-500/10 text-blue-500', + low: 'border-gray-500/25 bg-gray-500/10 text-gray-500', +}; + +/** Formats SLA time remaining. */ +function formatSlaTime(msLeft: number) { + const absoluteMs = Math.max(0, msLeft); + const hours = Math.floor(absoluteMs / (60 * 60 * 1000)); + const minutes = Math.ceil((absoluteMs % (60 * 60 * 1000)) / (60 * 1000)); + + if (hours <= 0) { + return `${minutes}m`; + } + + return `${hours}h ${minutes}m`; } /** Renders the item card UI. */ @@ -68,6 +93,10 @@ function ItemCard({ listeners?: Record; onSelect: () => void; }) { + const sla = useSlaCountdown(item.slaDeadlineMs, item.status); + const showPriorityBadge = + item.priority === 'critical' || item.priority === 'high' || Boolean(item.slaDeadlineMs); + return (
+ {showPriorityBadge ? ( + + {item.priority} + + ) : null}

{item.title}

+ {sla ? ( +
+ + {sla.isMet + ? 'SLA met' + : sla.isBreached + ? 'SLA breached' + : `SLA: ${formatSlaTime(sla.msLeft)}`} + +
+ ) : null}

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

diff --git a/src/renderer/features/workflow/components/WorkflowItemInspector.tsx b/src/renderer/features/workflow/components/WorkflowItemInspector.tsx index 1c4f713..5ac5a19 100644 --- a/src/renderer/features/workflow/components/WorkflowItemInspector.tsx +++ b/src/renderer/features/workflow/components/WorkflowItemInspector.tsx @@ -40,7 +40,7 @@ interface WorkflowItemInspectorProps { onAssignPrimaryAgent: (itemId: string, input: { agentId: string | null; agentName?: string | null }) => void; onCreateAgent: (itemId: string) => void; onOpenAgent: (agentId: string) => void; - onUpdateItem: (itemId: string, input: { brief?: string; title?: string }) => void; + onUpdateItem: (itemId: string, input: { brief?: string; priority?: WorkflowItem['priority']; slaDeadlineMs?: number | null; title?: string }) => void; onUpdateItemStatus: (itemId: string, status: WorkflowItemStatus) => void; onUpdateTask: ( itemId: string, @@ -109,6 +109,17 @@ function formatArtifactSize(size: number | null) { return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`; } +/** Formats a datetime-local input value. */ +function formatDatetimeLocal(timestamp: number | undefined) { + if (!timestamp) { + return ''; + } + + const date = new Date(timestamp); + const offsetMs = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); +} + /** Renders the workflow item inspector UI. */ export function WorkflowItemInspector({ item, @@ -362,6 +373,54 @@ export function WorkflowItemInspector({ + +
+
+
+ + +
+
+ + { + const value = event.target.value; + onUpdateItem(item.id, { + slaDeadlineMs: value ? new Date(value).getTime() : null, + }); + }} + type="datetime-local" + value={formatDatetimeLocal(item.slaDeadlineMs)} + /> +
+
+
{ + if (!slaDeadlineMs) { + return; + } + + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, [slaDeadlineMs]); + + return getSlaState(slaDeadlineMs, status, now); +} diff --git a/src/renderer/features/workflow/model/workflow-presenters.ts b/src/renderer/features/workflow/model/workflow-presenters.ts index f9165b7..a74e8c1 100644 --- a/src/renderer/features/workflow/model/workflow-presenters.ts +++ b/src/renderer/features/workflow/model/workflow-presenters.ts @@ -13,6 +13,7 @@ import type { WorkflowSnapshot, WorkflowTaskStatus, } from '@/renderer/features/workflow/types'; +import { compareWorkflowPriority } from '@/shared/workflow/priority-sla'; /** Defines workflow item status labels. */ export const workflowItemStatusLabels: Record = { @@ -35,6 +36,12 @@ export const workflowTaskStatusLabels: Record = { /** Compares items. */ function compareItems(left: WorkflowItem, right: WorkflowItem) { + const priorityDelta = compareWorkflowPriority(left, right); + + if (priorityDelta !== 0) { + return priorityDelta; + } + if (left.sortOrder !== right.sortOrder) { return left.sortOrder - right.sortOrder; } @@ -154,11 +161,14 @@ export function presentWorkflowItemSummary( isAgentWorking, primaryAgentId: item.primaryAgentId, primaryAgentName: primaryAgent?.name ?? null, + priority: item.priority, + ...(item.slaDeadlineMs ? { slaDeadlineMs: item.slaDeadlineMs } : {}), 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..2514a24 100644 --- a/src/renderer/features/workflow/model/workflow-seed.ts +++ b/src/renderer/features/workflow/model/workflow-seed.ts @@ -39,6 +39,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn createdAt: now - 1000 * 60 * 160, id: inboxItemId, primaryAgentId: null, + priority: 'medium', projectId, scheduledTaskId: null, sortOrder: 0, @@ -75,6 +76,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn createdAt: now - 1000 * 60 * 230, id: readyItemId, primaryAgentId: null, + priority: 'medium', projectId, scheduledTaskId: null, sortOrder: 0, @@ -119,6 +121,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn createdAt: now - 1000 * 60 * 360, id: activeItemId, primaryAgentId: null, + priority: 'medium', projectId, scheduledTaskId: null, sortOrder: 0, @@ -179,6 +182,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn createdAt: now - 1000 * 60 * 520, id: reviewItemId, primaryAgentId: null, + priority: 'medium', projectId, scheduledTaskId: null, sortOrder: 0, @@ -223,6 +227,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn createdAt: now - 1000 * 60 * 610, id: acceptanceItemId, primaryAgentId: null, + priority: 'medium', projectId, scheduledTaskId: null, sortOrder: 0, @@ -275,6 +280,7 @@ export function createSeedWorkflowSnapshot(now: number = Date.now()): WorkflowSn createdAt: now - 1000 * 60 * 700, id: doneItemId, primaryAgentId: null, + priority: 'medium', projectId, scheduledTaskId: null, sortOrder: 0, diff --git a/src/renderer/features/workflow/types.ts b/src/renderer/features/workflow/types.ts index 76018cb..82d7829 100644 --- a/src/renderer/features/workflow/types.ts +++ b/src/renderer/features/workflow/types.ts @@ -34,8 +34,18 @@ export const workflowProjectFilters = [ 'review', ] as const; +/** Workflow item priorities constant. */ +export const workflowItemPriorities = [ + 'critical', + 'high', + 'medium', + 'low', +] as const; + /** Workflow item status. */ export type WorkflowItemStatus = (typeof workflowItemStatuses)[number]; +/** Workflow item priority. */ +export type ItemPriority = (typeof workflowItemPriorities)[number]; /** Workflow task status. */ export type WorkflowTaskStatus = (typeof workflowTaskStatuses)[number]; /** Workflow project view shape. */ @@ -127,9 +137,13 @@ export interface WorkflowItem { createdAt: number; id: string; primaryAgentId: string | null; + priority: ItemPriority; projectId: string; scheduledTaskId: string | null; sortOrder: number; + slaBreachedAt?: number | null | undefined; + slaDeadlineMs?: number | undefined; + slaWarnedAt?: number | null | undefined; status: WorkflowItemStatus; tasks: WorkflowTask[]; title: string; @@ -158,10 +172,13 @@ export interface WorkflowItemSummary { isAgentWorking: boolean; primaryAgentId: string | null; primaryAgentName: string | null; + priority: ItemPriority; + slaDeadlineMs?: number; specialStateLabel: string | null; status: WorkflowItemStatus; statusLabel: string; title: string; totalTaskCount: number; + updatedAt: number; updatedLabel: string; } diff --git a/src/shared/ipc-types.ts b/src/shared/ipc-types.ts new file mode 100644 index 0000000..588d3fb --- /dev/null +++ b/src/shared/ipc-types.ts @@ -0,0 +1,16 @@ +// Shared IPC serialization types. + +import type { + ItemPriority, + WorkflowItem, + WorkflowSnapshot, +} from '@/renderer/features/workflow/types'; + +export interface SerializedWorkItem extends WorkflowItem { + priority: ItemPriority; + slaDeadlineMs?: number; +} + +export interface SerializedWorkflowSnapshot extends WorkflowSnapshot { + items: SerializedWorkItem[]; +} diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..0a1d0c8 --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,8 @@ +// Shared work item types. + +import type { + ItemPriority, + WorkflowItem as WorkItem, +} from '@/renderer/features/workflow/types'; + +export type { ItemPriority, WorkItem }; diff --git a/src/shared/workflow/priority-sla.ts b/src/shared/workflow/priority-sla.ts new file mode 100644 index 0000000..afadea2 --- /dev/null +++ b/src/shared/workflow/priority-sla.ts @@ -0,0 +1,74 @@ +// Priority and SLA helpers for workflow items. + +import type { + ItemPriority, + WorkflowItemStatus, +} from '@/renderer/features/workflow/types'; + +export const DEFAULT_ITEM_PRIORITY: ItemPriority = 'medium'; + +export const PRIORITY_ORDER: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; + +export const SLA_WARNING_WINDOW_MS = 2 * 60 * 60 * 1000; + +export function isItemPriority(value: unknown): value is ItemPriority { + return value === 'critical' || value === 'high' || value === 'medium' || value === 'low'; +} + +export function normalizeItemPriority(value: unknown): ItemPriority { + return isItemPriority(value) ? value : DEFAULT_ITEM_PRIORITY; +} + +export function normalizeSlaDeadlineMs(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : undefined; +} + +export function compareWorkflowPriority( + left: { priority?: ItemPriority; updatedAt: number }, + right: { priority?: ItemPriority; updatedAt: number }, +) { + const priorityDelta = + PRIORITY_ORDER[normalizeItemPriority(left.priority)] + - PRIORITY_ORDER[normalizeItemPriority(right.priority)]; + + return priorityDelta === 0 ? right.updatedAt - left.updatedAt : priorityDelta; +} + +export function isSlaTerminalStatus(status: WorkflowItemStatus | string) { + return status === 'acceptance' || status === 'done'; +} + +export function getSlaState( + slaDeadlineMs: number | undefined, + status: WorkflowItemStatus | string, + now: number = Date.now(), +) { + if (!slaDeadlineMs) { + return null; + } + + const msLeft = slaDeadlineMs - now; + + if (isSlaTerminalStatus(status)) { + return { + isBreached: false, + isMet: true, + isWarning: false, + msLeft, + }; + } + + return { + isBreached: msLeft <= 0, + isMet: false, + isWarning: msLeft > 0 && msLeft <= SLA_WARNING_WINDOW_MS, + msLeft, + }; +} 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..3a2d16b 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 @@ -18,6 +18,7 @@ function createSnapshot(status: string = 'active'): WorkflowSnapshot { createdAt: 1, id: 'item-1', primaryAgentId: 'agent-1', + priority: 'medium', projectId: 'project-1', scheduledTaskId: null, sortOrder: 0, @@ -125,6 +126,37 @@ describe('mutation notes', () => { expect(item.workflowEvents[1]?.description).toBe('Work item moved to active.'); }); + it('persists priority and SLA updates with activity history', async () => { + const services = createServices(createSnapshot('active')); + const updateHandler = itemTools.find((tool) => tool.definition.name === 'workflow.items.update')!.handler; + const deadline = Date.now() + 86_400_000; + + await updateHandler(services, { + itemId: 'item-1', + priority: 'critical', + slaDeadlineMs: deadline, + }); + + let item = services.getSnapshot().items[0]!; + expect(item.priority).toBe('critical'); + expect(item.slaDeadlineMs).toBe(deadline); + expect(item.workflowEvents.map((event) => event.description)).toContain( + 'Priority changed from medium to critical.', + ); + expect(item.workflowEvents.map((event) => event.description)).toContain( + `SLA deadline set to ${new Date(deadline).toISOString()}.`, + ); + + await updateHandler(services, { + itemId: 'item-1', + slaDeadlineMs: null, + }); + + item = services.getSnapshot().items[0]!; + expect(item.slaDeadlineMs).toBeUndefined(); + expect(item.workflowEvents[0]?.description).toBe('SLA deadline cleared.'); + }); + it('records notes for task updates', async () => { const services = createServices(createSnapshot('active')); const taskUpdateHandler = taskTools.find((tool) => tool.definition.name === 'workflow.tasks.update')!.handler; 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..781fbe9 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 @@ -18,6 +18,7 @@ function createSnapshot(): WorkflowSnapshot { createdAt: 1, id: 'item-1', primaryAgentId: 'agent-1', + priority: 'medium', projectId: 'project-1', scheduledTaskId: null, sortOrder: 0, 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..c578891 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 @@ -16,6 +16,7 @@ function createItem(status: WorkflowItem['status']): WorkflowItem { createdAt: 1, id: 'item-1', primaryAgentId: 'agent-1', + priority: 'medium', projectId: 'project-1', scheduledTaskId: null, sortOrder: 0, 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..bcdf702 100644 --- a/tests/unit/src/electron/main/runtime/agent-runtime.test.ts +++ b/tests/unit/src/electron/main/runtime/agent-runtime.test.ts @@ -2682,6 +2682,7 @@ describe('AgentRuntime', () => { createdAt: 1, id: 'item-1', primaryAgentId: null, + priority: 'medium', projectId: 'project-1', scheduledTaskId: 'task-1', sortOrder: 0, 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..c198783 --- /dev/null +++ b/tests/unit/src/electron/main/sla/sla-monitor.test.ts @@ -0,0 +1,72 @@ +// SLA monitor tests. + +import { describe, expect, it, vi } from 'vitest'; + +import { createSlaMonitor } from '@/electron/main/sla/sla-monitor'; +import type { AppStorage } from '@/electron/main/storage'; + +function createMemoryStore(snapshot: unknown) { + let value = snapshot; + const setSpy = vi.fn(); + + const store: AppStorage = { + delete: vi.fn(async () => undefined), + get: async () => value as T | null, + keys: vi.fn(async () => []), + set: async (_key: string, next: T) => { + setSpy(_key, next); + value = next; + }, + }; + + return Object.assign(store, { setSpy }); +} + +describe('createSlaMonitor', () => { + it('warns and breaches once while skipping terminal items', async () => { + const now = new Date('2026-04-24T12:00:00.000Z').getTime(); + const store = createMemoryStore({ + items: [ + { + id: 'warn', + status: 'active', + title: 'Warn item', + slaDeadlineMs: now + 60 * 60_000, + workflowEvents: [], + }, + { + id: 'breach', + status: 'review', + title: 'Breach item', + slaDeadlineMs: now - 1, + workflowEvents: [], + }, + { + id: 'done', + status: 'done', + title: 'Done item', + slaDeadlineMs: now - 1, + workflowEvents: [], + }, + ], + projects: [], + }); + const notifySla = vi.fn(); + const notifyWorkflowChanged = vi.fn(); + const monitor = createSlaMonitor({ + notifySla, + notifyWorkflowChanged, + now: () => now, + workflowStore: store, + }); + + await monitor.checkNow(); + await monitor.checkNow(); + + expect(notifySla).toHaveBeenCalledTimes(2); + expect(notifySla).toHaveBeenCalledWith(expect.objectContaining({ itemId: 'warn', type: 'sla_warning' })); + expect(notifySla).toHaveBeenCalledWith(expect.objectContaining({ itemId: 'breach', type: 'sla_breach' })); + expect(notifyWorkflowChanged).toHaveBeenCalledTimes(1); + expect(store.setSpy).toHaveBeenCalledTimes(1); + }); +}); 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..41876af 100644 --- a/tests/unit/src/renderer/features/workflow/components/WorkflowBoard.test.tsx +++ b/tests/unit/src/renderer/features/workflow/components/WorkflowBoard.test.tsx @@ -20,6 +20,7 @@ const baseItem = ( isAgentWorking: false, primaryAgentId: null, primaryAgentName: null, + priority: 'medium', specialStateLabel: status === 'review' ? 'Review' @@ -30,6 +31,7 @@ const baseItem = ( statusLabel: status, title, totalTaskCount: 1, + updatedAt: Date.now(), updatedLabel: 'just now', }); @@ -93,4 +95,52 @@ describe('WorkflowBoard', () => { ), ).toBeInTheDocument(); }); + + it('sorts cards by priority inside each lane and renders SLA alerts', () => { + const now = new Date('2026-04-24T12:00:00.000Z').getTime(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + render( + , + ); + + const activeColumn = screen.getByTestId('workflow-column-body-active'); + const titles = within(activeColumn) + .getAllByRole('button', { name: /^Open / }) + .map((button) => button.textContent); + + expect(titles[0]).toContain('critical'); + expect(titles[0]).toContain('Critical priority'); + expect(titles[1]).toContain('high'); + expect(titles[1]).toContain('High priority'); + expect(titles[2]).toContain('Low priority'); + expect(within(activeColumn).getByText('SLA breached')).toBeInTheDocument(); + expect(within(activeColumn).getByText('SLA: 1h 0m')).toBeInTheDocument(); + + 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..ce49e5e 100644 --- a/tests/unit/src/renderer/features/workflow/components/WorkflowItemInspector.test.tsx +++ b/tests/unit/src/renderer/features/workflow/components/WorkflowItemInspector.test.tsx @@ -32,6 +32,7 @@ const item: WorkflowItem & { id: 'item-1', primaryAgentId: null, primaryAgentName: null, + priority: 'medium', projectId: project.id, scheduledTaskId: null, sortOrder: 0, diff --git a/tests/unit/src/renderer/features/workflow/hooks/use-sla-countdown.test.tsx b/tests/unit/src/renderer/features/workflow/hooks/use-sla-countdown.test.tsx new file mode 100644 index 0000000..ed343f6 --- /dev/null +++ b/tests/unit/src/renderer/features/workflow/hooks/use-sla-countdown.test.tsx @@ -0,0 +1,37 @@ +// SLA countdown hook tests. + +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useSlaCountdown } from '@/renderer/features/workflow/hooks/use-sla-countdown'; + +describe('useSlaCountdown', () => { + it('computes warning, breached, and met states', () => { + const now = new Date('2026-04-24T12:00:00.000Z').getTime(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + expect(renderHook(() => useSlaCountdown(now + 60 * 60_000, 'active')).result.current).toMatchObject({ + isBreached: false, + isMet: false, + isWarning: true, + msLeft: 60 * 60_000, + }); + + expect(renderHook(() => useSlaCountdown(now - 1, 'review')).result.current).toMatchObject({ + isBreached: true, + isMet: false, + isWarning: false, + msLeft: -1, + }); + + expect(renderHook(() => useSlaCountdown(now - 1, 'acceptance')).result.current).toMatchObject({ + isBreached: false, + isMet: true, + isWarning: false, + msLeft: -1, + }); + + vi.useRealTimers(); + }); +}); diff --git a/tests/unit/src/shared/workflow/activity.test.ts b/tests/unit/src/shared/workflow/activity.test.ts index 7c87a34..109ea98 100644 --- a/tests/unit/src/shared/workflow/activity.test.ts +++ b/tests/unit/src/shared/workflow/activity.test.ts @@ -15,6 +15,7 @@ function createItem(overrides: Partial = {}): WorkflowItem { createdAt: 1, id: 'item-1', primaryAgentId: null, + priority: 'medium', projectId: 'project-1', scheduledTaskId: null, sortOrder: 0,