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:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
471
packages/core/src/run-audit.test.ts
Normal file
471
packages/core/src/run-audit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user