From dc4f13305030d3e9e1bb1a23ef7be7ccf9e64bae Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 07:01:24 +0800 Subject: [PATCH 1/5] feat: comprehensive audit log with SQLite persistence and Audit Log view --- src/electron/main.ts | 10 ++ .../main/agent-actions/handlers/items.ts | 78 +++++++- .../main/agent-actions/handlers/tasks.ts | 86 ++++++++- .../main/agent-actions/handlers/types.ts | 2 + .../agent-actions/handlers/work-products.ts | 69 ++++++- .../main/agent-actions/register-actions.ts | 12 ++ src/electron/main/audit/audit-db.ts | 34 ++++ src/electron/main/audit/audit-log.ts | 146 +++++++++++++++ .../main/ipc/register-main-ipc-handlers.ts | 17 ++ src/electron/main/orm/schema/constants.ts | 2 +- .../main/runtime/runtime-bootstrap.ts | 3 + src/electron/preload.ts | 3 + src/renderer/app/store/app-commands.ts | 6 + src/renderer/app/store/workflow-slice.ts | 169 +++++++++++++++++- src/renderer/app/testing/setup.ts | 1 + .../app/workspaces/WorkflowWorkspace.tsx | 9 + .../features/audit-log/AuditLogFilters.tsx | 117 ++++++++++++ .../features/audit-log/AuditLogTable.tsx | 80 +++++++++ .../features/audit-log/AuditLogView.tsx | 88 +++++++++ .../features/audit-log/useAuditLog.ts | 65 +++++++ src/renderer/features/workflow/types.ts | 1 + src/shared/audit-log.ts | 52 ++++++ src/shared/electron/desktop-bridge.ts | 8 + src/shared/electron/ipc-channels.ts | 3 + .../src/electron/main/audit/audit-log.test.ts | 96 ++++++++++ .../ipc/register-main-ipc-handlers.test.ts | 5 +- .../features/audit-log/AuditLogView.test.tsx | 93 ++++++++++ 27 files changed, 1237 insertions(+), 18 deletions(-) create mode 100644 src/electron/main/audit/audit-db.ts create mode 100644 src/electron/main/audit/audit-log.ts create mode 100644 src/renderer/features/audit-log/AuditLogFilters.tsx create mode 100644 src/renderer/features/audit-log/AuditLogTable.tsx create mode 100644 src/renderer/features/audit-log/AuditLogView.tsx create mode 100644 src/renderer/features/audit-log/useAuditLog.ts create mode 100644 src/shared/audit-log.ts create mode 100644 tests/unit/src/electron/main/audit/audit-log.test.ts create mode 100644 tests/unit/src/renderer/features/audit-log/AuditLogView.test.tsx diff --git a/src/electron/main.ts b/src/electron/main.ts index 93eea0d..d66f33a 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -10,6 +10,11 @@ import path from 'node:path'; import started from 'electron-squirrel-startup'; import { createQuitCoordinator } from '@/electron/main/quit-coordinator'; +import { AuditLog } from '@/electron/main/audit/audit-log'; +import { + openDuneDatabase, + resolveDuneDatabasePath, +} from '@/electron/main/db'; import { resolveAgentLiteRuntimeRoot } from '@/electron/main/dune-paths'; import { registerMainIpcHandlers } from '@/electron/main/ipc/register-main-ipc-handlers'; import { createTelegramPowerCoordinator } from '@/electron/main/lifecycle/telegram-power-coordinator'; @@ -69,6 +74,8 @@ void app.whenReady().then(async () => { const agentLiteHomeDir = process.env.DUNE_AGENTLITE_HOME_DIR; const agentLiteRuntimeRoot = resolveAgentLiteRuntimeRoot(agentLiteHomeDir); const userDataDir = app.getPath('userData'); + const sqlite = openDuneDatabase(resolveDuneDatabasePath(userDataDir)); + const auditLog = new AuditLog(sqlite); const stores = { agents: new JsonFileStorage(userDataDir, 'agents'), secrets: new EncryptedFileStorage(userDataDir, 'secrets'), @@ -114,6 +121,7 @@ void app.whenReady().then(async () => { runtimeBootstrap = createRuntimeBootstrap({ agentStore: stores.agents, app, + auditLog, ...(agentLiteHomeDir ? { agentLiteHomeDir } : {}), onAgentIdle: workflowCoordinator.onAgentIdle, onItemActivityChanged: (payload) => { @@ -134,6 +142,7 @@ void app.whenReady().then(async () => { workflowCoordinator.stop(); telegramPowerCoordinator.shutdown(); await runtimeBootstrap?.shutdown(); + sqlite.close(); }; const applyPersistedNetworkSettings = async () => { @@ -155,6 +164,7 @@ void app.whenReady().then(async () => { registerMainIpcHandlers({ applyPersistedNetworkSettings, + auditLog, createInitialRuntimeSnapshot, deleteLocalData: async () => { await shutdownMainProcess(); diff --git a/src/electron/main/agent-actions/handlers/items.ts b/src/electron/main/agent-actions/handlers/items.ts index 9418bec..2ae10ea 100644 --- a/src/electron/main/agent-actions/handlers/items.ts +++ b/src/electron/main/agent-actions/handlers/items.ts @@ -40,6 +40,11 @@ import { } from './validators'; import { ToolHandlerError, type RegisteredTool } from './types'; +/** Returns the actor label for audit events emitted by agent actions. */ +function auditActor(agentContext: { agentId?: string; agentName?: string }) { + return agentContext.agentName || agentContext.agentId || 'user'; +} + /** Lists item tools. */ export const itemTools: RegisteredTool[] = [ { @@ -76,7 +81,7 @@ export const itemTools: RegisteredTool[] = [ ), name: 'workflow.items.create', }, - handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => { + handler: async ({ agentContext, auditLog, onWorkflowChanged, workflowStore }, args) => { const snapshot = await readWorkflowSnapshot(workflowStore); const projectId = resolveProjectId(args.projectId, agentContext.projectId); const title = requireString(args.title, 'title'); @@ -124,6 +129,16 @@ export const itemTools: RegisteredTool[] = [ touchProject(snapshot, projectId, now); await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'item.created', + itemId, + itemTitle: title, + projectId, + summary: `Created work item "${title}".`, + details: { status }, + }); return { item: presentItem(snapshot, item), itemId }; }, }, @@ -142,12 +157,14 @@ export const itemTools: RegisteredTool[] = [ ), name: 'workflow.items.update', }, - handler: async ({ agentContext, getRuntimeController, onWorkflowChanged, workflowStore }, args) => { + handler: async ({ agentContext, auditLog, getRuntimeController, onWorkflowChanged, workflowStore }, args) => { const snapshot = await readWorkflowSnapshot(workflowStore); const item = findItem(snapshot, requireString(args.itemId, 'itemId')); const note = optionalString(args.note); const title = optionalString(args.title); const now = Date.now(); + const previousTitle = item.title; + const previousBrief = item.brief; const touchesDetails = args.title !== undefined || args.brief !== undefined; const touchesAssignment = args.primaryAgentId !== undefined; @@ -209,6 +226,36 @@ export const itemTools: RegisteredTool[] = [ touchProject(snapshot, item.projectId, now); await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + if (touchesDetails) { + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'item.updated', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Updated work item "${item.title}".`, + details: { + briefChanged: previousBrief !== item.brief, + titleChanged: previousTitle !== item.title, + }, + }); + } + + if (touchesAssignment) { + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'agent.assigned', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: item.primaryAgentId + ? `Assigned primary agent to "${item.title}".` + : `Cleared primary agent for "${item.title}".`, + details: { primaryAgentId: item.primaryAgentId }, + }); + } return { item: presentItem(snapshot, item) }; }, }, @@ -226,7 +273,7 @@ export const itemTools: RegisteredTool[] = [ ), name: 'workflow.items.move', }, - handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => { + handler: async ({ agentContext, auditLog, onWorkflowChanged, workflowStore }, args) => { const snapshot = await readWorkflowSnapshot(workflowStore); const item = findItem(snapshot, requireString(args.itemId, 'itemId')); const note = optionalString(args.note); @@ -281,6 +328,19 @@ export const itemTools: RegisteredTool[] = [ touchProject(snapshot, item.projectId, now); await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'item.moved', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Moved "${item.title}" from ${previousStatus} to ${status}.`, + details: { + fromStatus: previousStatus, + toStatus: status, + }, + }); return { item: presentItem(snapshot, item) }; }, }, @@ -296,7 +356,7 @@ export const itemTools: RegisteredTool[] = [ ), name: 'workflow.items.add_feedback', }, - handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => { + handler: async ({ agentContext, auditLog, onWorkflowChanged, workflowStore }, args) => { const snapshot = await readWorkflowSnapshot(workflowStore); const item = findItem(snapshot, requireString(args.itemId, 'itemId')); const feedback = requireString(args.feedback, 'feedback'); @@ -307,6 +367,16 @@ export const itemTools: RegisteredTool[] = [ touchProject(snapshot, item.projectId, now); await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'feedback.added', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Added feedback to "${item.title}".`, + details: { feedback }, + }); return { item: presentItem(snapshot, item) }; }, }, diff --git a/src/electron/main/agent-actions/handlers/tasks.ts b/src/electron/main/agent-actions/handlers/tasks.ts index 673bd72..39ab238 100644 --- a/src/electron/main/agent-actions/handlers/tasks.ts +++ b/src/electron/main/agent-actions/handlers/tasks.ts @@ -15,6 +15,11 @@ import { objectSchema, optionalStringSchema, stringSchema } from './schemas'; import { assertAgentCanMutateTasks } from './validators'; import { ToolHandlerError, type RegisteredTool } from './types'; +/** Returns the actor label for audit events emitted by agent actions. */ +function auditActor(agentContext: { agentId?: string; agentName?: string }) { + return agentContext.agentName || agentContext.agentId || 'user'; +} + /** Lists task tools. */ export const taskTools: RegisteredTool[] = [ { @@ -30,7 +35,7 @@ export const taskTools: RegisteredTool[] = [ ), name: 'workflow.tasks.add', }, - handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => { + handler: async ({ agentContext, auditLog, onWorkflowChanged, workflowStore }, args) => { const snapshot = await readWorkflowSnapshot(workflowStore); const item = findItem(snapshot, requireString(args.itemId, 'itemId')); const note = optionalString(args.note); @@ -56,6 +61,16 @@ export const taskTools: RegisteredTool[] = [ touchProject(snapshot, item.projectId, now); await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'task.created', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Added task "${title}" to "${item.title}".`, + details: { taskId, taskTitle: title }, + }); return { taskId }; }, }, @@ -75,7 +90,7 @@ export const taskTools: RegisteredTool[] = [ ), name: 'workflow.tasks.update', }, - handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => { + handler: async ({ agentContext, auditLog, onWorkflowChanged, workflowStore }, args) => { const snapshot = await readWorkflowSnapshot(workflowStore); const item = findItem(snapshot, requireString(args.itemId, 'itemId')); const note = optionalString(args.note); @@ -114,7 +129,74 @@ export const taskTools: RegisteredTool[] = [ touchProject(snapshot, item.projectId, task.updatedAt); await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'task.updated', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Updated task "${task.title}" on "${item.title}".`, + details: { + status: task.status, + taskId: task.id, + taskTitle: task.title, + }, + }); return { task }; }, }, + { + definition: { + description: 'Delete a task from a Dune work item.', + inputSchema: objectSchema( + { + itemId: stringSchema, + note: optionalStringSchema, + taskId: stringSchema, + }, + ['itemId', 'taskId'], + ), + name: 'workflow.tasks.delete', + }, + handler: async ({ agentContext, auditLog, onWorkflowChanged, workflowStore }, args) => { + const snapshot = await readWorkflowSnapshot(workflowStore); + const item = findItem(snapshot, requireString(args.itemId, 'itemId')); + const note = optionalString(args.note); + const taskId = requireString(args.taskId, 'taskId'); + const task = item.tasks.find((candidate) => candidate.id === taskId) ?? null; + + assertAgentCanMutateTasks(agentContext.agentId, item); + + if (!task) { + throw new ToolHandlerError('not-found', `Task ${taskId} not found.`); + } + + const now = Date.now(); + + item.tasks = item.tasks.filter((candidate) => candidate.id !== taskId); + item.updatedAt = now; + prependWorkflowEvents(item, [ + ...(note ? [createWorkflowEvent('note', note, now, agentContext.agentName)] : []), + createWorkflowEvent('task', `Task "${task.title}" was deleted.`, now, agentContext.agentName), + ]); + touchProject(snapshot, item.projectId, now); + + await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'task.deleted', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Deleted task "${task.title}" from "${item.title}".`, + details: { + taskId, + taskTitle: task.title, + }, + }); + return { taskId }; + }, + }, ]; diff --git a/src/electron/main/agent-actions/handlers/types.ts b/src/electron/main/agent-actions/handlers/types.ts index c30746f..9f4b992 100644 --- a/src/electron/main/agent-actions/handlers/types.ts +++ b/src/electron/main/agent-actions/handlers/types.ts @@ -2,6 +2,7 @@ import type { AppStorage } from '@/electron/main/storage/app-storage'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; /** MCP-style tool definition. Used internally by RegisteredTool. */ export interface ToolDefinition { @@ -21,6 +22,7 @@ export interface ToolHandlerContext { /** Tool handler options. */ export interface ToolHandlerOptions { + auditLog?: AuditLog; getRuntimeController: () => DesktopRuntimeController; onWorkflowChanged: () => void; workflowStore: AppStorage; diff --git a/src/electron/main/agent-actions/handlers/work-products.ts b/src/electron/main/agent-actions/handlers/work-products.ts index a0f8c78..6d76657 100644 --- a/src/electron/main/agent-actions/handlers/work-products.ts +++ b/src/electron/main/agent-actions/handlers/work-products.ts @@ -13,7 +13,12 @@ import { } from './snapshot'; import { objectSchema, optionalStringSchema, stringSchema } from './schemas'; import { assertAgentCanAddWorkProduct } from './validators'; -import type { RegisteredTool } from './types'; +import { ToolHandlerError, type RegisteredTool } from './types'; + +/** Returns the actor label for audit events emitted by agent actions. */ +function auditActor(agentContext: { agentId?: string; agentName?: string }) { + return agentContext.agentName || agentContext.agentId || 'user'; +} /** Lists work product tools. */ export const workProductTools: RegisteredTool[] = [ @@ -31,7 +36,7 @@ export const workProductTools: RegisteredTool[] = [ ), name: 'workflow.work_products.add', }, - handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => { + handler: async ({ agentContext, auditLog, onWorkflowChanged, workflowStore }, args) => { const snapshot = await readWorkflowSnapshot(workflowStore); const item = findItem(snapshot, requireString(args.itemId, 'itemId')); const title = requireString(args.title, 'title'); @@ -56,6 +61,66 @@ export const workProductTools: RegisteredTool[] = [ touchProject(snapshot, item.projectId, now); await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'work_product.added', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Added work product "${title}" to "${item.title}".`, + details: { workProductId, workProductTitle: title }, + }); + return { workProductId }; + }, + }, + { + definition: { + description: 'Delete a work product from a Dune work item.', + inputSchema: objectSchema( + { + itemId: stringSchema, + note: optionalStringSchema, + workProductId: stringSchema, + }, + ['itemId', 'workProductId'], + ), + name: 'workflow.work_products.delete', + }, + handler: async ({ agentContext, auditLog, onWorkflowChanged, workflowStore }, args) => { + const snapshot = await readWorkflowSnapshot(workflowStore); + const item = findItem(snapshot, requireString(args.itemId, 'itemId')); + const workProductId = requireString(args.workProductId, 'workProductId'); + const product = item.workProducts.find((candidate) => candidate.id === workProductId) ?? null; + const note = optionalString(args.note); + + assertAgentCanAddWorkProduct(agentContext.agentId, item); + + if (!product) { + throw new ToolHandlerError('not-found', `Work product ${workProductId} not found.`); + } + + const now = Date.now(); + + item.workProducts = item.workProducts.filter((candidate) => candidate.id !== workProductId); + item.updatedAt = now; + prependWorkflowEvents(item, [ + ...(note ? [createWorkflowEvent('note', note, now, agentContext.agentName)] : []), + createWorkflowEvent('note', `Deleted output "${product.title}".`, now, agentContext.agentName), + ]); + touchProject(snapshot, item.projectId, now); + + await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + auditLog?.record({ + actor: auditActor(agentContext), + actorType: 'agent', + eventType: 'work_product.deleted', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Deleted work product "${product.title}" from "${item.title}".`, + details: { workProductId, workProductTitle: product.title }, + }); return { workProductId }; }, }, diff --git a/src/electron/main/agent-actions/register-actions.ts b/src/electron/main/agent-actions/register-actions.ts index c87bb7e..5c39af9 100644 --- a/src/electron/main/agent-actions/register-actions.ts +++ b/src/electron/main/agent-actions/register-actions.ts @@ -16,6 +16,7 @@ import type { Agent as AgentLiteAgent } from '@boxlite-ai/agentlite'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage/app-storage'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; import { agentTools } from '@/electron/main/agent-actions/handlers/agents'; import { itemTools } from '@/electron/main/agent-actions/handlers/items'; @@ -31,6 +32,7 @@ import type { /** Services captured by every action handler closure. */ export interface ActionHostServices { + auditLog?: AuditLog; getRuntimeController: () => DesktopRuntimeController; onWorkflowChanged: () => void; workflowStore: AppStorage; @@ -172,6 +174,11 @@ export function registerDuneActions( notes: z.string().optional().describe('Task notes'), status: z.enum(['todo', 'doing', 'blocked', 'review', 'done']).optional().describe('Task status'), }); + reg('workflow.tasks.delete', { + itemId: z.string().describe('Work item ID'), + note: z.string().optional().describe('Optional note to add to workflow history with this task deletion.'), + taskId: z.string().describe('Task ID'), + }); // ---- Work products ------------------------------------------------------ reg('workflow.work_products.add', { @@ -180,6 +187,11 @@ export function registerDuneActions( title: z.string().describe('Work product title'), body: z.string().describe('Work product content'), }); + reg('workflow.work_products.delete', { + itemId: z.string().describe('Work item ID'), + note: z.string().optional().describe('Optional note to add to workflow history with this deletion.'), + workProductId: z.string().describe('Work product ID'), + }); // ---- Agents ------------------------------------------------------------- reg('agents.list', { diff --git a/src/electron/main/audit/audit-db.ts b/src/electron/main/audit/audit-db.ts new file mode 100644 index 0000000..cc42482 --- /dev/null +++ b/src/electron/main/audit/audit-db.ts @@ -0,0 +1,34 @@ +import Database from 'better-sqlite3'; +import path from 'node:path'; + +import { AuditLog } from './audit-log'; + +export { + ensureAuditEventsSchema, + rowsToCsv, +} from './audit-log'; +export type { + AuditActorType, + AuditEvent as RecordEventParams, + AuditEventRow, + AuditEventType, + QueryAuditParams, +} from '@/shared/audit-log'; + +export class AuditDatabase extends AuditLog { + private readonly sqlite: InstanceType; + + constructor(userDataDirOrMemory: string) { + const sqlite = userDataDirOrMemory === ':memory:' + ? new Database(':memory:') + : new Database(path.join(userDataDirOrMemory, 'audit.db')); + + sqlite.pragma('journal_mode = WAL'); + super(sqlite); + this.sqlite = sqlite; + } + + close(): void { + this.sqlite.close(); + } +} diff --git a/src/electron/main/audit/audit-log.ts b/src/electron/main/audit/audit-log.ts new file mode 100644 index 0000000..4e22400 --- /dev/null +++ b/src/electron/main/audit/audit-log.ts @@ -0,0 +1,146 @@ +import Database from 'better-sqlite3'; + +import type { + AuditEvent, + AuditEventRow, + QueryAuditParams, +} from '@/shared/audit-log'; + +export type { + AuditEvent, + AuditEventRow, + QueryAuditParams, +} from '@/shared/audit-log'; + +/** Ensures the audit_events SQLite table exists in the app database. */ +export function ensureAuditEventsSchema(db: InstanceType): void { + db.exec(` + CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + actor TEXT NOT NULL, + actor_type TEXT NOT NULL, + event_type TEXT NOT NULL, + item_id TEXT, + item_title TEXT, + project_id TEXT NOT NULL, + summary TEXT NOT NULL, + details TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_audit_events_ts ON audit_events(ts); + CREATE INDEX IF NOT EXISTS idx_audit_events_event_type ON audit_events(event_type); + CREATE INDEX IF NOT EXISTS idx_audit_events_actor ON audit_events(actor); + CREATE INDEX IF NOT EXISTS idx_audit_events_item_id ON audit_events(item_id); + CREATE INDEX IF NOT EXISTS idx_audit_events_project_id ON audit_events(project_id); + `); +} + +/** Persistent audit log backed by SQLite. */ +export class AuditLog { + constructor(private db: InstanceType) { + ensureAuditEventsSchema(db); + } + + record(event: AuditEvent): void { + this.db.prepare(` + INSERT INTO audit_events + (ts, actor, actor_type, event_type, item_id, item_title, project_id, summary, details) + VALUES + (@ts, @actor, @actorType, @eventType, @itemId, @itemTitle, @projectId, @summary, @details) + `).run({ + ts: event.ts ?? Date.now(), + actor: event.actor, + actorType: event.actorType, + eventType: event.eventType, + itemId: event.itemId ?? null, + itemTitle: event.itemTitle ?? null, + projectId: event.projectId, + summary: event.summary, + details: event.details ? JSON.stringify(event.details) : null, + }); + } + + query(params: QueryAuditParams): { rows: AuditEventRow[]; total: number } { + const conditions: string[] = ['project_id = @projectId']; + const bindings: Record = { projectId: params.projectId }; + + if (params.since) { + conditions.push('ts >= @since'); + bindings.since = params.since; + } + + if (params.until) { + conditions.push('ts <= @until'); + bindings.until = params.until; + } + + if (params.eventType) { + conditions.push('event_type = @eventType'); + bindings.eventType = params.eventType; + } + + if (params.actor) { + conditions.push('actor = @actor'); + bindings.actor = params.actor; + } + + if (params.itemId) { + conditions.push('item_id = @itemId'); + bindings.itemId = params.itemId; + } + + const where = conditions.join(' AND '); + const limit = Math.min(params.limit ?? 100, 500); + const offset = params.offset ?? 0; + + const total = (this.db.prepare( + `SELECT COUNT(*) as c FROM audit_events WHERE ${where}`, + ).get(bindings) as { c: number }).c; + const rows = this.db.prepare(` + SELECT * FROM audit_events + WHERE ${where} + ORDER BY ts DESC + LIMIT @limit OFFSET @offset + `).all({ ...bindings, limit, offset }) as AuditEventRow[]; + + return { rows, total }; + } + + exportCsv(params: QueryAuditParams): string { + const { rows } = this.query({ ...params, limit: 10_000, offset: 0 }); + return rowsToCsv(rows); + } +} + +export function rowsToCsv(rows: AuditEventRow[]): string { + const headers = [ + 'id', + 'timestamp', + 'actor', + 'actor_type', + 'event_type', + 'item_id', + 'item_title', + 'project_id', + 'summary', + 'details', + ]; + const escape = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`; + + return [ + headers.join(','), + ...rows.map((row) => [ + row.id, + new Date(row.ts).toISOString(), + escape(row.actor), + escape(row.actor_type), + escape(row.event_type), + escape(row.item_id), + escape(row.item_title), + escape(row.project_id), + escape(row.summary), + escape(row.details), + ].join(',')), + ].join('\n'); +} diff --git a/src/electron/main/ipc/register-main-ipc-handlers.ts b/src/electron/main/ipc/register-main-ipc-handlers.ts index f255596..b5eb7b5 100644 --- a/src/electron/main/ipc/register-main-ipc-handlers.ts +++ b/src/electron/main/ipc/register-main-ipc-handlers.ts @@ -11,6 +11,8 @@ import type { OpenDialogOptions } from 'electron'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage'; +import type { AuditEvent, QueryAuditParams } from '@/shared/audit-log'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; import { assertEmptyProjectRootDirectory, ensureProjectArtifactFolder, @@ -49,6 +51,7 @@ interface RegisterMainIpcHandlersOptions { deleteLocalData: () => Promise; dialog?: DialogLike; ensureRuntime: () => Promise; + auditLog?: AuditLog; getFocusedWindow?: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null; getProjectActivityPage: ( @@ -95,6 +98,20 @@ export function registerMainIpcHandlers(options: RegisterMainIpcHandlersOptions) async (_event, projectId: string, pageOptions?: { beforeEntryId?: string | null; limit?: number }) => options.getProjectActivityPage(projectId, pageOptions), ); + ipcMain.handle( + ipcChannels.getAuditLog, + async (_event, params: QueryAuditParams) => options.auditLog?.query(params) ?? { rows: [], total: 0 }, + ); + ipcMain.handle( + ipcChannels.exportAuditCsv, + async (_event, params: QueryAuditParams) => options.auditLog?.exportCsv(params) ?? '', + ); + ipcMain.handle( + ipcChannels.recordAuditEvent, + async (_event, event: AuditEvent) => { + options.auditLog?.record(event); + }, + ); ipcMain.handle( ipcChannels.applyNetworkSettings, async () => { diff --git a/src/electron/main/orm/schema/constants.ts b/src/electron/main/orm/schema/constants.ts index 4f80ca8..33af339 100644 --- a/src/electron/main/orm/schema/constants.ts +++ b/src/electron/main/orm/schema/constants.ts @@ -10,7 +10,7 @@ export const networkProxyModes = ['direct', 'manual', 'system'] as const; export const workflowEventKinds = ['assignment', 'feedback', 'item', 'note', 'task'] 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; +export const workflowProjectViews = ['board', 'agents', 'activity', 'audit-log'] as const; export const workflowTaskStatuses = ['todo', 'doing', 'blocked', 'review', 'done'] as const; /** Singleton row id used for app-level UI state tables. */ diff --git a/src/electron/main/runtime/runtime-bootstrap.ts b/src/electron/main/runtime/runtime-bootstrap.ts index 457fff8..b458467 100644 --- a/src/electron/main/runtime/runtime-bootstrap.ts +++ b/src/electron/main/runtime/runtime-bootstrap.ts @@ -5,12 +5,14 @@ import type { App } from 'electron'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; import type { AgentServiceSnapshot } from '@/shared/agents/agent-runtime'; interface RuntimeBootstrapOptions { agentLiteHomeDir?: string; agentStore: AppStorage; app: Pick; + auditLog?: AuditLog; onAgentIdle: (agentId: string) => void; onItemActivityChanged: (payload: { isWorking: boolean; itemId: string }) => void; onRuntimeSnapshot: (snapshot: AgentServiceSnapshot) => void; @@ -59,6 +61,7 @@ export function createRuntimeBootstrap(options: RuntimeBootstrapOptions) { runtimeController = new DesktopRuntimeController({ actionServices: { + ...(options.auditLog ? { auditLog: options.auditLog } : {}), getRuntimeController: requireRuntimeController, onWorkflowChanged: options.onWorkflowChanged, workflowStore: options.workflowStore, diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 83312ed..4d68eb3 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -26,8 +26,10 @@ const bridge: DesktopBridge = { projectName, projectRootPath, ), + exportAuditCsv: (params) => ipcRenderer.invoke(ipcChannels.exportAuditCsv, params), getProjectActivityPage: (projectId, options) => ipcRenderer.invoke(ipcChannels.getProjectActivityPage, projectId, options), + getAuditLog: (params) => ipcRenderer.invoke(ipcChannels.getAuditLog, params), getAgentTranscriptPage: (agentId, options) => ipcRenderer.invoke(ipcChannels.getAgentTranscriptPage, agentId, options), getRuntimeSnapshot: () => ipcRenderer.invoke(ipcChannels.getRuntimeSnapshot), @@ -39,6 +41,7 @@ const bridge: DesktopBridge = { openPath: (targetPath) => ipcRenderer.invoke(ipcChannels.openPath, targetPath), prepareProjectRootPath: (rootPath, artifactFolderNames) => ipcRenderer.invoke(ipcChannels.prepareProjectRootPath, rootPath, artifactFolderNames), + recordAuditEvent: (event) => ipcRenderer.invoke(ipcChannels.recordAuditEvent, event), reloadExternalChannels: () => ipcRenderer.invoke(ipcChannels.reloadExternalChannels), resetRuntime: () => ipcRenderer.invoke(ipcChannels.resetRuntime), restartApp: () => ipcRenderer.invoke(ipcChannels.restartApp), diff --git a/src/renderer/app/store/app-commands.ts b/src/renderer/app/store/app-commands.ts index 94abd48..06d638c 100644 --- a/src/renderer/app/store/app-commands.ts +++ b/src/renderer/app/store/app-commands.ts @@ -262,6 +262,11 @@ export function openProjectActivity(projectId?: string | null) { openProjectView('activity', projectId); } +/** Opens project audit log. */ +export function openAuditLog(projectId?: string | null) { + openProjectView('audit-log', projectId); +} + /** Opens project settings. */ export function openProjectSettings() { withNavigationChange(() => { @@ -365,6 +370,7 @@ export function useAppCommands() { cycleAgent, goBack, goForward, + openAuditLog, openAgent, openAgents, openItem, diff --git a/src/renderer/app/store/workflow-slice.ts b/src/renderer/app/store/workflow-slice.ts index b9d2e63..0874804 100644 --- a/src/renderer/app/store/workflow-slice.ts +++ b/src/renderer/app/store/workflow-slice.ts @@ -27,6 +27,7 @@ import { normalizeProjectRootPath, } from '@/shared/workflow/project-artifacts'; import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; +import type { AuditEvent } from '@/shared/audit-log'; const defaultProjectColors = ['#A86D46', '#7A8B5D', '#4F7A78', '#9D6A71', '#6C69A6'] as const; @@ -51,6 +52,19 @@ function normalizeNote(note: string | null | undefined) { return normalized ? normalized : null; } +/** Records a user-originated audit event through the Electron bridge. */ +function recordUserAuditEvent(event: Omit) { + if (typeof window === 'undefined') { + return; + } + + void window.duneDesktop?.recordAuditEvent?.({ + ...event, + actor: 'user', + actorType: 'user', + }); +} + /** Sorts items by project status. */ function sortItemsByProjectStatus(items: WorkflowItem[]) { const nextItems = items.map((item) => ({ @@ -247,13 +261,14 @@ export function createInitialWorkflowState(): WorkflowState { export function createWorkflowSlice( initialState: WorkflowState, ): AppStoreSlice { - return (set) => { + return (set, get) => { const actions: WorkflowActions = { addTask: (itemId, title, note) => { const trimmedTitle = title.trim(); const normalizedNote = normalizeNote(note); + const targetItem = get().items.find((item) => item.id === itemId) ?? null; - if (!trimmedTitle) { + if (!trimmedTitle || !targetItem) { return null; } @@ -307,14 +322,24 @@ export function createWorkflowSlice( }; }); + recordUserAuditEvent({ + eventType: 'task.created', + itemId, + itemTitle: targetItem.title, + projectId: targetItem.projectId, + summary: `Added task "${trimmedTitle}" to "${targetItem.title}".`, + ts: updatedAt, + details: { taskId, taskTitle: trimmedTitle }, + }); return taskId; }, addWorkProduct: (itemId, input) => { const title = input.title.trim(); const body = input.body.trim(); const normalizedNote = normalizeNote(input.note); + const targetItem = get().items.find((item) => item.id === itemId) ?? null; - if (!title || !body) { + if (!title || !body || !targetItem) { return null; } @@ -364,12 +389,26 @@ export function createWorkflowSlice( }; }); + recordUserAuditEvent({ + eventType: 'work_product.added', + itemId, + itemTitle: targetItem.title, + projectId: targetItem.projectId, + summary: `Added work product "${title}" to "${targetItem.title}".`, + ts: updatedAt, + details: { workProductId: productId, workProductTitle: title }, + }); return productId; }, assignPrimaryAgent: (itemId, input) => { const updatedAt = Date.now(); const clearedDescription = 'Primary agent cleared.'; const normalizedNote = normalizeNote(input.note); + const targetItem = get().items.find((item) => item.id === itemId) ?? null; + + if (!targetItem) { + return; + } set((state) => { const targetItem = state.items.find((item) => item.id === itemId); @@ -423,10 +462,26 @@ export function createWorkflowSlice( }), }; }); + + recordUserAuditEvent({ + eventType: 'agent.assigned', + itemId, + itemTitle: targetItem.title, + projectId: targetItem.projectId, + summary: input.agentId + ? `Assigned primary agent to "${targetItem.title}".` + : `Cleared primary agent for "${targetItem.title}".`, + ts: updatedAt, + details: { + agentId: input.agentId, + agentName: input.agentName ?? null, + }, + }); }, clearAgentAssignments: (agentId) => { const updatedAt = Date.now(); const clearedDescription = 'Primary agent cleared.'; + const clearedItems = get().items.filter((item) => item.primaryAgentId === agentId); set((state) => { const touchedProjectIds = new Set(); @@ -466,6 +521,18 @@ export function createWorkflowSlice( }), }; }); + + for (const item of clearedItems) { + recordUserAuditEvent({ + eventType: 'agent.assigned', + itemId: item.id, + itemTitle: item.title, + projectId: item.projectId, + summary: `Cleared primary agent for "${item.title}".`, + ts: updatedAt, + details: { agentId }, + }); + } }, createItem: (input) => { const title = input.title.trim(); @@ -521,6 +588,15 @@ export function createWorkflowSlice( }; }); + recordUserAuditEvent({ + eventType: 'item.created', + itemId, + itemTitle: title, + projectId: input.projectId, + summary: `Created work item "${title}".`, + ts: updatedAt, + details: { status: input.status }, + }); return itemId; }, createProject: (input) => { @@ -560,6 +636,8 @@ export function createWorkflowSlice( return projectId; }, deleteProject: (projectId) => { + const deletedItems = get().items.filter((item) => item.projectId === projectId); + set((state) => { if (!state.projects.some((project) => project.id === projectId)) { return state; @@ -604,6 +682,17 @@ export function createWorkflowSlice( selectedProjectScreen: 'main', }; }); + + for (const item of deletedItems) { + recordUserAuditEvent({ + eventType: 'item.deleted', + itemId: item.id, + itemTitle: item.title, + projectId, + summary: `Deleted work item "${item.title}".`, + details: { viaProjectDelete: true }, + }); + } }, hydrateWorkflow: (snapshot) => { const nextSnapshot = ensureSelection(getWorkflowSnapshotState(snapshot)); @@ -620,6 +709,14 @@ export function createWorkflowSlice( }); }, moveItem: (itemId, status, index, note) => { + const previousItem = get().items.find((currentItem) => currentItem.id === itemId) ?? null; + + if (!previousItem || (previousItem.status === 'review' && status === 'done')) { + return; + } + + const updatedAt = Date.now(); + set((state) => { const item = state.items.find((currentItem) => currentItem.id === itemId); @@ -632,7 +729,6 @@ export function createWorkflowSlice( } const normalizedNote = normalizeNote(note); - const updatedAt = Date.now(); const destination = getProjectItems( state.items.filter((currentItem) => currentItem.id !== itemId && currentItem.status === status), item.projectId, @@ -681,6 +777,19 @@ export function createWorkflowSlice( }), }; }); + + recordUserAuditEvent({ + eventType: 'item.moved', + itemId, + itemTitle: previousItem.title, + projectId: previousItem.projectId, + summary: `Moved "${previousItem.title}" from ${previousItem.status} to ${status}.`, + ts: updatedAt, + details: { + fromStatus: previousItem.status, + toStatus: status, + }, + }); }, openProjectSettings: () => { set({ selectedProjectScreen: 'settings' }); @@ -775,6 +884,17 @@ export function createWorkflowSlice( }); }, updateItem: (itemId, input) => { + const targetItem = get().items.find((item) => item.id === itemId) ?? null; + const title = input.title?.trim(); + const brief = input.brief?.trim(); + const hasUpdate = title !== undefined || brief !== undefined; + + if (!targetItem || !hasUpdate) { + return; + } + + const updatedAt = Date.now(); + set((state) => { const targetItem = state.items.find((item) => item.id === itemId); @@ -782,7 +902,6 @@ export function createWorkflowSlice( return state; } - const updatedAt = Date.now(); const normalizedNote = normalizeNote(input.note); return { @@ -792,8 +911,6 @@ export function createWorkflowSlice( return item; } - const title = input.title?.trim(); - const brief = input.brief?.trim(); const nextItem = { ...item, ...(title ? { title } : {}), @@ -822,8 +939,30 @@ export function createWorkflowSlice( }), }; }); + + recordUserAuditEvent({ + eventType: 'item.updated', + itemId, + itemTitle: title || targetItem.title, + projectId: targetItem.projectId, + summary: `Updated work item "${title || targetItem.title}".`, + ts: updatedAt, + details: { + briefChanged: brief !== undefined && brief !== targetItem.brief, + titleChanged: !!title && title !== targetItem.title, + }, + }); }, updateTask: (itemId, taskId, input) => { + const targetItem = get().items.find((item) => item.id === itemId) ?? null; + const targetTask = targetItem?.tasks.find((task) => task.id === taskId) ?? null; + + if (!targetItem || !targetTask) { + return; + } + + const updatedAt = Date.now(); + set((state) => { const targetItem = state.items.find((item) => item.id === itemId); @@ -831,7 +970,6 @@ export function createWorkflowSlice( return state; } - const updatedAt = Date.now(); const normalizedNote = normalizeNote(input.note); return { @@ -877,6 +1015,21 @@ export function createWorkflowSlice( }), }; }); + + const taskTitle = input.title?.trim() || targetTask.title; + recordUserAuditEvent({ + eventType: 'task.updated', + itemId, + itemTitle: targetItem.title, + projectId: targetItem.projectId, + summary: `Updated task "${taskTitle}" on "${targetItem.title}".`, + ts: updatedAt, + details: { + status: input.status ?? targetTask.status, + taskId, + taskTitle, + }, + }); }, setItemActivity: (itemId, activity) => { set((state) => ({ diff --git a/src/renderer/app/testing/setup.ts b/src/renderer/app/testing/setup.ts index 2782807..463d216 100644 --- a/src/renderer/app/testing/setup.ts +++ b/src/renderer/app/testing/setup.ts @@ -87,6 +87,7 @@ beforeEach(() => { openPath: vi.fn(() => Promise.resolve(undefined)), platform: 'darwin', prepareProjectRootPath: vi.fn((rootPath: string) => Promise.resolve(rootPath)), + recordAuditEvent: vi.fn(() => Promise.resolve(undefined)), reloadExternalChannels: vi.fn(() => Promise.resolve(undefined)), restartApp: vi.fn(() => Promise.resolve(undefined)), selectProjectDirectory: vi.fn(() => Promise.resolve('/tmp/project-root')), diff --git a/src/renderer/app/workspaces/WorkflowWorkspace.tsx b/src/renderer/app/workspaces/WorkflowWorkspace.tsx index 16a9b75..24e31ea 100644 --- a/src/renderer/app/workspaces/WorkflowWorkspace.tsx +++ b/src/renderer/app/workspaces/WorkflowWorkspace.tsx @@ -11,6 +11,7 @@ import { CompactShellToolbar } from '@/renderer/app/shell/CompactShellToolbar'; import { useAppCommands } from '@/renderer/app/store/app-commands'; import { useWorkflowSession } from '@/renderer/app/store/selectors'; import { useAppStore } from '@/renderer/app/store/use-app-store'; +import { AuditLogView } from '@/renderer/features/audit-log/AuditLogView'; import { CreateProjectDialog } from '@/renderer/features/workflow/components/CreateProjectDialog'; import { CreateWorkItemDialog } from '@/renderer/features/workflow/components/CreateWorkItemDialog'; import { WorkflowBoard } from '@/renderer/features/workflow/components/WorkflowBoard'; @@ -36,6 +37,7 @@ const projectHeaderTabs = [ { label: 'Activity', value: 'activity' }, { label: 'Board', value: 'board' }, { label: 'Agents', value: 'agents' }, + { label: 'Audit Log', value: 'audit-log' }, ] as const; /** Workflow workspace props. */ @@ -426,6 +428,11 @@ export function WorkflowWorkspace({ return; } + if (tab.value === 'audit-log') { + commands.openAuditLog(); + return; + } + commands.openWorkflow(); }} role="tab" @@ -486,6 +493,8 @@ export function WorkflowWorkspace({ }} runtimeInfo={runtimeInfo} /> + ) : selectedProjectView === 'audit-log' ? ( + ) : ( ; + +interface AuditLogFiltersProps { + filters: AuditFilters; + onChange: (filters: AuditFilters) => void; +} + +function dateInputValue(timestamp: number | undefined) { + return timestamp ? new Date(timestamp).toISOString().slice(0, 10) : ''; +} + +function parseDate(value: string, endOfDay = false) { + if (!value) { + return undefined; + } + + const date = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}`); + return Number.isNaN(date.getTime()) ? undefined : date.getTime(); +} + +/** Renders audit log filters. */ +export function AuditLogFilters({ + filters, + onChange, +}: AuditLogFiltersProps) { + const update = (next: AuditFilters) => { + onChange(Object.fromEntries( + Object.entries(next).filter(([, value]) => value !== ''), + ) as AuditFilters); + }; + const updateValue = ( + key: Key, + value: AuditFilters[Key] | undefined, + ) => { + const next = { ...filters }; + + if (value === undefined || value === '') { + delete next[key]; + } else { + next[key] = value; + } + + update(next); + }; + + return ( +
+ + + + + + + + + + +
+ +
+
+ ); +} diff --git a/src/renderer/features/audit-log/AuditLogTable.tsx b/src/renderer/features/audit-log/AuditLogTable.tsx new file mode 100644 index 0000000..5a6b159 --- /dev/null +++ b/src/renderer/features/audit-log/AuditLogTable.tsx @@ -0,0 +1,80 @@ +import type { AuditEventRow } from '@/shared/audit-log'; + +interface AuditLogTableProps { + loading: boolean; + rows: AuditEventRow[]; +} + +function formatTimestamp(timestamp: number) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(timestamp)); +} + +/** Renders the audit event table. */ +export function AuditLogTable({ + loading, + rows, +}: AuditLogTableProps) { + return ( +
+
+ + + + + + + + + + + + {loading ? ( + + + + ) : rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + + + + + + + + )) + )} + +
TimestampActorEvent TypeItemSummary
+ Loading audit log +
+ No audit events match these filters. +
+ {formatTimestamp(row.ts)} + +
{row.actor}
+
{row.actor_type}
+
+ + {row.event_type} + + + {row.item_title ? ( +
{row.item_title}
+ ) : ( + - + )} + {row.item_id ? ( +
{row.item_id}
+ ) : null} +
{row.summary}
+
+
+ ); +} diff --git a/src/renderer/features/audit-log/AuditLogView.tsx b/src/renderer/features/audit-log/AuditLogView.tsx new file mode 100644 index 0000000..319818d --- /dev/null +++ b/src/renderer/features/audit-log/AuditLogView.tsx @@ -0,0 +1,88 @@ +import { Download } from 'lucide-react'; + +import { AuditLogFilters } from '@/renderer/features/audit-log/AuditLogFilters'; +import { AuditLogTable } from '@/renderer/features/audit-log/AuditLogTable'; +import { useAuditLog } from '@/renderer/features/audit-log/useAuditLog'; +import { Button } from '@/renderer/shared/ui/button'; + +interface AuditLogViewProps { + projectId: string; +} + +/** Renders the project audit log view. */ +export function AuditLogView({ projectId }: AuditLogViewProps) { + const { + PAGE_SIZE, + exportCsv, + filters, + loading, + page, + rows, + setFilters, + setPage, + total, + } = useAuditLog(projectId); + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const firstRow = total === 0 ? 0 : page * PAGE_SIZE + 1; + const lastRow = Math.min(total, (page + 1) * PAGE_SIZE); + + return ( +
+
+
+
Audit
+

Audit Log

+
+ +
+ + { + setPage(0); + setFilters(nextFilters); + }} + /> + + + +
+
+ Showing {firstRow}-{lastRow} of {total} +
+
+ + + Page {page + 1} of {pageCount} + + +
+
+
+ ); +} diff --git a/src/renderer/features/audit-log/useAuditLog.ts b/src/renderer/features/audit-log/useAuditLog.ts new file mode 100644 index 0000000..5a942e0 --- /dev/null +++ b/src/renderer/features/audit-log/useAuditLog.ts @@ -0,0 +1,65 @@ +import { + useCallback, + useEffect, + useState, +} from 'react'; + +import type { + AuditEventRow, + QueryAuditParams, +} from '@/shared/audit-log'; + +export function useAuditLog(projectId: string) { + const PAGE_SIZE = 50; + const [filters, setFilters] = useState>({}); + const [page, setPage] = useState(0); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + window.duneDesktop?.getAuditLog?.({ + projectId, + ...filters, + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }) + .then((result: { rows: AuditEventRow[]; total: number } | undefined) => { + setRows(result?.rows ?? []); + setTotal(result?.total ?? 0); + }) + .catch(() => { + setRows([]); + setTotal(0); + }) + .finally(() => setLoading(false)); + }, [projectId, filters, page]); + + const exportCsv = useCallback(async () => { + const csv = await window.duneDesktop?.exportAuditCsv?.({ projectId, ...filters }); + if (!csv) { + return; + } + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-log-${projectId}.csv`; + a.click(); + URL.revokeObjectURL(url); + }, [projectId, filters]); + + return { + PAGE_SIZE, + exportCsv, + filters, + loading, + page, + rows, + setFilters, + setPage, + total, + }; +} diff --git a/src/renderer/features/workflow/types.ts b/src/renderer/features/workflow/types.ts index 76018cb..a80e446 100644 --- a/src/renderer/features/workflow/types.ts +++ b/src/renderer/features/workflow/types.ts @@ -24,6 +24,7 @@ export const workflowProjectViews = [ 'board', 'agents', 'activity', + 'audit-log', ] as const; /** Workflow project filters constant. */ diff --git a/src/shared/audit-log.ts b/src/shared/audit-log.ts new file mode 100644 index 0000000..2283f9a --- /dev/null +++ b/src/shared/audit-log.ts @@ -0,0 +1,52 @@ +export const auditEventTypes = [ + 'item.created', + 'item.moved', + 'item.deleted', + 'item.updated', + 'agent.assigned', + 'feedback.added', + 'work_product.added', + 'work_product.deleted', + 'task.created', + 'task.updated', + 'task.deleted', +] as const; + +export type AuditEventType = (typeof auditEventTypes)[number]; +export type AuditActorType = 'agent' | 'user' | 'system'; + +export interface AuditEvent { + ts?: number; + actor: string; + actorType: AuditActorType; + eventType: AuditEventType; + itemId?: string | null; + itemTitle?: string | null; + projectId: string; + summary: string; + details?: Record | null; +} + +export interface AuditEventRow { + id: number; + ts: number; + actor: string; + actor_type: AuditActorType; + event_type: AuditEventType; + item_id: string | null; + item_title: string | null; + project_id: string; + summary: string; + details: string | null; +} + +export interface QueryAuditParams { + projectId: string; + since?: number; + until?: number; + eventType?: AuditEventType | string; + actor?: string; + itemId?: string; + limit?: number; + offset?: number; +} diff --git a/src/shared/electron/desktop-bridge.ts b/src/shared/electron/desktop-bridge.ts index fa699ab..36fd4e4 100644 --- a/src/shared/electron/desktop-bridge.ts +++ b/src/shared/electron/desktop-bridge.ts @@ -1,6 +1,11 @@ // Shared Electron desktop bridge contract. import type { AgentServiceSnapshot } from '@/shared/agents/agent-runtime'; +import type { + AuditEvent, + AuditEventRow, + QueryAuditParams, +} from '@/shared/audit-log'; import type { AgentDefinition, AgentTranscriptPage, @@ -29,10 +34,12 @@ export interface DesktopBridge { projectName: string, projectRootPath?: string | null, ) => Promise; + exportAuditCsv?: (params: QueryAuditParams) => Promise; getProjectActivityPage?: ( projectId: string, options?: { beforeEntryId?: string | null; limit?: number }, ) => Promise; + getAuditLog?: (params: QueryAuditParams) => Promise<{ rows: AuditEventRow[]; total: number }>; getAgentTranscriptPage?: ( agentId: string, options?: { beforeMessageId?: string | null; limit?: number }, @@ -46,6 +53,7 @@ export interface DesktopBridge { openExternal?: (url: string) => Promise; openPath?: (targetPath: string) => Promise; prepareProjectRootPath?: (rootPath: string, artifactFolderNames: string[]) => Promise; + recordAuditEvent?: (event: AuditEvent) => Promise; reloadExternalChannels?: () => Promise; resetRuntime?: () => Promise; restartApp?: () => Promise; diff --git a/src/shared/electron/ipc-channels.ts b/src/shared/electron/ipc-channels.ts index 04d2db3..80c30bb 100644 --- a/src/shared/electron/ipc-channels.ts +++ b/src/shared/electron/ipc-channels.ts @@ -10,7 +10,9 @@ export const ipcChannels = { deleteAgent: 'dune:runtime:delete-agent', ensureProjectArtifactFolder: 'dune:runtime:ensure-project-artifact-folder', ensureProjectMainAgent: 'dune:runtime:ensure-project-main-agent', + exportAuditCsv: 'dune:audit:export-csv', getAgentTranscriptPage: 'dune:runtime:get-agent-transcript-page', + getAuditLog: 'dune:audit:query', getProjectActivityPage: 'dune:workflow:get-project-activity-page', getRuntimeSnapshot: 'dune:runtime:get-snapshot', getTelegramSetupSession: 'dune:runtime:get-telegram-setup-session', @@ -24,6 +26,7 @@ export const ipcChannels = { runIsolatedResearch: 'dune:runtime:run-isolated-research', runtimeSnapshotUpdated: 'dune:runtime:snapshot-updated', itemActivityUpdated: 'dune:workflow:item-activity-updated', + recordAuditEvent: 'dune:audit:record', selectAgent: 'dune:runtime:select-agent', sendAgentMessage: 'dune:runtime:send-agent-message', startTelegramSetupSession: 'dune:runtime:start-telegram-setup-session', diff --git a/tests/unit/src/electron/main/audit/audit-log.test.ts b/tests/unit/src/electron/main/audit/audit-log.test.ts new file mode 100644 index 0000000..2baf3f9 --- /dev/null +++ b/tests/unit/src/electron/main/audit/audit-log.test.ts @@ -0,0 +1,96 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import Database from 'better-sqlite3'; + +import { + AuditLog, + rowsToCsv, +} from '@/electron/main/audit/audit-log'; + +describe('AuditLog', () => { + let sqlite: InstanceType; + let auditLog: AuditLog; + + beforeEach(() => { + sqlite = new Database(':memory:'); + auditLog = new AuditLog(sqlite); + }); + + afterEach(() => { + sqlite.close(); + }); + + it('records and retrieves an event', () => { + auditLog.record({ + actor: 'test-agent', + actorType: 'agent', + eventType: 'item.created', + projectId: 'proj-1', + itemId: 'item-1', + itemTitle: 'Test Item', + summary: 'Item created', + }); + const { rows, total } = auditLog.query({ projectId: 'proj-1' }); + expect(total).toBe(1); + expect(rows[0]?.actor).toBe('test-agent'); + expect(rows[0]?.event_type).toBe('item.created'); + }); + + it('filters by event_type', () => { + auditLog.record({ actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's1' }); + auditLog.record({ actor: 'a', actorType: 'agent', eventType: 'item.moved', projectId: 'p', summary: 's2' }); + const { rows } = auditLog.query({ projectId: 'p', eventType: 'item.moved' }); + expect(rows).toHaveLength(1); + expect(rows[0]?.event_type).toBe('item.moved'); + }); + + it('filters by actor', () => { + auditLog.record({ actor: 'alice', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's1' }); + auditLog.record({ actor: 'bob', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's2' }); + const { rows } = auditLog.query({ projectId: 'p', actor: 'alice' }); + expect(rows).toHaveLength(1); + expect(rows[0]?.actor).toBe('alice'); + }); + + it('filters by date range', () => { + const now = Date.now(); + auditLog.record({ ts: now - 10_000, actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 'old' }); + auditLog.record({ ts: now, actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 'new' }); + const { rows } = auditLog.query({ projectId: 'p', since: now - 5_000 }); + expect(rows).toHaveLength(1); + expect(rows[0]?.summary).toBe('new'); + }); + + it('exportCsv produces CSV with headers', () => { + auditLog.record({ actor: 'test', actorType: 'user', eventType: 'item.created', projectId: 'p', summary: 'created' }); + const csv = auditLog.exportCsv({ projectId: 'p' }); + expect(csv).toContain('id,timestamp,actor'); + expect(csv).toContain('"test"'); + }); + + it('rowsToCsv escapes quotes and null values', () => { + const csv = rowsToCsv([ + { + actor: 'Jane "QA"', + actor_type: 'user', + details: null, + event_type: 'item.updated', + id: 7, + item_id: null, + item_title: 'Escaped item', + project_id: 'p', + summary: 'Changed "brief"', + ts: 0, + }, + ]); + + expect(csv).toContain('"Jane ""QA"""'); + expect(csv).toContain('"Changed ""brief"""'); + expect(csv).toContain('1970-01-01T00:00:00.000Z'); + }); +}); diff --git a/tests/unit/src/electron/main/ipc/register-main-ipc-handlers.test.ts b/tests/unit/src/electron/main/ipc/register-main-ipc-handlers.test.ts index e6afc2f..cb4c0c6 100644 --- a/tests/unit/src/electron/main/ipc/register-main-ipc-handlers.test.ts +++ b/tests/unit/src/electron/main/ipc/register-main-ipc-handlers.test.ts @@ -137,10 +137,13 @@ describe('registerMainIpcHandlers', () => { it('registers the full main-process handler surface', () => { const { handlers } = createHarness(); - expect(handlers.size).toBe(30); + expect(handlers.size).toBe(33); expect([...handlers.keys()]).toEqual(expect.arrayContaining([ ipcChannels.getRuntimeSnapshot, ipcChannels.getAgentTranscriptPage, + ipcChannels.getAuditLog, + ipcChannels.exportAuditCsv, + ipcChannels.recordAuditEvent, ipcChannels.getProjectActivityPage, ipcChannels.applyNetworkSettings, ipcChannels.copyText, diff --git a/tests/unit/src/renderer/features/audit-log/AuditLogView.test.tsx b/tests/unit/src/renderer/features/audit-log/AuditLogView.test.tsx new file mode 100644 index 0000000..2930924 --- /dev/null +++ b/tests/unit/src/renderer/features/audit-log/AuditLogView.test.tsx @@ -0,0 +1,93 @@ +// Audit log view tests. + +import { + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + describe, + expect, + it, + vi, +} from 'vitest'; + +import { AuditLogView } from '@/renderer/features/audit-log/AuditLogView'; + +describe('AuditLogView', () => { + it('loads audit rows and renders the filterable table', async () => { + const getAuditLog = vi.fn(() => Promise.resolve({ + rows: [ + { + actor: 'Research agent', + actor_type: 'agent' as const, + details: null, + event_type: 'item.created' as const, + id: 1, + item_id: 'item-1', + item_title: 'Audit item', + project_id: 'project-1', + summary: 'Created work item "Audit item".', + ts: Date.UTC(2026, 0, 1, 12, 0, 0), + }, + ], + total: 1, + })); + + window.duneDesktop = { + ...window.duneDesktop, + getAuditLog, + platform: 'darwin', + }; + + render(); + + expect(screen.getByRole('heading', { name: 'Audit Log' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('Research agent')).toBeInTheDocument()); + expect(within(screen.getByRole('table')).getByText('item.created')).toBeInTheDocument(); + expect(screen.getByText('Audit item')).toBeInTheDocument(); + expect(screen.getByText('Created work item "Audit item".')).toBeInTheDocument(); + expect(getAuditLog).toHaveBeenLastCalledWith({ + limit: 50, + offset: 0, + projectId: 'project-1', + }); + }); + + it('exports the current audit filters as CSV', async () => { + const user = userEvent.setup(); + const exportAuditCsv = vi.fn(() => Promise.resolve('id,timestamp\n')); + const createObjectUrl = vi.fn(() => 'blob:audit-log'); + const revokeObjectUrl = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectUrl, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectUrl, + }); + + window.duneDesktop = { + ...window.duneDesktop, + exportAuditCsv, + getAuditLog: vi.fn(() => Promise.resolve({ rows: [], total: 0 })), + platform: 'darwin', + }; + + render(); + await user.selectOptions(screen.getByLabelText(/Event/i), 'task.updated'); + await user.click(screen.getByRole('button', { name: /Export CSV/i })); + + await waitFor(() => + expect(exportAuditCsv).toHaveBeenCalledWith({ + eventType: 'task.updated', + projectId: 'project-1', + })); + expect(click).toHaveBeenCalled(); + }); +}); From 116bd1e5d9ff359d7de1b1053a8e6ba7bc81df7f Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 11:57:39 +0800 Subject: [PATCH 2/5] feat: comprehensive audit log with SQLite persistence and Audit Log view --- src/electron/main.ts | 9 +- .../main/agent-actions/handlers/items.ts | 2 +- .../main/agent-actions/handlers/tasks.ts | 2 +- .../main/agent-actions/handlers/types.ts | 4 +- .../agent-actions/handlers/work-products.ts | 2 +- .../main/agent-actions/register-actions.ts | 4 +- src/electron/main/audit/audit-db.test.ts | 54 ++++++ src/electron/main/audit/audit-db.ts | 177 +++++++++++++++--- .../main/ipc/register-main-ipc-handlers.ts | 6 +- .../main/runtime/runtime-bootstrap.ts | 4 +- .../src/electron/main/audit/audit-db.test.ts | 61 ++++++ 11 files changed, 283 insertions(+), 42 deletions(-) create mode 100644 src/electron/main/audit/audit-db.test.ts create mode 100644 tests/unit/src/electron/main/audit/audit-db.test.ts diff --git a/src/electron/main.ts b/src/electron/main.ts index d66f33a..744bf23 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import started from 'electron-squirrel-startup'; import { createQuitCoordinator } from '@/electron/main/quit-coordinator'; -import { AuditLog } from '@/electron/main/audit/audit-log'; +import { AuditDatabase } from '@/electron/main/audit/audit-db'; import { openDuneDatabase, resolveDuneDatabasePath, @@ -75,7 +75,7 @@ void app.whenReady().then(async () => { const agentLiteRuntimeRoot = resolveAgentLiteRuntimeRoot(agentLiteHomeDir); const userDataDir = app.getPath('userData'); const sqlite = openDuneDatabase(resolveDuneDatabasePath(userDataDir)); - const auditLog = new AuditLog(sqlite); + const auditDb = new AuditDatabase(userDataDir); const stores = { agents: new JsonFileStorage(userDataDir, 'agents'), secrets: new EncryptedFileStorage(userDataDir, 'secrets'), @@ -121,7 +121,7 @@ void app.whenReady().then(async () => { runtimeBootstrap = createRuntimeBootstrap({ agentStore: stores.agents, app, - auditLog, + auditLog: auditDb, ...(agentLiteHomeDir ? { agentLiteHomeDir } : {}), onAgentIdle: workflowCoordinator.onAgentIdle, onItemActivityChanged: (payload) => { @@ -142,6 +142,7 @@ void app.whenReady().then(async () => { workflowCoordinator.stop(); telegramPowerCoordinator.shutdown(); await runtimeBootstrap?.shutdown(); + auditDb.close(); sqlite.close(); }; @@ -164,7 +165,7 @@ void app.whenReady().then(async () => { registerMainIpcHandlers({ applyPersistedNetworkSettings, - auditLog, + auditLog: auditDb, createInitialRuntimeSnapshot, deleteLocalData: async () => { await shutdownMainProcess(); diff --git a/src/electron/main/agent-actions/handlers/items.ts b/src/electron/main/agent-actions/handlers/items.ts index 2ae10ea..509c780 100644 --- a/src/electron/main/agent-actions/handlers/items.ts +++ b/src/electron/main/agent-actions/handlers/items.ts @@ -42,7 +42,7 @@ import { ToolHandlerError, type RegisteredTool } from './types'; /** Returns the actor label for audit events emitted by agent actions. */ function auditActor(agentContext: { agentId?: string; agentName?: string }) { - return agentContext.agentName || agentContext.agentId || 'user'; + return agentContext.agentName ?? 'user'; } /** Lists item tools. */ diff --git a/src/electron/main/agent-actions/handlers/tasks.ts b/src/electron/main/agent-actions/handlers/tasks.ts index 39ab238..26b45f7 100644 --- a/src/electron/main/agent-actions/handlers/tasks.ts +++ b/src/electron/main/agent-actions/handlers/tasks.ts @@ -17,7 +17,7 @@ import { ToolHandlerError, type RegisteredTool } from './types'; /** Returns the actor label for audit events emitted by agent actions. */ function auditActor(agentContext: { agentId?: string; agentName?: string }) { - return agentContext.agentName || agentContext.agentId || 'user'; + return agentContext.agentName ?? 'user'; } /** Lists task tools. */ diff --git a/src/electron/main/agent-actions/handlers/types.ts b/src/electron/main/agent-actions/handlers/types.ts index 9f4b992..019006b 100644 --- a/src/electron/main/agent-actions/handlers/types.ts +++ b/src/electron/main/agent-actions/handlers/types.ts @@ -2,7 +2,7 @@ import type { AppStorage } from '@/electron/main/storage/app-storage'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditDatabase } from '@/electron/main/audit/audit-db'; /** MCP-style tool definition. Used internally by RegisteredTool. */ export interface ToolDefinition { @@ -22,7 +22,7 @@ export interface ToolHandlerContext { /** Tool handler options. */ export interface ToolHandlerOptions { - auditLog?: AuditLog; + auditLog?: AuditDatabase; getRuntimeController: () => DesktopRuntimeController; onWorkflowChanged: () => void; workflowStore: AppStorage; diff --git a/src/electron/main/agent-actions/handlers/work-products.ts b/src/electron/main/agent-actions/handlers/work-products.ts index 6d76657..2889c32 100644 --- a/src/electron/main/agent-actions/handlers/work-products.ts +++ b/src/electron/main/agent-actions/handlers/work-products.ts @@ -17,7 +17,7 @@ import { ToolHandlerError, type RegisteredTool } from './types'; /** Returns the actor label for audit events emitted by agent actions. */ function auditActor(agentContext: { agentId?: string; agentName?: string }) { - return agentContext.agentName || agentContext.agentId || 'user'; + return agentContext.agentName ?? 'user'; } /** Lists work product tools. */ diff --git a/src/electron/main/agent-actions/register-actions.ts b/src/electron/main/agent-actions/register-actions.ts index 5c39af9..873b49d 100644 --- a/src/electron/main/agent-actions/register-actions.ts +++ b/src/electron/main/agent-actions/register-actions.ts @@ -16,7 +16,7 @@ import type { Agent as AgentLiteAgent } from '@boxlite-ai/agentlite'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage/app-storage'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditDatabase } from '@/electron/main/audit/audit-db'; import { agentTools } from '@/electron/main/agent-actions/handlers/agents'; import { itemTools } from '@/electron/main/agent-actions/handlers/items'; @@ -32,7 +32,7 @@ import type { /** Services captured by every action handler closure. */ export interface ActionHostServices { - auditLog?: AuditLog; + auditLog?: AuditDatabase; getRuntimeController: () => DesktopRuntimeController; onWorkflowChanged: () => void; workflowStore: AppStorage; diff --git a/src/electron/main/audit/audit-db.test.ts b/src/electron/main/audit/audit-db.test.ts new file mode 100644 index 0000000..bc8841b --- /dev/null +++ b/src/electron/main/audit/audit-db.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AuditDatabase } from './audit-db'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; + +describe('AuditDatabase', () => { + let tmpDir: string; + let auditDb: AuditDatabase; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-test-')); + auditDb = new AuditDatabase(tmpDir); + }); + + afterEach(() => { + auditDb.close(); + fs.rmSync(tmpDir, { recursive: true }); + }); + + it('records and queries events', () => { + auditDb.record({ + actor: 'test-agent', + actorType: 'agent', + eventType: 'item.created', + projectId: 'proj-1', + itemId: 'item-1', + itemTitle: 'Test Item', + summary: 'Test item created', + }); + + const { rows, total } = auditDb.query({ projectId: 'proj-1' }); + expect(total).toBe(1); + expect(rows[0]!.actor).toBe('test-agent'); + expect(rows[0]!.event_type).toBe('item.created'); + }); + + it('filters by event_type', () => { + auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's' }); + auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.moved', projectId: 'p', summary: 's' }); + + const { rows } = auditDb.query({ projectId: 'p', eventType: 'item.created' }); + expect(rows).toHaveLength(1); + expect(rows[0]!.event_type).toBe('item.created'); + }); + + it('exports valid CSV', () => { + auditDb.record({ actor: 'user', actorType: 'user', eventType: 'item.moved', projectId: 'p', summary: 'moved' }); + const csv = auditDb.exportCsv({ projectId: 'p' }); + const lines = csv.split('\n'); + expect(lines[0]).toContain('timestamp'); + expect(lines.length).toBe(2); + }); +}); diff --git a/src/electron/main/audit/audit-db.ts b/src/electron/main/audit/audit-db.ts index cc42482..d151cf7 100644 --- a/src/electron/main/audit/audit-db.ts +++ b/src/electron/main/audit/audit-db.ts @@ -1,34 +1,159 @@ import Database from 'better-sqlite3'; import path from 'node:path'; -import { AuditLog } from './audit-log'; - -export { - ensureAuditEventsSchema, - rowsToCsv, -} from './audit-log'; -export type { - AuditActorType, - AuditEvent as RecordEventParams, - AuditEventRow, - AuditEventType, - QueryAuditParams, -} from '@/shared/audit-log'; - -export class AuditDatabase extends AuditLog { - private readonly sqlite: InstanceType; - - constructor(userDataDirOrMemory: string) { - const sqlite = userDataDirOrMemory === ':memory:' - ? new Database(':memory:') - : new Database(path.join(userDataDirOrMemory, 'audit.db')); - - sqlite.pragma('journal_mode = WAL'); - super(sqlite); - this.sqlite = sqlite; +export type AuditEventType = + | 'item.created' | 'item.moved' | 'item.deleted' | 'item.updated' + | 'agent.assigned' + | 'feedback.added' + | 'work_product.added' | 'work_product.deleted' + | 'task.created' | 'task.updated' | 'task.deleted'; + +export interface AuditEventRow { + id: number; + ts: number; + actor: string; + actor_type: 'agent' | 'user' | 'system'; + event_type: AuditEventType; + item_id: string | null; + item_title: string | null; + project_id: string; + summary: string; + details: string | null; +} + +export interface RecordEventParams { + ts?: number; + actor: string; + actorType: 'agent' | 'user' | 'system'; + eventType: AuditEventType; + itemId?: string | null; + itemTitle?: string | null; + projectId: string; + summary: string; + details?: Record | null; +} + +export interface QueryAuditParams { + projectId: string; + since?: number; + until?: number; + eventType?: string; + actor?: string; + itemId?: string; + limit?: number; + offset?: number; +} + +export class AuditDatabase { + private db: InstanceType; + private insertStmt: ReturnType['prepare']>; + + constructor(userDataDir: string) { + this.db = new Database(path.join(userDataDir, 'audit.db')); + this.db.pragma('journal_mode = WAL'); + this.db.exec(` + CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + actor TEXT NOT NULL, + actor_type TEXT NOT NULL, + event_type TEXT NOT NULL, + item_id TEXT, + item_title TEXT, + project_id TEXT NOT NULL, + summary TEXT NOT NULL, + details TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(ts); + CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_events(event_type); + CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor); + CREATE INDEX IF NOT EXISTS idx_audit_item_id ON audit_events(item_id); + CREATE INDEX IF NOT EXISTS idx_audit_project_id ON audit_events(project_id); + `); + this.insertStmt = this.db.prepare(` + INSERT INTO audit_events + (ts, actor, actor_type, event_type, item_id, item_title, project_id, summary, details) + VALUES + (@ts, @actor, @actorType, @eventType, @itemId, @itemTitle, @projectId, @summary, @details) + `); + } + + record(params: RecordEventParams): void { + this.insertStmt.run({ + ts: params.ts ?? Date.now(), + actor: params.actor, + actorType: params.actorType, + eventType: params.eventType, + itemId: params.itemId ?? null, + itemTitle: params.itemTitle ?? null, + projectId: params.projectId, + summary: params.summary, + details: params.details ? JSON.stringify(params.details) : null, + }); + } + + query(params: QueryAuditParams): { rows: AuditEventRow[]; total: number } { + const conditions: string[] = ['project_id = @projectId']; + const bindings: Record = { projectId: params.projectId }; + + if (params.since) { + conditions.push('ts >= @since'); + bindings.since = params.since; + } + if (params.until) { + conditions.push('ts <= @until'); + bindings.until = params.until; + } + if (params.eventType) { + conditions.push('event_type = @eventType'); + bindings.eventType = params.eventType; + } + if (params.actor) { + conditions.push('actor = @actor'); + bindings.actor = params.actor; + } + if (params.itemId) { + conditions.push('item_id = @itemId'); + bindings.itemId = params.itemId; + } + + const where = conditions.join(' AND '); + const limit = Math.min(params.limit ?? 100, 500); + const offset = params.offset ?? 0; + + const rows = this.db.prepare( + `SELECT * FROM audit_events WHERE ${where} ORDER BY ts DESC LIMIT @limit OFFSET @offset`, + ).all({ ...bindings, limit, offset }) as AuditEventRow[]; + + const { count } = this.db.prepare( + `SELECT COUNT(*) as count FROM audit_events WHERE ${where}`, + ).get(bindings) as { count: number }; + + return { rows, total: count }; + } + + exportCsv(params: Omit): string { + const { rows } = this.query({ ...params, limit: 10_000, offset: 0 }); + const headers = ['id', 'timestamp', 'actor', 'actor_type', 'event_type', 'item_id', 'item_title', 'project_id', 'summary', 'details']; + const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`; + return [ + headers.join(','), + ...rows.map((r) => [ + r.id, + new Date(r.ts).toISOString(), + esc(r.actor), + esc(r.actor_type), + esc(r.event_type), + esc(r.item_id), + esc(r.item_title), + esc(r.project_id), + esc(r.summary), + esc(r.details), + ].join(',')), + ].join('\n'); } close(): void { - this.sqlite.close(); + this.db.close(); } } diff --git a/src/electron/main/ipc/register-main-ipc-handlers.ts b/src/electron/main/ipc/register-main-ipc-handlers.ts index b5eb7b5..0c5def4 100644 --- a/src/electron/main/ipc/register-main-ipc-handlers.ts +++ b/src/electron/main/ipc/register-main-ipc-handlers.ts @@ -11,8 +11,8 @@ import type { OpenDialogOptions } from 'electron'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage'; -import type { AuditEvent, QueryAuditParams } from '@/shared/audit-log'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditEvent } from '@/shared/audit-log'; +import type { AuditDatabase, QueryAuditParams } from '@/electron/main/audit/audit-db'; import { assertEmptyProjectRootDirectory, ensureProjectArtifactFolder, @@ -51,7 +51,7 @@ interface RegisterMainIpcHandlersOptions { deleteLocalData: () => Promise; dialog?: DialogLike; ensureRuntime: () => Promise; - auditLog?: AuditLog; + auditLog?: AuditDatabase; getFocusedWindow?: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null; getProjectActivityPage: ( diff --git a/src/electron/main/runtime/runtime-bootstrap.ts b/src/electron/main/runtime/runtime-bootstrap.ts index b458467..56d7fc9 100644 --- a/src/electron/main/runtime/runtime-bootstrap.ts +++ b/src/electron/main/runtime/runtime-bootstrap.ts @@ -5,14 +5,14 @@ import type { App } from 'electron'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditDatabase } from '@/electron/main/audit/audit-db'; import type { AgentServiceSnapshot } from '@/shared/agents/agent-runtime'; interface RuntimeBootstrapOptions { agentLiteHomeDir?: string; agentStore: AppStorage; app: Pick; - auditLog?: AuditLog; + auditLog?: AuditDatabase; onAgentIdle: (agentId: string) => void; onItemActivityChanged: (payload: { isWorking: boolean; itemId: string }) => void; onRuntimeSnapshot: (snapshot: AgentServiceSnapshot) => void; diff --git a/tests/unit/src/electron/main/audit/audit-db.test.ts b/tests/unit/src/electron/main/audit/audit-db.test.ts new file mode 100644 index 0000000..a246fec --- /dev/null +++ b/tests/unit/src/electron/main/audit/audit-db.test.ts @@ -0,0 +1,61 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { AuditDatabase } from '@/electron/main/audit/audit-db'; + +describe('AuditDatabase', () => { + let tmpDir: string; + let auditDb: AuditDatabase; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-test-')); + auditDb = new AuditDatabase(tmpDir); + }); + + afterEach(() => { + auditDb.close(); + fs.rmSync(tmpDir, { recursive: true }); + }); + + it('records and queries events', () => { + auditDb.record({ + actor: 'test-agent', + actorType: 'agent', + eventType: 'item.created', + projectId: 'proj-1', + itemId: 'item-1', + itemTitle: 'Test Item', + summary: 'Test item created', + }); + + const { rows, total } = auditDb.query({ projectId: 'proj-1' }); + expect(total).toBe(1); + expect(rows[0]?.actor).toBe('test-agent'); + expect(rows[0]?.event_type).toBe('item.created'); + }); + + it('filters by event_type', () => { + auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's' }); + auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.moved', projectId: 'p', summary: 's' }); + + const { rows } = auditDb.query({ projectId: 'p', eventType: 'item.created' }); + expect(rows).toHaveLength(1); + expect(rows[0]?.event_type).toBe('item.created'); + }); + + it('exports valid CSV', () => { + auditDb.record({ actor: 'user', actorType: 'user', eventType: 'item.moved', projectId: 'p', summary: 'moved' }); + const csv = auditDb.exportCsv({ projectId: 'p' }); + const lines = csv.split('\n'); + expect(lines[0]).toContain('timestamp'); + expect(lines.length).toBe(2); + }); +}); From f1cb68bda608062a249cf3538996a17f9b4d325d Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 13:10:06 +0800 Subject: [PATCH 3/5] feat: comprehensive audit log with SQLite persistence and Audit Log view --- .../main/agent-actions/handlers/projects.ts | 17 ++++++++++++++++- src/renderer/app/AppShell.tsx | 5 +++++ src/renderer/app/shell/AppSidebar.tsx | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/electron/main/agent-actions/handlers/projects.ts b/src/electron/main/agent-actions/handlers/projects.ts index 330c069..722cb06 100644 --- a/src/electron/main/agent-actions/handlers/projects.ts +++ b/src/electron/main/agent-actions/handlers/projects.ts @@ -143,7 +143,7 @@ export const projectTools: RegisteredTool[] = [ inputSchema: objectSchema({ projectId: optionalStringSchema }), name: 'workflow.projects.delete', }, - handler: async ({ agentContext, getRuntimeController, onWorkflowChanged, workflowStore }, args) => { + handler: async ({ agentContext, auditLog, getRuntimeController, onWorkflowChanged, workflowStore }, args) => { const snapshot = await readWorkflowSnapshot(workflowStore); const projectId = resolveProjectId(args.projectId, agentContext.projectId); @@ -154,6 +154,7 @@ export const projectTools: RegisteredTool[] = [ const runtimeController = getRuntimeController(); const projectAgents = runtimeController.getSnapshot().agents .filter((agent) => agent.projectId === projectId); + const deletedItems = snapshot.items.filter((item) => item.projectId === projectId); await Promise.all(projectAgents.map((agent) => runtimeController.deleteAgent(agent.id))); @@ -167,6 +168,20 @@ export const projectTools: RegisteredTool[] = [ } await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + + for (const item of deletedItems) { + auditLog?.record({ + actor: agentContext.agentName ?? 'user', + actorType: 'agent', + eventType: 'item.deleted', + itemId: item.id, + itemTitle: item.title, + projectId, + summary: `Deleted work item "${item.title}".`, + details: { viaProjectDelete: true }, + }); + } + return { success: true }; }, }, diff --git a/src/renderer/app/AppShell.tsx b/src/renderer/app/AppShell.tsx index 13802a6..6d3e8b4 100644 --- a/src/renderer/app/AppShell.tsx +++ b/src/renderer/app/AppShell.tsx @@ -214,6 +214,10 @@ export default function AppShell() { controller.handleSidebarDrawerOpenChange(false); setCreateProjectOpen(true); }, + onOpenAuditLog: () => { + controller.handleSidebarDrawerOpenChange(false); + commands.openAuditLog(selectedProjectId); + }, onOpenPlugins: () => { controller.handleSidebarDrawerOpenChange(false); commands.openPlugins(); @@ -225,6 +229,7 @@ export default function AppShell() { }, projects, selectedProjectId, + selectedProjectView, }, }; const sidebar = (className: string, options?: { showQuickSwitch?: boolean }) => ( diff --git a/src/renderer/app/shell/AppSidebar.tsx b/src/renderer/app/shell/AppSidebar.tsx index e9eb2f5..55503d8 100644 --- a/src/renderer/app/shell/AppSidebar.tsx +++ b/src/renderer/app/shell/AppSidebar.tsx @@ -3,6 +3,7 @@ import { Blocks, Command, + History, Plus, Settings2, Sparkles, @@ -24,11 +25,13 @@ import { /** Workflow sidebar state. */ interface WorkflowSidebarState { onCreateProject: () => void; + onOpenAuditLog: () => void; onOpenPlugins: () => void; onOpenSettings: () => void; onSelectProject: (projectId: string) => void; projects: WorkflowProject[]; selectedProjectId: string | null; + selectedProjectView: string; } /** App sidebar props. */ @@ -109,6 +112,22 @@ export function AppSidebar({ Plugins + From eb2b63facc2cbf136ecd86090f929f64aa48c975 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 13:11:43 +0800 Subject: [PATCH 4/5] refactor audit log persistence --- src/electron/main.ts | 9 +- .../main/agent-actions/handlers/types.ts | 4 +- .../main/agent-actions/register-actions.ts | 4 +- src/electron/main/audit/audit-db.test.ts | 54 ------ src/electron/main/audit/audit-db.ts | 159 ------------------ src/electron/main/audit/audit-log.ts | 33 +--- src/electron/main/audit/csv-export.ts | 37 ++++ src/electron/main/ipc/audit-ipc.ts | 33 ++++ .../main/ipc/register-main-ipc-handlers.ts | 25 +-- .../main/runtime/runtime-bootstrap.ts | 4 +- .../src/electron/main/audit/audit-db.test.ts | 61 ------- .../src/electron/main/audit/audit-log.test.ts | 6 +- 12 files changed, 91 insertions(+), 338 deletions(-) delete mode 100644 src/electron/main/audit/audit-db.test.ts delete mode 100644 src/electron/main/audit/audit-db.ts create mode 100644 src/electron/main/audit/csv-export.ts create mode 100644 src/electron/main/ipc/audit-ipc.ts delete mode 100644 tests/unit/src/electron/main/audit/audit-db.test.ts diff --git a/src/electron/main.ts b/src/electron/main.ts index 744bf23..d66f33a 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import started from 'electron-squirrel-startup'; import { createQuitCoordinator } from '@/electron/main/quit-coordinator'; -import { AuditDatabase } from '@/electron/main/audit/audit-db'; +import { AuditLog } from '@/electron/main/audit/audit-log'; import { openDuneDatabase, resolveDuneDatabasePath, @@ -75,7 +75,7 @@ void app.whenReady().then(async () => { const agentLiteRuntimeRoot = resolveAgentLiteRuntimeRoot(agentLiteHomeDir); const userDataDir = app.getPath('userData'); const sqlite = openDuneDatabase(resolveDuneDatabasePath(userDataDir)); - const auditDb = new AuditDatabase(userDataDir); + const auditLog = new AuditLog(sqlite); const stores = { agents: new JsonFileStorage(userDataDir, 'agents'), secrets: new EncryptedFileStorage(userDataDir, 'secrets'), @@ -121,7 +121,7 @@ void app.whenReady().then(async () => { runtimeBootstrap = createRuntimeBootstrap({ agentStore: stores.agents, app, - auditLog: auditDb, + auditLog, ...(agentLiteHomeDir ? { agentLiteHomeDir } : {}), onAgentIdle: workflowCoordinator.onAgentIdle, onItemActivityChanged: (payload) => { @@ -142,7 +142,6 @@ void app.whenReady().then(async () => { workflowCoordinator.stop(); telegramPowerCoordinator.shutdown(); await runtimeBootstrap?.shutdown(); - auditDb.close(); sqlite.close(); }; @@ -165,7 +164,7 @@ void app.whenReady().then(async () => { registerMainIpcHandlers({ applyPersistedNetworkSettings, - auditLog: auditDb, + auditLog, createInitialRuntimeSnapshot, deleteLocalData: async () => { await shutdownMainProcess(); diff --git a/src/electron/main/agent-actions/handlers/types.ts b/src/electron/main/agent-actions/handlers/types.ts index 019006b..9f4b992 100644 --- a/src/electron/main/agent-actions/handlers/types.ts +++ b/src/electron/main/agent-actions/handlers/types.ts @@ -2,7 +2,7 @@ import type { AppStorage } from '@/electron/main/storage/app-storage'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; -import type { AuditDatabase } from '@/electron/main/audit/audit-db'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; /** MCP-style tool definition. Used internally by RegisteredTool. */ export interface ToolDefinition { @@ -22,7 +22,7 @@ export interface ToolHandlerContext { /** Tool handler options. */ export interface ToolHandlerOptions { - auditLog?: AuditDatabase; + auditLog?: AuditLog; getRuntimeController: () => DesktopRuntimeController; onWorkflowChanged: () => void; workflowStore: AppStorage; diff --git a/src/electron/main/agent-actions/register-actions.ts b/src/electron/main/agent-actions/register-actions.ts index 873b49d..5c39af9 100644 --- a/src/electron/main/agent-actions/register-actions.ts +++ b/src/electron/main/agent-actions/register-actions.ts @@ -16,7 +16,7 @@ import type { Agent as AgentLiteAgent } from '@boxlite-ai/agentlite'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage/app-storage'; -import type { AuditDatabase } from '@/electron/main/audit/audit-db'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; import { agentTools } from '@/electron/main/agent-actions/handlers/agents'; import { itemTools } from '@/electron/main/agent-actions/handlers/items'; @@ -32,7 +32,7 @@ import type { /** Services captured by every action handler closure. */ export interface ActionHostServices { - auditLog?: AuditDatabase; + auditLog?: AuditLog; getRuntimeController: () => DesktopRuntimeController; onWorkflowChanged: () => void; workflowStore: AppStorage; diff --git a/src/electron/main/audit/audit-db.test.ts b/src/electron/main/audit/audit-db.test.ts deleted file mode 100644 index bc8841b..0000000 --- a/src/electron/main/audit/audit-db.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { AuditDatabase } from './audit-db'; -import os from 'node:os'; -import path from 'node:path'; -import fs from 'node:fs'; - -describe('AuditDatabase', () => { - let tmpDir: string; - let auditDb: AuditDatabase; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-test-')); - auditDb = new AuditDatabase(tmpDir); - }); - - afterEach(() => { - auditDb.close(); - fs.rmSync(tmpDir, { recursive: true }); - }); - - it('records and queries events', () => { - auditDb.record({ - actor: 'test-agent', - actorType: 'agent', - eventType: 'item.created', - projectId: 'proj-1', - itemId: 'item-1', - itemTitle: 'Test Item', - summary: 'Test item created', - }); - - const { rows, total } = auditDb.query({ projectId: 'proj-1' }); - expect(total).toBe(1); - expect(rows[0]!.actor).toBe('test-agent'); - expect(rows[0]!.event_type).toBe('item.created'); - }); - - it('filters by event_type', () => { - auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's' }); - auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.moved', projectId: 'p', summary: 's' }); - - const { rows } = auditDb.query({ projectId: 'p', eventType: 'item.created' }); - expect(rows).toHaveLength(1); - expect(rows[0]!.event_type).toBe('item.created'); - }); - - it('exports valid CSV', () => { - auditDb.record({ actor: 'user', actorType: 'user', eventType: 'item.moved', projectId: 'p', summary: 'moved' }); - const csv = auditDb.exportCsv({ projectId: 'p' }); - const lines = csv.split('\n'); - expect(lines[0]).toContain('timestamp'); - expect(lines.length).toBe(2); - }); -}); diff --git a/src/electron/main/audit/audit-db.ts b/src/electron/main/audit/audit-db.ts deleted file mode 100644 index d151cf7..0000000 --- a/src/electron/main/audit/audit-db.ts +++ /dev/null @@ -1,159 +0,0 @@ -import Database from 'better-sqlite3'; -import path from 'node:path'; - -export type AuditEventType = - | 'item.created' | 'item.moved' | 'item.deleted' | 'item.updated' - | 'agent.assigned' - | 'feedback.added' - | 'work_product.added' | 'work_product.deleted' - | 'task.created' | 'task.updated' | 'task.deleted'; - -export interface AuditEventRow { - id: number; - ts: number; - actor: string; - actor_type: 'agent' | 'user' | 'system'; - event_type: AuditEventType; - item_id: string | null; - item_title: string | null; - project_id: string; - summary: string; - details: string | null; -} - -export interface RecordEventParams { - ts?: number; - actor: string; - actorType: 'agent' | 'user' | 'system'; - eventType: AuditEventType; - itemId?: string | null; - itemTitle?: string | null; - projectId: string; - summary: string; - details?: Record | null; -} - -export interface QueryAuditParams { - projectId: string; - since?: number; - until?: number; - eventType?: string; - actor?: string; - itemId?: string; - limit?: number; - offset?: number; -} - -export class AuditDatabase { - private db: InstanceType; - private insertStmt: ReturnType['prepare']>; - - constructor(userDataDir: string) { - this.db = new Database(path.join(userDataDir, 'audit.db')); - this.db.pragma('journal_mode = WAL'); - this.db.exec(` - CREATE TABLE IF NOT EXISTS audit_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts INTEGER NOT NULL, - actor TEXT NOT NULL, - actor_type TEXT NOT NULL, - event_type TEXT NOT NULL, - item_id TEXT, - item_title TEXT, - project_id TEXT NOT NULL, - summary TEXT NOT NULL, - details TEXT - ); - CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(ts); - CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_events(event_type); - CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor); - CREATE INDEX IF NOT EXISTS idx_audit_item_id ON audit_events(item_id); - CREATE INDEX IF NOT EXISTS idx_audit_project_id ON audit_events(project_id); - `); - this.insertStmt = this.db.prepare(` - INSERT INTO audit_events - (ts, actor, actor_type, event_type, item_id, item_title, project_id, summary, details) - VALUES - (@ts, @actor, @actorType, @eventType, @itemId, @itemTitle, @projectId, @summary, @details) - `); - } - - record(params: RecordEventParams): void { - this.insertStmt.run({ - ts: params.ts ?? Date.now(), - actor: params.actor, - actorType: params.actorType, - eventType: params.eventType, - itemId: params.itemId ?? null, - itemTitle: params.itemTitle ?? null, - projectId: params.projectId, - summary: params.summary, - details: params.details ? JSON.stringify(params.details) : null, - }); - } - - query(params: QueryAuditParams): { rows: AuditEventRow[]; total: number } { - const conditions: string[] = ['project_id = @projectId']; - const bindings: Record = { projectId: params.projectId }; - - if (params.since) { - conditions.push('ts >= @since'); - bindings.since = params.since; - } - if (params.until) { - conditions.push('ts <= @until'); - bindings.until = params.until; - } - if (params.eventType) { - conditions.push('event_type = @eventType'); - bindings.eventType = params.eventType; - } - if (params.actor) { - conditions.push('actor = @actor'); - bindings.actor = params.actor; - } - if (params.itemId) { - conditions.push('item_id = @itemId'); - bindings.itemId = params.itemId; - } - - const where = conditions.join(' AND '); - const limit = Math.min(params.limit ?? 100, 500); - const offset = params.offset ?? 0; - - const rows = this.db.prepare( - `SELECT * FROM audit_events WHERE ${where} ORDER BY ts DESC LIMIT @limit OFFSET @offset`, - ).all({ ...bindings, limit, offset }) as AuditEventRow[]; - - const { count } = this.db.prepare( - `SELECT COUNT(*) as count FROM audit_events WHERE ${where}`, - ).get(bindings) as { count: number }; - - return { rows, total: count }; - } - - exportCsv(params: Omit): string { - const { rows } = this.query({ ...params, limit: 10_000, offset: 0 }); - const headers = ['id', 'timestamp', 'actor', 'actor_type', 'event_type', 'item_id', 'item_title', 'project_id', 'summary', 'details']; - const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`; - return [ - headers.join(','), - ...rows.map((r) => [ - r.id, - new Date(r.ts).toISOString(), - esc(r.actor), - esc(r.actor_type), - esc(r.event_type), - esc(r.item_id), - esc(r.item_title), - esc(r.project_id), - esc(r.summary), - esc(r.details), - ].join(',')), - ].join('\n'); - } - - close(): void { - this.db.close(); - } -} diff --git a/src/electron/main/audit/audit-log.ts b/src/electron/main/audit/audit-log.ts index 4e22400..78743c6 100644 --- a/src/electron/main/audit/audit-log.ts +++ b/src/electron/main/audit/audit-log.ts @@ -5,6 +5,7 @@ import type { AuditEventRow, QueryAuditParams, } from '@/shared/audit-log'; +import { rowsToCsv } from '@/electron/main/audit/csv-export'; export type { AuditEvent, @@ -112,35 +113,3 @@ export class AuditLog { return rowsToCsv(rows); } } - -export function rowsToCsv(rows: AuditEventRow[]): string { - const headers = [ - 'id', - 'timestamp', - 'actor', - 'actor_type', - 'event_type', - 'item_id', - 'item_title', - 'project_id', - 'summary', - 'details', - ]; - const escape = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`; - - return [ - headers.join(','), - ...rows.map((row) => [ - row.id, - new Date(row.ts).toISOString(), - escape(row.actor), - escape(row.actor_type), - escape(row.event_type), - escape(row.item_id), - escape(row.item_title), - escape(row.project_id), - escape(row.summary), - escape(row.details), - ].join(',')), - ].join('\n'); -} diff --git a/src/electron/main/audit/csv-export.ts b/src/electron/main/audit/csv-export.ts new file mode 100644 index 0000000..0b97788 --- /dev/null +++ b/src/electron/main/audit/csv-export.ts @@ -0,0 +1,37 @@ +import type { AuditEventRow } from '@/shared/audit-log'; + +const auditCsvHeaders = [ + 'id', + 'timestamp', + 'actor', + 'actor_type', + 'event_type', + 'item_id', + 'item_title', + 'project_id', + 'summary', + 'details', +] as const; + +function escapeCsvValue(value: unknown): string { + return `"${String(value ?? '').replace(/"/g, '""')}"`; +} + +/** Converts audit log rows into a downloadable CSV payload. */ +export function rowsToCsv(rows: AuditEventRow[]): string { + return [ + auditCsvHeaders.join(','), + ...rows.map((row) => [ + row.id, + new Date(row.ts).toISOString(), + escapeCsvValue(row.actor), + escapeCsvValue(row.actor_type), + escapeCsvValue(row.event_type), + escapeCsvValue(row.item_id), + escapeCsvValue(row.item_title), + escapeCsvValue(row.project_id), + escapeCsvValue(row.summary), + escapeCsvValue(row.details), + ].join(',')), + ].join('\n'); +} diff --git a/src/electron/main/ipc/audit-ipc.ts b/src/electron/main/ipc/audit-ipc.ts new file mode 100644 index 0000000..8181a50 --- /dev/null +++ b/src/electron/main/ipc/audit-ipc.ts @@ -0,0 +1,33 @@ +import type { AuditEvent, QueryAuditParams } from '@/shared/audit-log'; +import { ipcChannels } from '@/shared/electron/ipc-channels'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; + +interface IpcMainLike { + handle(channel: string, listener: (...args: any[]) => any): void; +} + +interface RegisterAuditIpcHandlersOptions { + auditLog?: AuditLog | undefined; + ipcMain: IpcMainLike; +} + +/** Registers audit-log IPC handlers. */ +export function registerAuditIpcHandlers({ + auditLog, + ipcMain, +}: RegisterAuditIpcHandlersOptions): void { + ipcMain.handle( + ipcChannels.getAuditLog, + async (_event, params: QueryAuditParams) => auditLog?.query(params) ?? { rows: [], total: 0 }, + ); + ipcMain.handle( + ipcChannels.exportAuditCsv, + async (_event, params: QueryAuditParams) => auditLog?.exportCsv(params) ?? '', + ); + ipcMain.handle( + ipcChannels.recordAuditEvent, + async (_event, event: AuditEvent) => { + auditLog?.record(event); + }, + ); +} diff --git a/src/electron/main/ipc/register-main-ipc-handlers.ts b/src/electron/main/ipc/register-main-ipc-handlers.ts index 0c5def4..c87d993 100644 --- a/src/electron/main/ipc/register-main-ipc-handlers.ts +++ b/src/electron/main/ipc/register-main-ipc-handlers.ts @@ -11,8 +11,8 @@ import type { OpenDialogOptions } from 'electron'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage'; -import type { AuditEvent } from '@/shared/audit-log'; -import type { AuditDatabase, QueryAuditParams } from '@/electron/main/audit/audit-db'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; +import { registerAuditIpcHandlers } from '@/electron/main/ipc/audit-ipc'; import { assertEmptyProjectRootDirectory, ensureProjectArtifactFolder, @@ -51,7 +51,7 @@ interface RegisterMainIpcHandlersOptions { deleteLocalData: () => Promise; dialog?: DialogLike; ensureRuntime: () => Promise; - auditLog?: AuditDatabase; + auditLog?: AuditLog; getFocusedWindow?: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null; getProjectActivityPage: ( @@ -81,6 +81,11 @@ export function registerMainIpcHandlers(options: RegisterMainIpcHandlersOptions) return action(options.requireRuntimeController()); }; + registerAuditIpcHandlers({ + auditLog: options.auditLog, + ipcMain, + }); + // Runtime handlers. ipcMain.handle(ipcChannels.getRuntimeSnapshot, async () => getBootstrappedRuntimeSnapshot({ @@ -98,20 +103,6 @@ export function registerMainIpcHandlers(options: RegisterMainIpcHandlersOptions) async (_event, projectId: string, pageOptions?: { beforeEntryId?: string | null; limit?: number }) => options.getProjectActivityPage(projectId, pageOptions), ); - ipcMain.handle( - ipcChannels.getAuditLog, - async (_event, params: QueryAuditParams) => options.auditLog?.query(params) ?? { rows: [], total: 0 }, - ); - ipcMain.handle( - ipcChannels.exportAuditCsv, - async (_event, params: QueryAuditParams) => options.auditLog?.exportCsv(params) ?? '', - ); - ipcMain.handle( - ipcChannels.recordAuditEvent, - async (_event, event: AuditEvent) => { - options.auditLog?.record(event); - }, - ); ipcMain.handle( ipcChannels.applyNetworkSettings, async () => { diff --git a/src/electron/main/runtime/runtime-bootstrap.ts b/src/electron/main/runtime/runtime-bootstrap.ts index 56d7fc9..b458467 100644 --- a/src/electron/main/runtime/runtime-bootstrap.ts +++ b/src/electron/main/runtime/runtime-bootstrap.ts @@ -5,14 +5,14 @@ import type { App } from 'electron'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage'; -import type { AuditDatabase } from '@/electron/main/audit/audit-db'; +import type { AuditLog } from '@/electron/main/audit/audit-log'; import type { AgentServiceSnapshot } from '@/shared/agents/agent-runtime'; interface RuntimeBootstrapOptions { agentLiteHomeDir?: string; agentStore: AppStorage; app: Pick; - auditLog?: AuditDatabase; + auditLog?: AuditLog; onAgentIdle: (agentId: string) => void; onItemActivityChanged: (payload: { isWorking: boolean; itemId: string }) => void; onRuntimeSnapshot: (snapshot: AgentServiceSnapshot) => void; diff --git a/tests/unit/src/electron/main/audit/audit-db.test.ts b/tests/unit/src/electron/main/audit/audit-db.test.ts deleted file mode 100644 index a246fec..0000000 --- a/tests/unit/src/electron/main/audit/audit-db.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, -} from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import { AuditDatabase } from '@/electron/main/audit/audit-db'; - -describe('AuditDatabase', () => { - let tmpDir: string; - let auditDb: AuditDatabase; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-test-')); - auditDb = new AuditDatabase(tmpDir); - }); - - afterEach(() => { - auditDb.close(); - fs.rmSync(tmpDir, { recursive: true }); - }); - - it('records and queries events', () => { - auditDb.record({ - actor: 'test-agent', - actorType: 'agent', - eventType: 'item.created', - projectId: 'proj-1', - itemId: 'item-1', - itemTitle: 'Test Item', - summary: 'Test item created', - }); - - const { rows, total } = auditDb.query({ projectId: 'proj-1' }); - expect(total).toBe(1); - expect(rows[0]?.actor).toBe('test-agent'); - expect(rows[0]?.event_type).toBe('item.created'); - }); - - it('filters by event_type', () => { - auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's' }); - auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.moved', projectId: 'p', summary: 's' }); - - const { rows } = auditDb.query({ projectId: 'p', eventType: 'item.created' }); - expect(rows).toHaveLength(1); - expect(rows[0]?.event_type).toBe('item.created'); - }); - - it('exports valid CSV', () => { - auditDb.record({ actor: 'user', actorType: 'user', eventType: 'item.moved', projectId: 'p', summary: 'moved' }); - const csv = auditDb.exportCsv({ projectId: 'p' }); - const lines = csv.split('\n'); - expect(lines[0]).toContain('timestamp'); - expect(lines.length).toBe(2); - }); -}); diff --git a/tests/unit/src/electron/main/audit/audit-log.test.ts b/tests/unit/src/electron/main/audit/audit-log.test.ts index 2baf3f9..24243c4 100644 --- a/tests/unit/src/electron/main/audit/audit-log.test.ts +++ b/tests/unit/src/electron/main/audit/audit-log.test.ts @@ -7,10 +7,8 @@ import { } from 'vitest'; import Database from 'better-sqlite3'; -import { - AuditLog, - rowsToCsv, -} from '@/electron/main/audit/audit-log'; +import { AuditLog } from '@/electron/main/audit/audit-log'; +import { rowsToCsv } from '@/electron/main/audit/csv-export'; describe('AuditLog', () => { let sqlite: InstanceType; From 46377dd4915edf2f141f406ef9a2cadf1f52c071 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 15:11:30 +0800 Subject: [PATCH 5/5] feat: comprehensive audit log with SQLite persistence and Audit Log view --- package.json | 2 +- pnpm-lock.yaml | 6 +- src/electron/main.ts | 11 +- .../main/agent-actions/handlers/items.ts | 16 +-- .../main/agent-actions/handlers/types.ts | 4 +- .../main/agent-actions/register-actions.ts | 4 +- src/electron/main/audit/audit-db.test.ts | 56 ++++++++ src/electron/main/audit/audit-db.ts | 132 ++++++++++++++++++ src/electron/main/ipc/audit-ipc.ts | 6 +- .../main/ipc/register-main-ipc-handlers.ts | 4 +- .../main/runtime/runtime-bootstrap.ts | 4 +- .../src/electron/main/audit/audit-db.test.ts | 56 ++++++++ 12 files changed, 270 insertions(+), 31 deletions(-) create mode 100644 src/electron/main/audit/audit-db.test.ts create mode 100644 src/electron/main/audit/audit-db.ts create mode 100644 tests/unit/src/electron/main/audit/audit-db.test.ts diff --git a/package.json b/package.json index 8c289ac..21a32fe 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "@vitejs/plugin-react": "^4.7.0", "@vitest/coverage-v8": "^4.1.1", "autoprefixer": "^10.4.21", - "better-sqlite3": "^12.8.0", "drizzle-kit": "^0.31.10", "electron": "41.0.3", "eslint": "^8.57.1", @@ -86,6 +85,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", + "better-sqlite3": "^12.8.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e53d482..47a37ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + better-sqlite3: + specifier: ^12.8.0 + version: 12.8.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -172,9 +175,6 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.27(postcss@8.5.8) - better-sqlite3: - specifier: ^12.8.0 - version: 12.8.0 drizzle-kit: specifier: ^0.31.10 version: 0.31.10 diff --git a/src/electron/main.ts b/src/electron/main.ts index d66f33a..4edebbf 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -10,11 +10,7 @@ import path from 'node:path'; import started from 'electron-squirrel-startup'; import { createQuitCoordinator } from '@/electron/main/quit-coordinator'; -import { AuditLog } from '@/electron/main/audit/audit-log'; -import { - openDuneDatabase, - resolveDuneDatabasePath, -} from '@/electron/main/db'; +import { AuditDatabase } from '@/electron/main/audit/audit-db'; import { resolveAgentLiteRuntimeRoot } from '@/electron/main/dune-paths'; import { registerMainIpcHandlers } from '@/electron/main/ipc/register-main-ipc-handlers'; import { createTelegramPowerCoordinator } from '@/electron/main/lifecycle/telegram-power-coordinator'; @@ -74,8 +70,7 @@ void app.whenReady().then(async () => { const agentLiteHomeDir = process.env.DUNE_AGENTLITE_HOME_DIR; const agentLiteRuntimeRoot = resolveAgentLiteRuntimeRoot(agentLiteHomeDir); const userDataDir = app.getPath('userData'); - const sqlite = openDuneDatabase(resolveDuneDatabasePath(userDataDir)); - const auditLog = new AuditLog(sqlite); + const auditLog = new AuditDatabase(userDataDir); const stores = { agents: new JsonFileStorage(userDataDir, 'agents'), secrets: new EncryptedFileStorage(userDataDir, 'secrets'), @@ -142,7 +137,7 @@ void app.whenReady().then(async () => { workflowCoordinator.stop(); telegramPowerCoordinator.shutdown(); await runtimeBootstrap?.shutdown(); - sqlite.close(); + auditLog.close(); }; const applyPersistedNetworkSettings = async () => { diff --git a/src/electron/main/agent-actions/handlers/items.ts b/src/electron/main/agent-actions/handlers/items.ts index 509c780..5d6c0e3 100644 --- a/src/electron/main/agent-actions/handlers/items.ts +++ b/src/electron/main/agent-actions/handlers/items.ts @@ -136,7 +136,7 @@ export const itemTools: RegisteredTool[] = [ itemId, itemTitle: title, projectId, - summary: `Created work item "${title}".`, + summary: `"${title}" created`, details: { status }, }); return { item: presentItem(snapshot, item), itemId }; @@ -234,7 +234,7 @@ export const itemTools: RegisteredTool[] = [ itemId: item.id, itemTitle: item.title, projectId: item.projectId, - summary: `Updated work item "${item.title}".`, + summary: `"${item.title}" updated`, details: { briefChanged: previousBrief !== item.brief, titleChanged: previousTitle !== item.title, @@ -251,8 +251,8 @@ export const itemTools: RegisteredTool[] = [ itemTitle: item.title, projectId: item.projectId, summary: item.primaryAgentId - ? `Assigned primary agent to "${item.title}".` - : `Cleared primary agent for "${item.title}".`, + ? `Agent assigned to "${item.title}"` + : `Agent cleared for "${item.title}"`, details: { primaryAgentId: item.primaryAgentId }, }); } @@ -335,10 +335,10 @@ export const itemTools: RegisteredTool[] = [ itemId: item.id, itemTitle: item.title, projectId: item.projectId, - summary: `Moved "${item.title}" from ${previousStatus} to ${status}.`, + summary: `"${item.title}" moved from ${previousStatus} to ${status}`, details: { - fromStatus: previousStatus, - toStatus: status, + from: previousStatus, + to: status, }, }); return { item: presentItem(snapshot, item) }; @@ -374,7 +374,7 @@ export const itemTools: RegisteredTool[] = [ itemId: item.id, itemTitle: item.title, projectId: item.projectId, - summary: `Added feedback to "${item.title}".`, + summary: `Feedback added to "${item.title}"`, details: { feedback }, }); return { item: presentItem(snapshot, item) }; diff --git a/src/electron/main/agent-actions/handlers/types.ts b/src/electron/main/agent-actions/handlers/types.ts index 9f4b992..019006b 100644 --- a/src/electron/main/agent-actions/handlers/types.ts +++ b/src/electron/main/agent-actions/handlers/types.ts @@ -2,7 +2,7 @@ import type { AppStorage } from '@/electron/main/storage/app-storage'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditDatabase } from '@/electron/main/audit/audit-db'; /** MCP-style tool definition. Used internally by RegisteredTool. */ export interface ToolDefinition { @@ -22,7 +22,7 @@ export interface ToolHandlerContext { /** Tool handler options. */ export interface ToolHandlerOptions { - auditLog?: AuditLog; + auditLog?: AuditDatabase; getRuntimeController: () => DesktopRuntimeController; onWorkflowChanged: () => void; workflowStore: AppStorage; diff --git a/src/electron/main/agent-actions/register-actions.ts b/src/electron/main/agent-actions/register-actions.ts index 5c39af9..873b49d 100644 --- a/src/electron/main/agent-actions/register-actions.ts +++ b/src/electron/main/agent-actions/register-actions.ts @@ -16,7 +16,7 @@ import type { Agent as AgentLiteAgent } from '@boxlite-ai/agentlite'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage/app-storage'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditDatabase } from '@/electron/main/audit/audit-db'; import { agentTools } from '@/electron/main/agent-actions/handlers/agents'; import { itemTools } from '@/electron/main/agent-actions/handlers/items'; @@ -32,7 +32,7 @@ import type { /** Services captured by every action handler closure. */ export interface ActionHostServices { - auditLog?: AuditLog; + auditLog?: AuditDatabase; getRuntimeController: () => DesktopRuntimeController; onWorkflowChanged: () => void; workflowStore: AppStorage; diff --git a/src/electron/main/audit/audit-db.test.ts b/src/electron/main/audit/audit-db.test.ts new file mode 100644 index 0000000..f65b0ce --- /dev/null +++ b/src/electron/main/audit/audit-db.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AuditDatabase } from './audit-db'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; + +describe('AuditDatabase', () => { + let tmpDir: string; + let auditDb: AuditDatabase; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-test-')); + auditDb = new AuditDatabase(tmpDir); + }); + + afterEach(() => { + auditDb.close(); + fs.rmSync(tmpDir, { recursive: true }); + }); + + it('records and queries events', () => { + auditDb.record({ + actor: 'test-agent', + actorType: 'agent', + eventType: 'item.created', + projectId: 'proj-1', + itemId: 'item-1', + itemTitle: 'Test Item', + summary: 'Test item created', + }); + + const { rows, total } = auditDb.query({ projectId: 'proj-1' }); + expect(total).toBe(1); + expect(rows[0]).toMatchObject({ + actor: 'test-agent', + event_type: 'item.created', + }); + }); + + it('filters by event_type', () => { + auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's' }); + auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.moved', projectId: 'p', summary: 's' }); + + const { rows } = auditDb.query({ projectId: 'p', eventType: 'item.created' }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ event_type: 'item.created' }); + }); + + it('exports valid CSV', () => { + auditDb.record({ actor: 'user', actorType: 'user', eventType: 'item.moved', projectId: 'p', summary: 'moved' }); + const csv = auditDb.exportCsv({ projectId: 'p' }); + const lines = csv.split('\n'); + expect(lines[0]).toContain('timestamp'); + expect(lines.length).toBe(2); + }); +}); diff --git a/src/electron/main/audit/audit-db.ts b/src/electron/main/audit/audit-db.ts new file mode 100644 index 0000000..c30b349 --- /dev/null +++ b/src/electron/main/audit/audit-db.ts @@ -0,0 +1,132 @@ +import Database from 'better-sqlite3'; +import path from 'node:path'; + +import type { AuditEventRow, AuditEventType, QueryAuditParams } from '@/shared/audit-log'; + +export type { AuditEventRow, AuditEventType, QueryAuditParams } from '@/shared/audit-log'; + +export interface RecordEventParams { + ts?: number; + actor: string; + actorType: 'agent' | 'user' | 'system'; + eventType: AuditEventType; + itemId?: string | null; + itemTitle?: string | null; + projectId: string; + summary: string; + details?: Record | null; +} + +export class AuditDatabase { + private db: InstanceType; + private insertStmt: ReturnType['prepare']>; + + constructor(userDataDir: string) { + this.db = new Database(path.join(userDataDir, 'audit.db')); + this.db.pragma('journal_mode = WAL'); + this.db.exec(` + CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + actor TEXT NOT NULL, + actor_type TEXT NOT NULL, + event_type TEXT NOT NULL, + item_id TEXT, + item_title TEXT, + project_id TEXT NOT NULL, + summary TEXT NOT NULL, + details TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(ts); + CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_events(event_type); + CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor); + CREATE INDEX IF NOT EXISTS idx_audit_item_id ON audit_events(item_id); + CREATE INDEX IF NOT EXISTS idx_audit_project_id ON audit_events(project_id); + `); + this.insertStmt = this.db.prepare(` + INSERT INTO audit_events + (ts, actor, actor_type, event_type, item_id, item_title, project_id, summary, details) + VALUES + (@ts, @actor, @actorType, @eventType, @itemId, @itemTitle, @projectId, @summary, @details) + `); + } + + record(params: RecordEventParams): void { + this.insertStmt.run({ + ts: params.ts ?? Date.now(), + actor: params.actor, + actorType: params.actorType, + eventType: params.eventType, + itemId: params.itemId ?? null, + itemTitle: params.itemTitle ?? null, + projectId: params.projectId, + summary: params.summary, + details: params.details ? JSON.stringify(params.details) : null, + }); + } + + query(params: QueryAuditParams): { rows: AuditEventRow[]; total: number } { + const conditions: string[] = ['project_id = @projectId']; + const bindings: Record = { projectId: params.projectId }; + + if (params.since) { + conditions.push('ts >= @since'); + bindings.since = params.since; + } + if (params.until) { + conditions.push('ts <= @until'); + bindings.until = params.until; + } + if (params.eventType) { + conditions.push('event_type = @eventType'); + bindings.eventType = params.eventType; + } + if (params.actor) { + conditions.push('actor = @actor'); + bindings.actor = params.actor; + } + if (params.itemId) { + conditions.push('item_id = @itemId'); + bindings.itemId = params.itemId; + } + + const where = conditions.join(' AND '); + const limit = Math.min(params.limit ?? 100, 500); + const offset = params.offset ?? 0; + + const rows = this.db.prepare( + `SELECT * FROM audit_events WHERE ${where} ORDER BY ts DESC LIMIT @limit OFFSET @offset`, + ).all({ ...bindings, limit, offset }) as AuditEventRow[]; + + const { count } = this.db.prepare( + `SELECT COUNT(*) as count FROM audit_events WHERE ${where}`, + ).get(bindings) as { count: number }; + + return { rows, total: count }; + } + + exportCsv(params: Omit): string { + const { rows } = this.query({ ...params, limit: 10_000, offset: 0 }); + const headers = ['id', 'timestamp', 'actor', 'actor_type', 'event_type', 'item_id', 'item_title', 'project_id', 'summary', 'details']; + const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`; + return [ + headers.join(','), + ...rows.map((r) => [ + r.id, + new Date(r.ts).toISOString(), + esc(r.actor), + esc(r.actor_type), + esc(r.event_type), + esc(r.item_id), + esc(r.item_title), + esc(r.project_id), + esc(r.summary), + esc(r.details), + ].join(',')), + ].join('\n'); + } + + close(): void { + this.db.close(); + } +} diff --git a/src/electron/main/ipc/audit-ipc.ts b/src/electron/main/ipc/audit-ipc.ts index 8181a50..6c97926 100644 --- a/src/electron/main/ipc/audit-ipc.ts +++ b/src/electron/main/ipc/audit-ipc.ts @@ -1,13 +1,13 @@ -import type { AuditEvent, QueryAuditParams } from '@/shared/audit-log'; import { ipcChannels } from '@/shared/electron/ipc-channels'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditEvent, QueryAuditParams } from '@/shared/audit-log'; +import type { AuditDatabase } from '@/electron/main/audit/audit-db'; interface IpcMainLike { handle(channel: string, listener: (...args: any[]) => any): void; } interface RegisterAuditIpcHandlersOptions { - auditLog?: AuditLog | undefined; + auditLog?: AuditDatabase | undefined; ipcMain: IpcMainLike; } diff --git a/src/electron/main/ipc/register-main-ipc-handlers.ts b/src/electron/main/ipc/register-main-ipc-handlers.ts index c87d993..3f11c3a 100644 --- a/src/electron/main/ipc/register-main-ipc-handlers.ts +++ b/src/electron/main/ipc/register-main-ipc-handlers.ts @@ -11,7 +11,7 @@ import type { OpenDialogOptions } from 'electron'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditDatabase } from '@/electron/main/audit/audit-db'; import { registerAuditIpcHandlers } from '@/electron/main/ipc/audit-ipc'; import { assertEmptyProjectRootDirectory, @@ -51,7 +51,7 @@ interface RegisterMainIpcHandlersOptions { deleteLocalData: () => Promise; dialog?: DialogLike; ensureRuntime: () => Promise; - auditLog?: AuditLog; + auditLog?: AuditDatabase; getFocusedWindow?: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null; getProjectActivityPage: ( diff --git a/src/electron/main/runtime/runtime-bootstrap.ts b/src/electron/main/runtime/runtime-bootstrap.ts index b458467..56d7fc9 100644 --- a/src/electron/main/runtime/runtime-bootstrap.ts +++ b/src/electron/main/runtime/runtime-bootstrap.ts @@ -5,14 +5,14 @@ import type { App } from 'electron'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AppStorage } from '@/electron/main/storage'; -import type { AuditLog } from '@/electron/main/audit/audit-log'; +import type { AuditDatabase } from '@/electron/main/audit/audit-db'; import type { AgentServiceSnapshot } from '@/shared/agents/agent-runtime'; interface RuntimeBootstrapOptions { agentLiteHomeDir?: string; agentStore: AppStorage; app: Pick; - auditLog?: AuditLog; + auditLog?: AuditDatabase; onAgentIdle: (agentId: string) => void; onItemActivityChanged: (payload: { isWorking: boolean; itemId: string }) => void; onRuntimeSnapshot: (snapshot: AgentServiceSnapshot) => void; diff --git a/tests/unit/src/electron/main/audit/audit-db.test.ts b/tests/unit/src/electron/main/audit/audit-db.test.ts new file mode 100644 index 0000000..7c545f8 --- /dev/null +++ b/tests/unit/src/electron/main/audit/audit-db.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AuditDatabase } from '@/electron/main/audit/audit-db'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; + +describe('AuditDatabase', () => { + let tmpDir: string; + let auditDb: AuditDatabase; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-test-')); + auditDb = new AuditDatabase(tmpDir); + }); + + afterEach(() => { + auditDb.close(); + fs.rmSync(tmpDir, { recursive: true }); + }); + + it('records and queries events', () => { + auditDb.record({ + actor: 'test-agent', + actorType: 'agent', + eventType: 'item.created', + projectId: 'proj-1', + itemId: 'item-1', + itemTitle: 'Test Item', + summary: 'Test item created', + }); + + const { rows, total } = auditDb.query({ projectId: 'proj-1' }); + expect(total).toBe(1); + expect(rows[0]).toMatchObject({ + actor: 'test-agent', + event_type: 'item.created', + }); + }); + + it('filters by event_type', () => { + auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.created', projectId: 'p', summary: 's' }); + auditDb.record({ actor: 'a', actorType: 'agent', eventType: 'item.moved', projectId: 'p', summary: 's' }); + + const { rows } = auditDb.query({ projectId: 'p', eventType: 'item.created' }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ event_type: 'item.created' }); + }); + + it('exports valid CSV', () => { + auditDb.record({ actor: 'user', actorType: 'user', eventType: 'item.moved', projectId: 'p', summary: 'moved' }); + const csv = auditDb.exportCsv({ projectId: 'p' }); + const lines = csv.split('\n'); + expect(lines[0]).toContain('timestamp'); + expect(lines.length).toBe(2); + }); +});