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:
@@ -152,7 +152,7 @@ Concrete references:
|
|||||||
- **Database adapter**: `packages/core/src/db.ts`
|
- **Database adapter**: `packages/core/src/db.ts`
|
||||||
- SQLite (`node:sqlite`) with WAL mode + foreign keys
|
- SQLite (`node:sqlite`) with WAL mode + foreign keys
|
||||||
- JSON helpers: `toJson`, `toJsonNullable`, `fromJson`
|
- JSON helpers: `toJson`, `toJsonNullable`, `fromJson`
|
||||||
- Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta`
|
- Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta`
|
||||||
- Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage`
|
- Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage`
|
||||||
- `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error`
|
- `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error`
|
||||||
- **Standalone roadmap model**: `packages/core/src/roadmap-types.ts`, `roadmap-ordering.ts`, `roadmap-store.ts`
|
- **Standalone roadmap model**: `packages/core/src/roadmap-types.ts`, `roadmap-ordering.ts`, `roadmap-store.ts`
|
||||||
@@ -168,7 +168,8 @@ Concrete references:
|
|||||||
- `AgentStore` (`agent-store.ts`) — filesystem-based agent metadata + heartbeat run history
|
- `AgentStore` (`agent-store.ts`) — filesystem-based agent metadata + heartbeat run history
|
||||||
- `MissionStore` (`mission-store.ts`) — mission/milestone/slice/feature hierarchy
|
- `MissionStore` (`mission-store.ts`) — mission/milestone/slice/feature hierarchy
|
||||||
- `AutomationStore` (`automation-store.ts`) — scheduled jobs with global/project scope isolation
|
- `AutomationStore` (`automation-store.ts`) — scheduled jobs with global/project scope isolation
|
||||||
- `MessageStore` (`message-store.ts`) — mailbox/inbox/outbox messaging
|
- `MessageStore` (`message-store.ts`) — SQLite-backed mailbox/inbox/outbox messaging
|
||||||
|
- `ApprovalRequestStore` (`approval-request-store.ts`) — durable approval request lifecycle + append-only audit events
|
||||||
- `ChatStore` (`chat-store.ts`) — session/message persistence for agent chat
|
- `ChatStore` (`chat-store.ts`) — session/message persistence for agent chat
|
||||||
- `InsightStore` (`insight-store.ts`) — project insight persistence + dedupe/run tracking
|
- `InsightStore` (`insight-store.ts`) — project insight persistence + dedupe/run tracking
|
||||||
- `ReflectionStore` (`reflection-store.ts`) — agent reflection records and performance snapshots
|
- `ReflectionStore` (`reflection-store.ts`) — agent reflection records and performance snapshots
|
||||||
@@ -814,8 +815,9 @@ SQLite schema is initialized in `packages/core/src/db.ts` and uses:
|
|||||||
|
|
||||||
### File-based side stores
|
### File-based side stores
|
||||||
Some data remains intentionally filesystem-based:
|
Some data remains intentionally filesystem-based:
|
||||||
- Agents: `.fusion/agents/*` (`AgentStore`)
|
- Agent instruction bundles and heartbeat markdown: `.fusion/agents/*` (`AgentStore`)
|
||||||
- Messages: `.fusion/messages/*` (`MessageStore`)
|
|
||||||
|
Agent/message/approval metadata and history now persist in SQLite tables.
|
||||||
|
|
||||||
### Migration from legacy file storage
|
### Migration from legacy file storage
|
||||||
- Detection + migration: `packages/core/src/db-migrate.ts`
|
- Detection + migration: `packages/core/src/db-migrate.ts`
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ Additional backend notes:
|
|||||||
| `automations` | Scheduled automation definitions, run state, and run history. |
|
| `automations` | Scheduled automation definitions, run state, and run history. |
|
||||||
| `agents` | Agent registry/state/task assignment metadata. |
|
| `agents` | Agent registry/state/task assignment metadata. |
|
||||||
| `agentHeartbeats` | Heartbeat run events linked to agents (`agentId` FK cascade). |
|
| `agentHeartbeats` | Heartbeat run events linked to agents (`agentId` FK cascade). |
|
||||||
|
| `approval_requests` | Durable approval request records: requester actor snapshot, target action payload (category/action/resource/context), lifecycle status (`pending`/`approved`/`denied`/`completed`), optional task/run context, and requested/decided/completed timestamps. |
|
||||||
|
| `approval_request_audit_events` | Append-only audit trail for approval requests. Each row stores event type (`created`/`approved`/`denied`/`completed`), immutable actor snapshot, optional note, and deterministic per-request ordering by `(createdAt, rowid)`. |
|
||||||
| `task_documents` | Task-scoped document metadata/content keyed by `(taskId, key)` with current revision pointer. |
|
| `task_documents` | Task-scoped document metadata/content keyed by `(taskId, key)` with current revision pointer. |
|
||||||
| `task_document_revisions` | Immutable revision history for task documents (content snapshots by revision). |
|
| `task_document_revisions` | Immutable revision history for task documents (content snapshots by revision). |
|
||||||
| `__meta` | Schema version + monotonic `lastModified` change detector. |
|
| `__meta` | Schema version + monotonic `lastModified` change detector. |
|
||||||
|
|||||||
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", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(67);
|
expect(db.getSchemaVersion()).toBe(68);
|
||||||
});
|
});
|
||||||
it("seeds lastModified", () => {
|
it("seeds lastModified", () => {
|
||||||
const ts = db.getLastModified();
|
const ts = db.getLastModified();
|
||||||
@@ -186,7 +186,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(67);
|
expect(db.getSchemaVersion()).toBe(68);
|
||||||
});
|
});
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
// Update the config
|
// Update the config
|
||||||
@@ -959,7 +959,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
// 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
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
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);
|
const db = new Database(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(67);
|
expect(db.getSchemaVersion()).toBe(68);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(67);
|
expect(db.getSchemaVersion()).toBe(68);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -1023,7 +1023,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
expect(cols.map((col) => col.name)).toContain("priority");
|
expect(cols.map((col) => col.name)).toContain("priority");
|
||||||
@@ -1064,7 +1064,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const colNames = cols.map((col) => col.name);
|
const colNames = cols.map((col) => col.name);
|
||||||
@@ -1133,7 +1133,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
const colNames = cols.map((col) => col.name);
|
const colNames = cols.map((col) => col.name);
|
||||||
@@ -1236,7 +1236,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||||
@@ -1310,7 +1310,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
@@ -1334,7 +1334,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||||
@@ -1438,7 +1438,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29
|
// Verify version bumped to 29
|
||||||
expect(db.getSchemaVersion()).toBe(67);
|
expect(db.getSchemaVersion()).toBe(68);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -1907,7 +1907,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(fusionDir);
|
const db = createDatabase(fusionDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(67);
|
expect(db.getSchemaVersion()).toBe(68);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
@@ -2040,7 +2040,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
|||||||
|
|
||||||
const migrated = new Database(fusion);
|
const migrated = new Database(fusion);
|
||||||
migrated.init();
|
migrated.init();
|
||||||
expect(migrated.getSchemaVersion()).toBe(67);
|
expect(migrated.getSchemaVersion()).toBe(68);
|
||||||
const tables = migrated
|
const tables = migrated
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||||
.all() as Array<{ name: string }>;
|
.all() as Array<{ name: string }>;
|
||||||
@@ -2054,7 +2054,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
|||||||
const fusion = join(temp, ".fusion");
|
const fusion = join(temp, ".fusion");
|
||||||
const fresh = new Database(fusion);
|
const fresh = new Database(fusion);
|
||||||
fresh.init();
|
fresh.init();
|
||||||
expect(fresh.getSchemaVersion()).toBe(67);
|
expect(fresh.getSchemaVersion()).toBe(68);
|
||||||
const tables = fresh
|
const tables = fresh
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||||
.all() as Array<{ name: string }>;
|
.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)
|
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||||
const db1 = createDatabase(legacyDir);
|
const db1 = createDatabase(legacyDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(67);
|
expect(db1.getSchemaVersion()).toBe(68);
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
// 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");
|
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||||
// Now run init — this triggers the v32→v33 migration
|
// Now run init — this triggers the v32→v33 migration
|
||||||
db3.init();
|
db3.init();
|
||||||
expect(db3.getSchemaVersion()).toBe(67);
|
expect(db3.getSchemaVersion()).toBe(68);
|
||||||
|
|
||||||
// Step 4: Verify insight tables exist after migration
|
// Step 4: Verify insight tables exist after migration
|
||||||
const tablesAfter = db3.prepare(
|
const tablesAfter = db3.prepare(
|
||||||
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
try {
|
try {
|
||||||
const db1 = createDatabase(testDir);
|
const db1 = createDatabase(testDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(67);
|
expect(db1.getSchemaVersion()).toBe(68);
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
const db2 = createDatabase(testDir);
|
const db2 = createDatabase(testDir);
|
||||||
expect(() => db2.init()).not.toThrow();
|
expect(() => db2.init()).not.toThrow();
|
||||||
expect(db2.getSchemaVersion()).toBe(67);
|
expect(db2.getSchemaVersion()).toBe(68);
|
||||||
db2.close();
|
db2.close();
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
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
|
// Step 1: Create a fresh DB and run migrations
|
||||||
const db1 = createDatabase(compatDir);
|
const db1 = createDatabase(compatDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(67);
|
expect(db1.getSchemaVersion()).toBe(68);
|
||||||
|
|
||||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||||
// table without them. This simulates a DB that was created before 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)", () => {
|
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||||
it("schema version is 40 after migration", () => {
|
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", () => {
|
it("mission_features table has loop state columns", () => {
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
|
|||||||
|
|
||||||
describe("schema version", () => {
|
describe("schema version", () => {
|
||||||
it("schema version is 40 after init", () => {
|
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", () => {
|
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_documents")).toBe(true);
|
||||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||||
expect(db.getSchemaVersion()).toBe(67);
|
expect(db.getSchemaVersion()).toBe(68);
|
||||||
|
|
||||||
const index = db
|
const index = db
|
||||||
.prepare(
|
.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 ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 67;
|
const SCHEMA_VERSION = 68;
|
||||||
|
|
||||||
function normalizeTaskComments(
|
function normalizeTaskComments(
|
||||||
steeringComments: SteeringComment[] | undefined,
|
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 { 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, 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 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 { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export * from "./mesh-replication-protocol.js";
|
export * from "./mesh-replication-protocol.js";
|
||||||
export * from "./mesh-task-replication.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 type { ReflectionStoreEvents } from "./reflection-store.js";
|
||||||
export { MessageStore } from "./message-store.js";
|
export { MessageStore } from "./message-store.js";
|
||||||
export type { MessageStoreEvents } from "./message-store.js";
|
export type { MessageStoreEvents } from "./message-store.js";
|
||||||
|
export { ApprovalRequestStore } from "./approval-request-store.js";
|
||||||
export { TaskStore } from "./store.js";
|
export { TaskStore } from "./store.js";
|
||||||
export {
|
export {
|
||||||
createDistributedTaskIdAllocator,
|
createDistributedTaskIdAllocator,
|
||||||
|
|||||||
@@ -3708,6 +3708,112 @@ export interface AgentPermissionPolicy {
|
|||||||
rules: AgentPermissionPolicyRules;
|
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. */
|
/** Describes how an agent's task assignment capability was determined. */
|
||||||
export type TaskAssignSource =
|
export type TaskAssignSource =
|
||||||
| "role_default" // Granted automatically by role (e.g., scheduler gets tasks:assign)
|
| "role_default" // Granted automatically by role (e.g., scheduler gets tasks:assign)
|
||||||
|
|||||||
Reference in New Issue
Block a user