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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion src/electron/main/agent-actions/handlers/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import {
compareItems,
createWorkflowEvent,
findItem,
isWorkflowItemPriority,
isWorkflowItemStatus,
prependWorkflowEvents,
readWorkflowSnapshot,
reindexProjectStatusGroup,
touchProject,
workflowItemStatuses,
workflowItemPriorities,
writeWorkflowSnapshot,
type WorkflowItem,
} from './snapshot';
Expand All @@ -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[] = [
{
Expand Down Expand Up @@ -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,
},
Expand All @@ -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');
Expand All @@ -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),
Expand Down Expand Up @@ -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'],
Expand All @@ -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);
Expand Down Expand Up @@ -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) };
}

Expand Down
31 changes: 31 additions & 0 deletions src/electron/main/agent-actions/handlers/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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[];
Expand Down Expand Up @@ -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 })),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/electron/main/agent-actions/register-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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'),
Expand Down
13 changes: 13 additions & 0 deletions src/electron/main/audit/audit-log.ts
Original file line number Diff line number Diff line change
@@ -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];
42 changes: 42 additions & 0 deletions src/electron/main/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
5 changes: 5 additions & 0 deletions src/electron/main/db/migrations/001_priority_sla.sql
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 28 additions & 0 deletions src/electron/main/notifications/notification-manager.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
14 changes: 13 additions & 1 deletion src/electron/main/orm/schema/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/electron/main/orm/schema/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

import type {
WorkflowEventKind,
ItemPriority,
WorkflowItemActivitySummary,
WorkflowItemStatus,
WorkflowProjectFilter,
Expand All @@ -15,6 +16,7 @@ import type { PersistedWorkflowItemActivityArchive } from '@/shared/workflow/act
import {
GLOBAL_STATE_ROW_ID,
workflowEventKinds,
workflowItemPriorities,
workflowItemStatuses,
workflowProjectFilters,
workflowProjectViews,
Expand All @@ -41,11 +43,15 @@ export const workflowItems = sqliteTable(
brief: text('brief').notNull(),
createdAt: integer('created_at').notNull().$type<number>(),
id: text('id').primaryKey(),
priority: text('priority', { enum: workflowItemPriorities }).$type<ItemPriority>().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<number | null>(),
slaDeadlineMs: integer('sla_deadline_ms').$type<number | null>(),
slaWarnedAt: integer('sla_warned_at').$type<number | null>(),
sortOrder: integer('sort_order').notNull(),
status: text('status', { enum: workflowItemStatuses }).$type<WorkflowItemStatus>().notNull(),
title: text('title').notNull(),
Expand Down
Loading