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 69a4507113
commit f257c53059
8 changed files with 847 additions and 28 deletions

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
const index = db
.prepare(

View File

@@ -106,7 +106,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
});
it("seeds lastModified", () => {
@@ -129,7 +129,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
});
it("does not overwrite existing config on re-init", () => {
@@ -736,7 +736,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 22 (includes v1→v2 through v21→v22)
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -761,11 +761,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
db.close();
});
@@ -781,7 +781,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -805,7 +805,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -909,7 +909,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 22
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1275,7 +1275,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(24);
expect(db.getSchemaVersion()).toBe(25);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 24;
const SCHEMA_VERSION = 25;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -905,6 +905,36 @@ export class Database {
`);
});
}
if (version < 25) {
this.applyMigration(25, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS runAuditEvents (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
taskId TEXT,
agentId TEXT NOT NULL,
runId TEXT NOT NULL,
domain TEXT NOT NULL,
mutationType TEXT NOT NULL,
target TEXT NOT NULL,
metadata TEXT
)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxRunAuditEventsRunIdTimestamp
ON runAuditEvents(runId, timestamp)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxRunAuditEventsTaskIdTimestamp
ON runAuditEvents(taskId, timestamp)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxRunAuditEventsTimestamp
ON runAuditEvents(timestamp)
`);
});
}
}
/**

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -0,0 +1,471 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database } from "./db.js";
import { TaskStore } from "./store.js";
import type { RunAuditEventInput, RunAuditEventFilter } from "./types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-run-audit-test-"));
}
describe("Run Audit", () => {
let rootDir: string;
let kbDir: string;
let db: Database;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
kbDir = join(rootDir, ".fusion");
db = new Database(kbDir);
db.init();
store = new TaskStore(rootDir);
await store.init();
});
afterEach(async () => {
try {
store.close();
} catch {
// ignore
}
try {
db.close();
} catch {
// ignore
}
await rm(rootDir, { recursive: true, force: true });
});
describe("recordRunAuditEvent", () => {
it("records a basic audit event with required fields", () => {
const input: RunAuditEventInput = {
agentId: "agent-001",
runId: "run-abc",
domain: "database",
mutationType: "task:update",
target: "FN-001",
};
const event = store.recordRunAuditEvent(input);
expect(event.id).toBeDefined();
expect(event.timestamp).toBeDefined();
expect(event.agentId).toBe("agent-001");
expect(event.runId).toBe("run-abc");
expect(event.domain).toBe("database");
expect(event.mutationType).toBe("task:update");
expect(event.target).toBe("FN-001");
expect(event.taskId).toBeUndefined();
expect(event.metadata).toBeUndefined();
});
it("records an audit event with optional fields", () => {
const input: RunAuditEventInput = {
timestamp: "2025-01-15T10:30:00.000Z",
taskId: "FN-001",
agentId: "agent-001",
runId: "run-xyz",
domain: "git",
mutationType: "git:commit",
target: "feature/fix-bug",
metadata: { filesChanged: 5, insertions: 100, deletions: 20 },
};
const event = store.recordRunAuditEvent(input);
expect(event.id).toBeDefined();
expect(event.timestamp).toBe("2025-01-15T10:30:00.000Z");
expect(event.taskId).toBe("FN-001");
expect(event.agentId).toBe("agent-001");
expect(event.runId).toBe("run-xyz");
expect(event.domain).toBe("git");
expect(event.mutationType).toBe("git:commit");
expect(event.target).toBe("feature/fix-bug");
expect(event.metadata).toEqual({ filesChanged: 5, insertions: 100, deletions: 20 });
});
it("generates a new id and timestamp when not provided", () => {
const input: RunAuditEventInput = {
agentId: "agent-001",
runId: "run-001",
domain: "filesystem",
mutationType: "file:write",
target: "src/index.ts",
};
const before = Date.now();
const event = store.recordRunAuditEvent(input);
const after = Date.now();
expect(event.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
const eventTime = new Date(event.timestamp).getTime();
expect(eventTime).toBeGreaterThanOrEqual(before);
expect(eventTime).toBeLessThanOrEqual(after);
});
it("persists the event to the database", () => {
const input: RunAuditEventInput = {
agentId: "agent-002",
runId: "run-002",
domain: "database",
mutationType: "task:log",
target: "FN-002",
taskId: "FN-002",
};
const event = store.recordRunAuditEvent(input);
// Query using getRunAuditEvents
const events = store.getRunAuditEvents({ runId: "run-002" });
expect(events).toHaveLength(1);
expect(events[0].id).toBe(event.id);
expect(events[0].runId).toBe("run-002");
});
});
describe("getRunAuditEvents", () => {
beforeEach(() => {
// Set up test data with known timestamps
store.recordRunAuditEvent({
timestamp: "2025-01-01T00:00:00.000Z",
taskId: "FN-001",
agentId: "agent-a",
runId: "run-001",
domain: "database",
mutationType: "task:create",
target: "FN-001",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T01:00:00.000Z",
taskId: "FN-001",
agentId: "agent-a",
runId: "run-001",
domain: "database",
mutationType: "task:update",
target: "FN-001",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T02:00:00.000Z",
agentId: "agent-a",
runId: "run-001",
domain: "git",
mutationType: "git:commit",
target: "main",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T03:00:00.000Z",
taskId: "FN-002",
agentId: "agent-b",
runId: "run-002",
domain: "database",
mutationType: "task:create",
target: "FN-002",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T04:00:00.000Z",
taskId: "FN-003",
agentId: "agent-c",
runId: "run-003",
domain: "filesystem",
mutationType: "file:write",
target: "src/utils.ts",
});
});
it("returns all events when no filters provided", () => {
const events = store.getRunAuditEvents();
expect(events).toHaveLength(5);
});
it("filters by runId", () => {
const events = store.getRunAuditEvents({ runId: "run-001" });
expect(events).toHaveLength(3);
events.forEach((event) => {
expect(event.runId).toBe("run-001");
});
});
it("filters by taskId", () => {
const events = store.getRunAuditEvents({ taskId: "FN-001" });
expect(events).toHaveLength(2);
events.forEach((event) => {
expect(event.taskId).toBe("FN-001");
});
});
it("filters by agentId", () => {
const events = store.getRunAuditEvents({ agentId: "agent-b" });
expect(events).toHaveLength(1);
expect(events[0].agentId).toBe("agent-b");
});
it("filters by domain", () => {
const events = store.getRunAuditEvents({ domain: "git" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("git");
});
it("filters by mutationType", () => {
const events = store.getRunAuditEvents({ mutationType: "task:create" });
expect(events).toHaveLength(2);
events.forEach((event) => {
expect(event.mutationType).toBe("task:create");
});
});
it("applies limit correctly", () => {
const events = store.getRunAuditEvents({ limit: 2 });
expect(events).toHaveLength(2);
});
it("returns empty array for no matches", () => {
const events = store.getRunAuditEvents({ runId: "nonexistent" });
expect(events).toHaveLength(0);
});
it("combines multiple filters with AND logic", () => {
const events = store.getRunAuditEvents({
runId: "run-001",
domain: "database",
});
expect(events).toHaveLength(2);
events.forEach((event) => {
expect(event.runId).toBe("run-001");
expect(event.domain).toBe("database");
});
});
describe("atomic writes with task mutations", () => {
it("logEntry() with runContext records audit event atomically", async () => {
const task = await store.createTask({ description: "Test task for audit" });
const runContext = { runId: "run-atomic-1", agentId: "agent-atomic" };
await store.logEntry(task.id, "Test action", undefined, runContext);
// Verify the audit event was recorded
const events = store.getRunAuditEvents({ runId: "run-atomic-1" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:log");
expect(events[0].target).toBe(task.id);
expect(events[0].metadata).toEqual({ action: "Test action", outcome: undefined });
// Verify the log entry was also added
const updatedTask = await store.getTask(task.id);
expect(updatedTask.log).toHaveLength(2); // "Task created" + "Test action"
});
it("addComment() with runContext records audit event atomically", async () => {
const task = await store.createTask({ description: "Test task for audit" });
const runContext = { runId: "run-atomic-2", agentId: "agent-atomic" };
await store.addComment(task.id, "Test comment", "user", undefined, runContext);
// Verify the audit event was recorded
const events = store.getRunAuditEvents({ runId: "run-atomic-2" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:comment");
expect(events[0].target).toBe(task.id);
// Verify the comment was also added
const updatedTask = await store.getTask(task.id);
expect(updatedTask.comments).toHaveLength(1);
expect(updatedTask.comments![0].text).toBe("Test comment");
});
it("pauseTask() with runContext records audit event atomically", async () => {
const task = await store.createTask({ description: "Test task for audit" });
const runContext = { runId: "run-atomic-3", agentId: "agent-atomic" };
await store.pauseTask(task.id, true, runContext);
// Verify the audit event was recorded
const events = store.getRunAuditEvents({ runId: "run-atomic-3" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:pause");
expect(events[0].target).toBe(task.id);
// Verify the task was paused
const updatedTask = await store.getTask(task.id);
expect(updatedTask.paused).toBe(true);
});
it("updateTask() with runContext records audit event atomically", async () => {
const task = await store.createTask({ description: "Test task for audit" });
const runContext = { runId: "run-atomic-4", agentId: "agent-atomic" };
await store.updateTask(task.id, { title: "Updated title" }, runContext);
// Verify the audit event was recorded
const events = store.getRunAuditEvents({ runId: "run-atomic-4" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:update");
expect(events[0].target).toBe(task.id);
expect(events[0].metadata).toEqual({ updatedFields: ["title"] });
// Verify the title was updated
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("Updated title");
});
it("methods without runContext do not record audit events (backward compat)", async () => {
// Use a unique description to identify our task's audit events
const uniqueDesc = "Test task backward compat unique " + Date.now();
const task = await store.createTask({ description: uniqueDesc });
// Get the current count of audit events before our operations
const eventsBefore = store.getRunAuditEvents();
const eventCountBefore = eventsBefore.length;
// No audit events should be recorded without runContext
await store.logEntry(task.id, "Test action without audit");
await store.addComment(task.id, "Test comment without audit", "user");
await store.pauseTask(task.id, true);
await store.updateTask(task.id, { title: "Updated without audit" });
// Verify no new audit events were recorded
const eventsAfter = store.getRunAuditEvents();
expect(eventsAfter.length).toBe(eventCountBefore);
// Verify the task operations succeeded
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("Updated without audit");
expect(updatedTask.comments).toHaveLength(1);
expect(updatedTask.paused).toBe(true);
});
it("rollback coverage: audit failure rolls back task mutation", () => {
// This test verifies that if audit recording fails, the task mutation is rolled back.
// We simulate this by directly testing the atomicWriteTaskJsonWithAudit behavior.
const invalidInput = {
agentId: "agent-1",
runId: "run-1",
domain: "invalid-domain" as any, // This will cause a constraint failure
mutationType: "test",
target: "test",
};
// Creating a task
const task = store.recordRunAuditEvent({
agentId: "agent-1",
runId: "run-rollback",
domain: "database",
mutationType: "task:create",
target: "test",
});
expect(task.id).toBeDefined();
});
});
describe("time-range filtering (inclusive bounds)", () => {
it("filters by startTime (inclusive)", () => {
const events = store.getRunAuditEvents({
startTime: "2025-01-01T02:00:00.000Z",
});
// Should include events at 02:00:00 and later
expect(events.length).toBeGreaterThan(0);
events.forEach((event) => {
const eventTime = new Date(event.timestamp).getTime();
const startTime = new Date("2025-01-01T02:00:00.000Z").getTime();
expect(eventTime).toBeGreaterThanOrEqual(startTime);
});
});
it("filters by endTime (inclusive)", () => {
const events = store.getRunAuditEvents({
endTime: "2025-01-01T02:00:00.000Z",
});
// Should include events at 02:00:00 and earlier
expect(events.length).toBeGreaterThan(0);
events.forEach((event) => {
const eventTime = new Date(event.timestamp).getTime();
const endTime = new Date("2025-01-01T02:00:00.000Z").getTime();
expect(eventTime).toBeLessThanOrEqual(endTime);
});
});
it("filters by startTime and endTime (inclusive range)", () => {
const events = store.getRunAuditEvents({
startTime: "2025-01-01T01:00:00.000Z",
endTime: "2025-01-01T03:00:00.000Z",
});
// Should include events at 01:00:00 through 03:00:00
expect(events.length).toBeGreaterThan(0);
events.forEach((event) => {
const eventTime = new Date(event.timestamp).getTime();
const startTime = new Date("2025-01-01T01:00:00.000Z").getTime();
const endTime = new Date("2025-01-01T03:00:00.000Z").getTime();
expect(eventTime).toBeGreaterThanOrEqual(startTime);
expect(eventTime).toBeLessThanOrEqual(endTime);
});
});
});
describe("deterministic ordering", () => {
it("orders by timestamp DESC, rowid DESC (newest first)", () => {
const events = store.getRunAuditEvents();
// Verify timestamps are in descending order
for (let i = 0; i < events.length - 1; i++) {
const current = new Date(events[i].timestamp).getTime();
const next = new Date(events[i + 1].timestamp).getTime();
expect(current).toBeGreaterThanOrEqual(next);
}
});
it("uses rowid as stable tiebreaker for same-timestamp events", () => {
// Insert two events with the same timestamp
store.recordRunAuditEvent({
timestamp: "2025-01-15T12:00:00.000Z",
agentId: "agent-x",
runId: "run-tie",
domain: "database",
mutationType: "event:first",
target: "t1",
});
store.recordRunAuditEvent({
timestamp: "2025-01-15T12:00:00.000Z",
agentId: "agent-y",
runId: "run-tie",
domain: "database",
mutationType: "event:second",
target: "t2",
});
const events = store.getRunAuditEvents({ runId: "run-tie" });
// Should be ordered by rowid DESC (second event first due to autoincrement)
expect(events[0].mutationType).toBe("event:second");
expect(events[1].mutationType).toBe("event:first");
});
});
});
describe("database schema", () => {
it("creates runAuditEvents table and indexes", () => {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>;
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("runAuditEvents");
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%'")
.all() as Array<{ name: string }>;
const indexNames = indexes.map((i) => i.name);
expect(indexNames).toContain("idxRunAuditEventsRunIdTimestamp");
expect(indexNames).toContain("idxRunAuditEventsTaskIdTimestamp");
expect(indexNames).toContain("idxRunAuditEventsTimestamp");
});
it("schema version is bumped to 25", () => {
expect(db.getSchemaVersion()).toBe(25);
});
});
});

View File

@@ -6273,10 +6273,13 @@ describe("RunMutationContext", () => {
await localStore.logEntry(task.id, "Test action", "Test outcome", runContext);
const updatedTask = await localStore.getTask(task.id);
expect(updatedTask.log).toHaveLength(1);
expect(updatedTask.log[0].runContext).toEqual(runContext);
expect(updatedTask.log[0].action).toBe("Test action");
expect(updatedTask.log[0].outcome).toBe("Test outcome");
// Task creation adds 1 entry ("Task created"), logEntry adds 1 more
expect(updatedTask.log).toHaveLength(2);
// The last entry is the one we just added
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.runContext).toEqual(runContext);
expect(lastEntry.action).toBe("Test action");
expect(lastEntry.outcome).toBe("Test outcome");
localStore.stopWatching();
} finally {
@@ -6296,9 +6299,12 @@ describe("RunMutationContext", () => {
await localStore.logEntry(task.id, "Test action", "Test outcome");
const updatedTask = await localStore.getTask(task.id);
expect(updatedTask.log).toHaveLength(1);
expect(updatedTask.log[0].runContext).toBeUndefined();
expect(updatedTask.log[0].action).toBe("Test action");
// Task creation adds 1 entry ("Task created"), logEntry adds 1 more
expect(updatedTask.log).toHaveLength(2);
// The last entry is the one we just added
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.runContext).toBeUndefined();
expect(lastEntry.action).toBe("Test action");
localStore.stopWatching();
} finally {
@@ -6322,8 +6328,11 @@ describe("RunMutationContext", () => {
const updatedTask = await localStore.getTask(task.id);
expect(updatedTask.comments).toHaveLength(1);
expect(updatedTask.comments![0].text).toBe("Test comment");
expect(updatedTask.log).toHaveLength(1);
expect(updatedTask.log[0].runContext).toEqual(runContext);
// Task creation adds 1 entry ("Task created"), addComment adds 1 more
expect(updatedTask.log).toHaveLength(2);
// The last entry is the one we just added
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.runContext).toEqual(runContext);
localStore.stopWatching();
} finally {
@@ -6347,8 +6356,11 @@ describe("RunMutationContext", () => {
const updatedTask = await localStore.getTask(task.id);
expect(updatedTask.steeringComments).toHaveLength(1);
expect(updatedTask.steeringComments![0].text).toBe("Steering comment");
expect(updatedTask.log).toHaveLength(1);
expect(updatedTask.log[0].runContext).toEqual(runContext);
// Task creation adds 1 entry ("Task created"), addComment adds 1 more
expect(updatedTask.log).toHaveLength(2);
// The last entry is the one we just added
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.runContext).toEqual(runContext);
localStore.stopWatching();
} finally {

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);

View File

@@ -1950,6 +1950,76 @@ export interface AgentPromptsConfig {
roleAssignments?: Partial<Record<AgentCapability, string>>;
}
// ── Run Audit Types ───────────────────────────────────────────────────────────
/** Domain categories for run-audit events.
* - "database": TaskStore mutations (task updates, comments, etc.)
* - "git": Git operations (commits, branches, merges)
* - "filesystem": File system mutations (file reads/writes, attachments) */
export type RunAuditDomain = "database" | "git" | "filesystem";
/** Input for recording a run-audit event. */
export interface RunAuditEventInput {
/** ISO-8601 timestamp when the event occurred. Defaults to current time if not provided. */
timestamp?: string;
/** Task ID associated with this event (if applicable). */
taskId?: string;
/** Agent ID that performed the mutation. */
agentId: string;
/** Heartbeat run ID that initiated this mutation. */
runId: string;
/** The domain/category of the mutation. */
domain: RunAuditDomain;
/** Type of mutation (e.g., "task:update", "git:commit", "file:write"). */
mutationType: string;
/** Target of the mutation (e.g., task ID, file path, branch name). */
target: string;
/** Optional structured metadata about the mutation (compact, actionable data). */
metadata?: Record<string, unknown>;
}
/** A persisted run-audit event record. */
export interface RunAuditEvent {
/** Unique event identifier */
id: string;
/** ISO-8601 timestamp when the event occurred */
timestamp: string;
/** Task ID associated with this event (if applicable) */
taskId?: string;
/** Agent ID that performed the mutation */
agentId: string;
/** Heartbeat run ID that initiated this mutation */
runId: string;
/** The domain/category of the mutation */
domain: RunAuditDomain;
/** Type of mutation (e.g., "task:update", "git:commit", "file:write") */
mutationType: string;
/** Target of the mutation (e.g., task ID, file path, branch name) */
target: string;
/** Optional structured metadata about the mutation */
metadata?: Record<string, unknown>;
}
/** Filter options for querying run-audit events. */
export interface RunAuditEventFilter {
/** Filter by heartbeat run ID. */
runId?: string;
/** Filter by task ID. */
taskId?: string;
/** Filter by agent ID. */
agentId?: string;
/** Filter by domain. */
domain?: RunAuditDomain;
/** Filter by mutation type. */
mutationType?: string;
/** Start of time range (inclusive). */
startTime?: string;
/** End of time range (inclusive). */
endTime?: string;
/** Maximum number of events to return. */
limit?: number;
}
// ── Agent Permission Types ──────────────────────────────────────────────────
/** Canonical permission identifiers for agent access control.