feat(FN-1403): add run audit system with SQLite persistence

- Add run-audit event types and SQLite schema migration
- Implement typed store read/write/query APIs for run audit
- Make audit writes atomic with task updates (transactional writes)
- Fix SQLite parameter type casting in db layer
- Add comprehensive tests for RunMutationContext and audit functionality
This commit is contained in:
gsxdsm
2026-04-09 16:39:16 -07:00
parent 4d8da65cb1
commit bc9e919a06
8 changed files with 847 additions and 28 deletions

View File

@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, InboxTask, TaskLogEntry, RunMutationContext } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
@@ -537,6 +537,54 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await rename(tmpPath, taskJsonPath);
}
/**
* Write a task to SQLite and optionally record a run-audit event, all in a single
* SQLite transaction. If the audit insert fails, the task mutation is rolled back.
*
* @param dir - Task directory path
* @param task - Task to write
* @param auditInput - Optional audit event input to record atomically with the task write
*/
private async atomicWriteTaskJsonWithAudit(
dir: string,
task: Task,
auditInput?: RunAuditEventInput,
): Promise<void> {
this.db.transaction(() => {
// Upsert the task
this.upsertTask(task);
// Optionally record the audit event in the same transaction
if (auditInput) {
const eventId = randomUUID();
const timestamp = auditInput.timestamp ?? new Date().toISOString();
this.db.prepare(`
INSERT INTO runAuditEvents (
id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
eventId,
timestamp,
auditInput.taskId ?? null,
auditInput.agentId,
auditInput.runId,
auditInput.domain,
auditInput.mutationType,
auditInput.target,
toJsonNullable(auditInput.metadata),
);
}
});
// File writes are not part of the SQLite transaction
const taskJsonPath = join(dir, "task.json");
const tmpPath = join(dir, "task.json.tmp");
this.suppressWatcher(taskJsonPath);
await mkdir(dir, { recursive: true });
await writeFile(tmpPath, JSON.stringify(task, null, 2));
await rename(tmpPath, taskJsonPath);
}
/**
* Get merged settings: global defaults ← global user prefs ← project overrides.
*
@@ -1579,7 +1627,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
// When runContext is provided, record audit event atomically with task mutation
if (runContext) {
await this.atomicWriteTaskJsonWithAudit(dir, task, {
taskId: task.id,
agentId: runContext.agentId,
runId: runContext.runId,
domain: "database",
mutationType: "task:update",
target: task.id,
metadata: { updatedFields: Object.keys(updates).filter((k) => (updates as any)[k] !== undefined) },
});
} else {
await this.atomicWriteTaskJson(dir, task);
}
// Update cache if watcher is active
if (this.isWatching) this.taskCache.set(id, { ...task });
@@ -1648,7 +1709,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
task.log.push(logEntry);
await this.atomicWriteTaskJson(dir, task);
// When runContext is provided, record audit event atomically with task mutation
if (runContext) {
await this.atomicWriteTaskJsonWithAudit(dir, task, {
taskId: task.id,
agentId: runContext.agentId,
runId: runContext.runId,
domain: "database",
mutationType: paused ? "task:pause" : "task:unpause",
target: task.id,
});
} else {
await this.atomicWriteTaskJson(dir, task);
}
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
@@ -1737,7 +1810,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.log.push(entry);
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
// When runContext is provided, record audit event atomically with task mutation
if (runContext) {
await this.atomicWriteTaskJsonWithAudit(dir, task, {
taskId: task.id,
agentId: runContext.agentId,
runId: runContext.runId,
domain: "database",
mutationType: "task:log",
target: task.id,
metadata: { action, outcome },
});
} else {
await this.atomicWriteTaskJson(dir, task);
}
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
@@ -1764,6 +1850,143 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return mutations.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
}
// ── Run Audit APIs ───────────────────────────────────────────────────
/**
* Convert a database row to a RunAuditEvent object.
*/
private rowToRunAuditEvent(row: any): RunAuditEvent {
return {
id: row.id,
timestamp: row.timestamp,
taskId: row.taskId || undefined,
agentId: row.agentId,
runId: row.runId,
domain: row.domain as RunAuditEvent["domain"],
mutationType: row.mutationType,
target: row.target,
metadata: fromJson<Record<string, unknown>>(row.metadata),
};
}
/**
* Record a run-audit event.
*
* Persists a structured audit trail entry correlating a mutation to the
* heartbeat run that caused it. Use this to track database mutations,
* git operations, and filesystem changes initiated by agent runs.
*
* @param input - The audit event input (runId, agentId, domain, mutationType, target, optional metadata)
* @returns The persisted RunAuditEvent with generated id and timestamp
*/
recordRunAuditEvent(input: RunAuditEventInput): RunAuditEvent {
const id = randomUUID();
const timestamp = input.timestamp ?? new Date().toISOString();
const event: RunAuditEvent = {
id,
timestamp,
taskId: input.taskId,
agentId: input.agentId,
runId: input.runId,
domain: input.domain,
mutationType: input.mutationType,
target: input.target,
metadata: input.metadata,
};
this.db.prepare(`
INSERT INTO runAuditEvents (
id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
event.id,
event.timestamp,
event.taskId ?? null,
event.agentId,
event.runId,
event.domain,
event.mutationType,
event.target,
toJsonNullable(event.metadata),
);
return event;
}
/**
* Query run-audit events with optional filters.
*
* @param options - Filter options (runId, taskId, startTime, endTime, domain, mutationType, limit)
* @returns Array of matching RunAuditEvent records, ordered by timestamp DESC, rowid DESC
*
* @remarks
* Time-range filtering uses **inclusive bounds**: `timestamp >= startTime` and `timestamp <= endTime`.
* When no time range is specified, all matching records are returned.
*
* Query results are ordered by timestamp descending with a stable rowid tiebreaker:
* `ORDER BY timestamp DESC, rowid DESC`. This ensures deterministic ordering
* when multiple events share the same millisecond timestamp.
*/
getRunAuditEvents(options: RunAuditEventFilter = {}): RunAuditEvent[] {
const conditions: string[] = [];
const params: unknown[] = [];
if (options.runId) {
conditions.push("runId = ?");
params.push(options.runId);
}
if (options.taskId) {
conditions.push("taskId = ?");
params.push(options.taskId);
}
if (options.agentId) {
conditions.push("agentId = ?");
params.push(options.agentId);
}
if (options.domain) {
conditions.push("domain = ?");
params.push(options.domain);
}
if (options.mutationType) {
conditions.push("mutationType = ?");
params.push(options.mutationType);
}
// Inclusive time range: timestamp >= startTime AND timestamp <= endTime
if (options.startTime) {
conditions.push("timestamp >= ?");
params.push(options.startTime);
}
if (options.endTime) {
conditions.push("timestamp <= ?");
params.push(options.endTime);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limitClause = options.limit ? `LIMIT ${Math.max(1, options.limit)}` : "";
const orderClause = "ORDER BY timestamp DESC, rowid DESC";
// Cast params to the expected SQLite input type
const sqlParams = params as (string | number | null)[];
const rows = this.db.prepare(`
SELECT * FROM runAuditEvents
${whereClause}
${orderClause}
${limitClause}
`).all(...sqlParams) as any[];
return rows.map((row) => this.rowToRunAuditEvent(row));
}
// ── End Run Audit APIs ───────────────────────────────────────────────
/**
* Sync steps from PROMPT.md into task.json (called when steps are empty).
*/
@@ -2741,7 +2964,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
task.log.push(logEntry);
await this.atomicWriteTaskJson(dir, task);
// When runContext is provided, record audit event atomically with task mutation
if (runContext) {
await this.atomicWriteTaskJsonWithAudit(dir, task, {
taskId: task.id,
agentId: runContext.agentId,
runId: runContext.runId,
domain: "database",
mutationType: "task:comment",
target: task.id,
metadata: { author, commentId },
});
} else {
await this.atomicWriteTaskJson(dir, task);
}
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);