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:
207
packages/core/src/__tests__/approval-request-store.test.ts
Normal file
207
packages/core/src/__tests__/approval-request-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(67);
|
||||
expect(db.getSchemaVersion()).toBe(68);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
281
packages/core/src/approval-request-store.ts
Normal file
281
packages/core/src/approval-request-store.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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)`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user