feat(FN-3546): add approval request persistence layer with SQLite store and

Merged branches introduce an approval-request persistence layer (FN-3546) with a new `ApprovalRequestStore` in core, schema v68 with audit history support, and updated dashboard task/API tests, alongside a minor task-schema extension (FN-3429) adding `branch`/`baseBranch` fields to the task model an

Fusion-Task-Id: FN-3546
This commit is contained in:
Fusion
2026-05-07 13:01:42 -07:00
committed by gsxdsm
parent dcead400c9
commit 3f7aedd4fd
13 changed files with 675 additions and 31 deletions

View File

@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database } from "../db.js";
import { ApprovalRequestStore } from "../approval-request-store.js";
import {
APPROVAL_REQUEST_AUDIT_EVENT_TYPES,
APPROVAL_REQUEST_STATUSES,
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
isValidApprovalRequestTransition,
type ApprovalRequest,
type ApprovalRequestActorSnapshot,
} from "../types.js";
const REQUESTER: ApprovalRequestActorSnapshot = {
actorId: "agent-1",
actorType: "agent",
actorName: "Executor",
};
const APPROVER: ApprovalRequestActorSnapshot = {
actorId: "user:dashboard",
actorType: "user",
actorName: "Dashboard User",
};
describe("approval request domain contract", () => {
it("exposes stable v1 status and audit-event vocabularies", () => {
expect(APPROVAL_REQUEST_STATUSES).toEqual(["pending", "approved", "denied", "completed"]);
expect(APPROVAL_REQUEST_AUDIT_EVENT_TYPES).toEqual(["created", "approved", "denied", "completed"]);
});
it("reuses shared action-category vocabulary for target actions", () => {
expect(AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.length).toBeGreaterThan(0);
});
it("enforces the lifecycle transition matrix", () => {
expect(isValidApprovalRequestTransition("pending", "approved")).toBe(true);
expect(isValidApprovalRequestTransition("pending", "denied")).toBe(true);
expect(isValidApprovalRequestTransition("approved", "completed")).toBe(true);
expect(isValidApprovalRequestTransition("pending", "completed")).toBe(false);
expect(isValidApprovalRequestTransition("approved", "denied")).toBe(false);
expect(isValidApprovalRequestTransition("denied", "approved")).toBe(false);
expect(isValidApprovalRequestTransition("denied", "completed")).toBe(false);
expect(isValidApprovalRequestTransition("completed", "approved")).toBe(false);
});
it("captures immutable actor snapshots and target-action context", () => {
const request: ApprovalRequest = {
id: "apr-001",
status: "pending",
requester: REQUESTER,
targetAction: {
category: AGENT_PERMISSION_POLICY_ACTION_CATEGORIES[0],
action: "git commit",
summary: "Create commit for task changes",
resourceType: "repository",
resourceId: "kb",
context: { taskId: "FN-3546" },
},
taskId: "FN-3546",
runId: "run-1",
requestedAt: "2026-05-05T00:00:00.000Z",
createdAt: "2026-05-05T00:00:00.000Z",
updatedAt: "2026-05-05T00:00:00.000Z",
};
expect(request.requester.actorName).toBe("Executor");
expect(request.targetAction.context).toEqual({ taskId: "FN-3546" });
});
});
describe("ApprovalRequestStore", () => {
let tempDir: string;
let db: Database;
let store: ApprovalRequestStore;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "kb-approval-request-test-"));
db = new Database(tempDir, { inMemory: true });
db.init();
store = new ApprovalRequestStore(db);
});
afterEach(() => {
db.close();
rmSync(tempDir, { recursive: true, force: true });
});
function createSampleRequest(taskId = "FN-3546") {
return store.create({
requester: REQUESTER,
targetAction: {
category: AGENT_PERMISSION_POLICY_ACTION_CATEGORIES[0],
action: "git commit",
summary: "Commit current task changes",
resourceType: "repository",
resourceId: "kb",
context: { branch: "fn/fn-3546" },
},
taskId,
runId: "run-abc",
});
}
it("creates request rows with full actor and target action payload", () => {
const created = createSampleRequest();
const fetched = store.get(created.id);
expect(fetched).toBeTruthy();
expect(fetched?.status).toBe("pending");
expect(fetched?.requester).toEqual(REQUESTER);
expect(fetched?.targetAction.context).toEqual({ branch: "fn/fn-3546" });
expect(fetched?.taskId).toBe("FN-3546");
expect(fetched?.runId).toBe("run-abc");
});
it("supports pending -> approved and approved -> completed with audit trail", () => {
const created = createSampleRequest();
const approved = store.decide(created.id, "approved", { actor: APPROVER, note: "Looks good" });
const completed = store.markCompleted(created.id, { actor: REQUESTER, note: "Action executed" });
expect(approved.status).toBe("approved");
expect(approved.decidedAt).toBeTruthy();
expect(completed.status).toBe("completed");
expect(completed.completedAt).toBeTruthy();
const history = store.getAuditHistory(created.id);
expect(history.map((e) => e.eventType)).toEqual(["created", "approved", "completed"]);
expect(history[1]?.note).toBe("Looks good");
});
it("supports pending -> denied", () => {
const created = createSampleRequest();
const denied = store.decide(created.id, "denied", { actor: APPROVER, note: "Not allowed" });
expect(denied.status).toBe("denied");
expect(denied.decidedAt).toBeTruthy();
expect(store.getAuditHistory(created.id).map((e) => e.eventType)).toEqual(["created", "denied"]);
});
it("rejects invalid transitions", () => {
const created = createSampleRequest();
expect(() => store.markCompleted(created.id, { actor: REQUESTER })).toThrow(
"Invalid approval request transition: pending -> completed",
);
store.decide(created.id, "approved", { actor: APPROVER });
expect(() => store.decide(created.id, "denied", { actor: APPROVER })).toThrow(
"Invalid approval request transition: approved -> denied",
);
});
it("lists and filters approval requests", () => {
const first = createSampleRequest("FN-100");
const second = createSampleRequest("FN-200");
store.decide(second.id, "approved", { actor: APPROVER });
const pending = store.list({ status: "pending" });
const approved = store.list({ status: "approved" });
const byTask = store.list({ taskId: "FN-100" });
expect(pending.map((r) => r.id)).toContain(first.id);
expect(approved.map((r) => r.id)).toContain(second.id);
expect(byTask.map((r) => r.id)).toEqual([first.id]);
});
it("persists requests and audit history across restart/migration", () => {
db.close();
const diskDir = mkdtempSync(join(tmpdir(), "kb-approval-request-disk-"));
try {
const dbA = new Database(diskDir);
dbA.init();
const storeA = new ApprovalRequestStore(dbA);
const created = storeA.create({
requester: REQUESTER,
targetAction: {
category: AGENT_PERMISSION_POLICY_ACTION_CATEGORIES[0],
action: "git push",
summary: "Push branch",
resourceType: "branch",
resourceId: "fn/fn-3546",
},
});
storeA.decide(created.id, "approved", { actor: APPROVER });
dbA.close();
const dbB = new Database(diskDir);
dbB.init();
const storeB = new ApprovalRequestStore(dbB);
const fetched = storeB.get(created.id);
expect(fetched?.status).toBe("approved");
expect(storeB.getAuditHistory(created.id).map((e) => e.eventType)).toEqual(["created", "approved"]);
dbB.close();
} finally {
rmSync(diskDir, { recursive: true, force: true });
}
db = new Database(tempDir, { inMemory: true });
db.init();
store = new ApprovalRequestStore(db);
});
});

View File

@@ -164,7 +164,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
});
it("seeds lastModified", () => {
const ts = db.getLastModified();
@@ -186,7 +186,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -959,7 +959,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -984,11 +984,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
db.close();
});
@@ -1023,7 +1023,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1064,7 +1064,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1133,7 +1133,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1236,7 +1236,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1310,7 +1310,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
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" }]);
@@ -1334,7 +1334,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
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" }]);
@@ -1438,7 +1438,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1907,7 +1907,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2040,7 +2040,7 @@ describe("migration v67 drops orphan project auth tables", () => {
const migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(67);
expect(migrated.getSchemaVersion()).toBe(68);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2054,7 +2054,7 @@ describe("migration v67 drops orphan project auth tables", () => {
const fusion = join(temp, ".fusion");
const fresh = new Database(fusion);
fresh.init();
expect(fresh.getSchemaVersion()).toBe(67);
expect(fresh.getSchemaVersion()).toBe(68);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -886,7 +886,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(67);
expect(db1.getSchemaVersion()).toBe(68);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -921,7 +921,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(67);
expect(db3.getSchemaVersion()).toBe(68);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(67);
expect(db1.getSchemaVersion()).toBe(68);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(67);
expect(db2.getSchemaVersion()).toBe(68);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(67);
expect(db1.getSchemaVersion()).toBe(68);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
});
it("mission_features table has loop state columns", () => {

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(67);
expect(db.getSchemaVersion()).toBe(68);
});
});
});

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(67);
expect(db.getSchemaVersion()).toBe(68);
const index = db
.prepare(

View File

@@ -0,0 +1,281 @@
import { randomUUID } from "node:crypto";
import type { Database } from "./db.js";
import { fromJson, toJsonNullable } from "./db.js";
import {
isValidApprovalRequestTransition,
type ApprovalRequest,
type ApprovalRequestActorSnapshot,
type ApprovalRequestAuditEvent,
type ApprovalRequestAuditEventType,
type ApprovalRequestCompletionInput,
type ApprovalRequestCreateInput,
type ApprovalRequestDecisionInput,
type ApprovalRequestListInput,
type ApprovalRequestStatus,
} from "./types.js";
interface ApprovalRequestRow {
id: string;
status: ApprovalRequestStatus;
requesterActorId: string;
requesterActorType: ApprovalRequestActorSnapshot["actorType"];
requesterActorName: string;
targetActionCategory: string;
targetActionOperation: string;
targetActionSummary: string;
targetResourceType: string;
targetResourceId: string;
targetContext: string | null;
taskId: string | null;
runId: string | null;
requestedAt: string;
decidedAt: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface ApprovalRequestAuditEventRow {
id: string;
requestId: string;
eventType: ApprovalRequestAuditEventType;
actorId: string;
actorType: ApprovalRequestActorSnapshot["actorType"];
actorName: string;
note: string | null;
createdAt: string;
}
export class ApprovalRequestStore {
constructor(private db: Database) {}
private rowToRequest(row: ApprovalRequestRow): ApprovalRequest {
return {
id: row.id,
status: row.status,
requester: {
actorId: row.requesterActorId,
actorType: row.requesterActorType,
actorName: row.requesterActorName,
},
targetAction: {
category: row.targetActionCategory as ApprovalRequest["targetAction"]["category"],
action: row.targetActionOperation,
summary: row.targetActionSummary,
resourceType: row.targetResourceType,
resourceId: row.targetResourceId,
context: fromJson<Record<string, unknown>>(row.targetContext),
},
taskId: row.taskId ?? undefined,
runId: row.runId ?? undefined,
requestedAt: row.requestedAt,
decidedAt: row.decidedAt ?? undefined,
completedAt: row.completedAt ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToAuditEvent(row: ApprovalRequestAuditEventRow): ApprovalRequestAuditEvent {
return {
id: row.id,
requestId: row.requestId,
eventType: row.eventType,
actor: {
actorId: row.actorId,
actorType: row.actorType,
actorName: row.actorName,
},
note: row.note ?? undefined,
createdAt: row.createdAt,
};
}
private appendAuditEvent(
requestId: string,
eventType: ApprovalRequestAuditEventType,
actor: ApprovalRequestActorSnapshot,
createdAt: string,
note?: string,
): ApprovalRequestAuditEvent {
const event: ApprovalRequestAuditEvent = {
id: `aprevt-${randomUUID().slice(0, 8)}`,
requestId,
eventType,
actor,
...(note !== undefined ? { note } : {}),
createdAt,
};
this.db.prepare(`
INSERT INTO approval_request_audit_events (id, requestId, eventType, actorId, actorType, actorName, note, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
event.id,
event.requestId,
event.eventType,
event.actor.actorId,
event.actor.actorType,
event.actor.actorName,
event.note ?? null,
event.createdAt,
);
return event;
}
create(input: ApprovalRequestCreateInput): ApprovalRequest {
const now = new Date().toISOString();
const request: ApprovalRequest = {
id: `apr-${randomUUID().slice(0, 8)}`,
status: "pending",
requester: input.requester,
targetAction: input.targetAction,
taskId: input.taskId,
runId: input.runId,
requestedAt: now,
createdAt: now,
updatedAt: now,
};
this.db.transaction(() => {
this.db.prepare(`
INSERT INTO approval_requests (
id, status,
requesterActorId, requesterActorType, requesterActorName,
targetActionCategory, targetActionOperation, targetActionSummary,
targetResourceType, targetResourceId, targetContext,
taskId, runId,
requestedAt, decidedAt, completedAt,
createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
request.id,
request.status,
request.requester.actorId,
request.requester.actorType,
request.requester.actorName,
request.targetAction.category,
request.targetAction.action,
request.targetAction.summary,
request.targetAction.resourceType,
request.targetAction.resourceId,
toJsonNullable(request.targetAction.context),
request.taskId ?? null,
request.runId ?? null,
request.requestedAt,
null,
null,
request.createdAt,
request.updatedAt,
);
this.appendAuditEvent(request.id, "created", input.requester, now);
});
this.db.bumpLastModified();
return request;
}
get(id: string): ApprovalRequest | null {
const row = this.db.prepare(`SELECT * FROM approval_requests WHERE id = ?`).get(id) as ApprovalRequestRow | undefined;
return row ? this.rowToRequest(row) : null;
}
list(input: ApprovalRequestListInput = {}): ApprovalRequest[] {
const where: string[] = [];
const params: Array<string | number> = [];
if (input.status) {
where.push("status = ?");
params.push(input.status);
}
if (input.requesterActorId) {
where.push("requesterActorId = ?");
params.push(input.requesterActorId);
}
if (input.taskId) {
where.push("taskId = ?");
params.push(input.taskId);
}
if (input.runId) {
where.push("runId = ?");
params.push(input.runId);
}
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
const limit = input.limit ?? 100;
const offset = input.offset ?? 0;
const rows = this.db.prepare(`
SELECT * FROM approval_requests
${whereSql}
ORDER BY createdAt DESC, id DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset) as ApprovalRequestRow[];
return rows.map((row) => this.rowToRequest(row));
}
decide(requestId: string, status: "approved" | "denied", input: ApprovalRequestDecisionInput): ApprovalRequest {
const existing = this.get(requestId);
if (!existing) {
throw new Error(`Approval request ${requestId} not found`);
}
if (!isValidApprovalRequestTransition(existing.status, status)) {
throw new Error(`Invalid approval request transition: ${existing.status} -> ${status}`);
}
const now = new Date().toISOString();
this.db.transaction(() => {
this.db.prepare(`
UPDATE approval_requests
SET status = ?, decidedAt = ?, updatedAt = ?
WHERE id = ?
`).run(status, now, now, requestId);
this.appendAuditEvent(requestId, status, input.actor, now, input.note);
});
this.db.bumpLastModified();
const updated = this.get(requestId);
if (!updated) {
throw new Error(`Approval request ${requestId} not found after update`);
}
return updated;
}
markCompleted(requestId: string, input: ApprovalRequestCompletionInput): ApprovalRequest {
const existing = this.get(requestId);
if (!existing) {
throw new Error(`Approval request ${requestId} not found`);
}
if (!isValidApprovalRequestTransition(existing.status, "completed")) {
throw new Error(`Invalid approval request transition: ${existing.status} -> completed`);
}
const now = new Date().toISOString();
this.db.transaction(() => {
this.db.prepare(`
UPDATE approval_requests
SET status = 'completed', completedAt = ?, updatedAt = ?
WHERE id = ?
`).run(now, now, requestId);
this.appendAuditEvent(requestId, "completed", input.actor, now, input.note);
});
this.db.bumpLastModified();
const updated = this.get(requestId);
if (!updated) {
throw new Error(`Approval request ${requestId} not found after completion`);
}
return updated;
}
getAuditHistory(requestId: string): ApprovalRequestAuditEvent[] {
const rows = this.db.prepare(`
SELECT * FROM approval_request_audit_events
WHERE requestId = ?
ORDER BY createdAt ASC, rowid ASC
`).all(requestId) as ApprovalRequestAuditEventRow[];
return rows.map((row) => this.rowToAuditEvent(row));
}
}

View File

@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 67;
const SCHEMA_VERSION = 68;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -2683,6 +2683,51 @@ export class Database {
});
}
if (version < 68) {
this.applyMigration(68, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS approval_requests (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
requesterActorId TEXT NOT NULL,
requesterActorType TEXT NOT NULL,
requesterActorName TEXT NOT NULL,
targetActionCategory TEXT NOT NULL,
targetActionOperation TEXT NOT NULL,
targetActionSummary TEXT NOT NULL,
targetResourceType TEXT NOT NULL,
targetResourceId TEXT NOT NULL,
targetContext TEXT,
taskId TEXT,
runId TEXT,
requestedAt TEXT NOT NULL,
decidedAt TEXT,
completedAt TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxApprovalRequestsStatusCreatedAt ON approval_requests(status, createdAt)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxApprovalRequestsRequesterCreatedAt ON approval_requests(requesterActorId, createdAt)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxApprovalRequestsTaskCreatedAt ON approval_requests(taskId, createdAt)`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS approval_request_audit_events (
id TEXT PRIMARY KEY,
requestId TEXT NOT NULL,
eventType TEXT NOT NULL,
actorId TEXT NOT NULL,
actorType TEXT NOT NULL,
actorName TEXT NOT NULL,
note TEXT,
createdAt TEXT NOT NULL,
FOREIGN KEY (requestId) REFERENCES approval_requests(id) ON DELETE CASCADE
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxApprovalRequestAuditRequestCreatedAt ON approval_request_audit_events(requestId, createdAt, id)`);
});
}
}
/**

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_PRESET_IDS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, 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, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_PRESET_IDS, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, 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, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export * from "./mesh-replication-protocol.js";
export * from "./mesh-task-replication.js";
@@ -56,6 +56,7 @@ export { ReflectionStore } from "./reflection-store.js";
export type { ReflectionStoreEvents } from "./reflection-store.js";
export { MessageStore } from "./message-store.js";
export type { MessageStoreEvents } from "./message-store.js";
export { ApprovalRequestStore } from "./approval-request-store.js";
export { TaskStore } from "./store.js";
export {
createDistributedTaskIdAllocator,

View File

@@ -3708,6 +3708,112 @@ export interface AgentPermissionPolicy {
rules: AgentPermissionPolicyRules;
}
/** Approval request lifecycle statuses. */
export const APPROVAL_REQUEST_STATUSES = ["pending", "approved", "denied", "completed"] as const;
/** A single approval request lifecycle status. */
export type ApprovalRequestStatus = (typeof APPROVAL_REQUEST_STATUSES)[number];
/** Append-only audit event types for approval requests. */
export const APPROVAL_REQUEST_AUDIT_EVENT_TYPES = [
"created",
"approved",
"denied",
"completed",
] as const;
/** A single append-only audit event type for approval requests. */
export type ApprovalRequestAuditEventType = (typeof APPROVAL_REQUEST_AUDIT_EVENT_TYPES)[number];
/** Immutable actor identity snapshot captured at request/audit event time. */
export interface ApprovalRequestActorSnapshot {
actorId: string;
actorType: "agent" | "user" | "system";
actorName: string;
}
/** Action payload gated by an approval request. */
export interface ApprovalRequestTargetAction {
category: AgentPermissionPolicyActionCategory;
action: string;
summary: string;
resourceType: string;
resourceId: string;
context?: Record<string, unknown>;
}
/** Append-only audit event row for approval request history. */
export interface ApprovalRequestAuditEvent {
id: string;
requestId: string;
eventType: ApprovalRequestAuditEventType;
actor: ApprovalRequestActorSnapshot;
note?: string;
createdAt: string;
}
/** Durable approval request record used by engine and dashboard surfaces. */
export interface ApprovalRequest {
id: string;
status: ApprovalRequestStatus;
requester: ApprovalRequestActorSnapshot;
targetAction: ApprovalRequestTargetAction;
taskId?: string;
runId?: string;
requestedAt: string;
decidedAt?: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
}
/** Create input for a new pending approval request. */
export interface ApprovalRequestCreateInput {
requester: ApprovalRequestActorSnapshot;
targetAction: ApprovalRequestTargetAction;
taskId?: string;
runId?: string;
}
/** Input for pending->approved / pending->denied decisions. */
export interface ApprovalRequestDecisionInput {
actor: ApprovalRequestActorSnapshot;
note?: string;
}
/** Input for approved->completed transition. */
export interface ApprovalRequestCompletionInput {
actor: ApprovalRequestActorSnapshot;
note?: string;
}
/** Query filters for approval request listings. */
export interface ApprovalRequestListInput {
status?: ApprovalRequestStatus;
requesterActorId?: string;
taskId?: string;
runId?: string;
limit?: number;
offset?: number;
}
/** True when a transition is valid for approval request lifecycle rules. */
export function isValidApprovalRequestTransition(
from: ApprovalRequestStatus,
to: ApprovalRequestStatus,
): boolean {
if (from === to) {
return true;
}
if (from === "pending") {
return to === "approved" || to === "denied";
}
if (from === "approved") {
return to === "completed";
}
return false;
}
/** Describes how an agent's task assignment capability was determined. */
export type TaskAssignSource =
| "role_default" // Granted automatically by role (e.g., scheduler gets tasks:assign)