From 34c5557a32002e7f5c0c5888addbc7cfec8629cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 17:45:39 -0400 Subject: [PATCH] Q108: deliverables under review are held until approved instead of written first Co-Authored-By: Claude Opus 5.5 --- .../migrations/086-held-deliverables.ts | 27 ++++ src/main/database/schema.ts | 2 + src/main/services/agent-deliverable-tools.ts | 80 ++++++------ src/main/services/agent-document-tools.ts | 19 ++- src/main/services/agent-goal-tools.ts | 9 ++ src/main/services/approval-service.ts | 5 + src/main/services/files/file-object-store.ts | 34 +++-- src/main/services/held-deliverables.ts | 117 ++++++++++++++++++ src/main/services/task-mutations.ts | 3 + tests/unit/agent-document-tools.test.ts | 12 +- tests/unit/agent-goal-tools.test.ts | 16 +++ tests/unit/domain-services.test.ts | 2 +- tests/unit/execution-modes.test.ts | 86 +++++++++++-- 13 files changed, 325 insertions(+), 87 deletions(-) create mode 100644 src/main/database/migrations/086-held-deliverables.ts create mode 100644 src/main/services/held-deliverables.ts diff --git a/src/main/database/migrations/086-held-deliverables.ts b/src/main/database/migrations/086-held-deliverables.ts new file mode 100644 index 00000000..0fe97380 --- /dev/null +++ b/src/main/database/migrations/086-held-deliverables.ts @@ -0,0 +1,27 @@ +export const heldDeliverablesSchema = ` + create table if not exists held_deliverables ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + approval_id text not null, + kind text not null check (kind in ('file', 'document_create', 'document_update')), + agent_id text, + task_id text, + goal_id text, + project_id text, + workspace_root text, + relative_path text, + document_id text, + base_version integer, + title text, + doc_type text, + reason text, + content text not null, + state text not null check (state in ('held', 'published', 'declined')), + created_at text not null + ); + create index if not exists held_deliverables_task on held_deliverables(task_id, state); + create index if not exists held_deliverables_goal on held_deliverables(goal_id, state); + + insert into schema_migrations(version, name, applied_at) + values (86, 'held_deliverables', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 1076e675..a50504e7 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -84,6 +84,7 @@ import { connectorPendingAccountSchema } from "./migrations/082-connector-pendin import { connectorAccountLabelsSchema } from "./migrations/083-connector-account-labels"; import { connectorGrantLimitsSchema } from "./migrations/084-connector-grant-limits"; import { approvalSubjectSnapshotSchema } from "./migrations/085-approval-subject-snapshot"; +import { heldDeliverablesSchema } from "./migrations/086-held-deliverables"; interface Migration { version: number; @@ -177,6 +178,7 @@ const MIGRATIONS: Migration[] = [ { version: 83, name: "connector_account_labels", sql: connectorAccountLabelsSchema }, { version: 84, name: "connector_grant_limits", sql: connectorGrantLimitsSchema }, { version: 85, name: "approval_subject_snapshot", sql: approvalSubjectSnapshotSchema }, + { version: 86, name: "held_deliverables", sql: heldDeliverablesSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-deliverable-tools.ts b/src/main/services/agent-deliverable-tools.ts index 3dcbcafa..47a766a9 100644 --- a/src/main/services/agent-deliverable-tools.ts +++ b/src/main/services/agent-deliverable-tools.ts @@ -1,4 +1,7 @@ import { RISK_LEVEL } from "../../shared/domain"; +import { createId } from "../../shared/ids"; +import { withSavepoint } from "../database/connection"; +import { holdDeliverable, reviewsDeliverables, type HeldTarget } from "./held-deliverables"; import { ApprovalService } from "./approval-service"; import { writeDeliverable } from "./deliverable-writer"; import { DocumentService } from "./document-service"; @@ -7,10 +10,16 @@ import { requireString } from "./agent-tool-args"; import type { AgentToolContext, AgentToolDeps } from "./agent-tools"; import type { JsonRecord } from "./provider-http"; +const REVIEW_EXCERPT_CHARS = 4000; + export function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { if (!ctx.workspace) throw new Error("No workspace is bound to this task; cannot write deliverables."); const path = requireString(args.path, "path"); const content = requireString(args.content, "content"); + if (reviewsDeliverables(deps.db, ctx.orgId)) { + holdForReview(deps, ctx, { kind: "file", workspaceRoot: ctx.workspace.localPath, relativePath: path }, content, `Review deliverable: ${path}`); + return `Deliverable ${path} (${content.length} chars) is held for board review in Approvals. It is not written until approved, so no one can use it yet.`; + } const written = writeDeliverable({ workspaceRoot: ctx.workspace.localPath, relativePath: path, content }); const fileId = recordWorkspaceFile(deps.db, { orgId: ctx.orgId, @@ -19,21 +28,18 @@ export function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, workspaceRoot: ctx.workspace.localPath, relativePath: written, }); - const review = requestDeliverableReview(deps, ctx, { - title: `Review deliverable: ${written}`, - details: `Deliverable ${written} (${content.length} chars) produced by an agent run.`, - sourceEntityType: "file", - sourceEntityId: fileId, - }); - return `Wrote deliverable ${written} (file_id=${fileId}, chars=${content.length}).${review ? " It is queued for board review in Approvals." : ""}`; + return `Wrote deliverable ${written} (file_id=${fileId}, chars=${content.length}).`; } export function createDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { const title = requireString(args.title, "title"); const docType = requireString(args.doc_type, "doc_type"); const content = requireString(args.content, "content"); - const documentService = new DocumentService(deps.db); - const docId = documentService.create({ + if (reviewsDeliverables(deps.db, ctx.orgId)) { + holdForReview(deps, ctx, { kind: "document_create", title, docType }, content, `Review ${docType}: ${title}`); + return `Document "${title}" (${docType}, ${content.length} chars) is held for board review in Approvals. It is not created until approved, so no one can use it yet.`; + } + const docId = new DocumentService(deps.db).create({ orgId: ctx.orgId, title, docType, @@ -44,40 +50,28 @@ export function createDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, a goalId: ctx.taskGoalId, taskId: ctx.taskId, }); - const review = requestDeliverableReview(deps, ctx, { - title: `Review ${docType}: ${title}`, - details: `Document "${title}" (${docType}, ${content.length} chars) authored by an agent run.`, - sourceEntityType: "document", - sourceEntityId: docId, - }); - return `Created document "${title}" (doc_id=${docId}, type=${docType}, chars=${content.length}).${review ? " It is queued for board review in Approvals." : ""}`; + return `Created document "${title}" (doc_id=${docId}, type=${docType}, chars=${content.length}).`; } -/** - * In AI Co-Founder mode the operator reviews what the company produces, so - * every deliverable and document lands in the Approvals queue. AI CEO mode - * ships without review — that autonomy is the mode's contract. - */ -export function requestDeliverableReview( - deps: AgentToolDeps, - ctx: AgentToolContext, - input: { title: string; details: string; sourceEntityType: "file" | "document"; sourceEntityId: string }, -): boolean { - const org = deps.db.prepare("select review_deliverables from organizations where id = ?") - .get(ctx.orgId) as { review_deliverables: number } | undefined; - if (!org) throw new Error("Organization was not found."); - if (org.review_deliverables !== 1) return false; - new ApprovalService(deps.db).create( - { - orgId: ctx.orgId, - kind: "deliverable_review", - title: input.title, - details: input.details, - risk: RISK_LEVEL.LOW, - requestedByType: "agent", - requestedById: ctx.agent.id, - }, - { sourceEntityType: input.sourceEntityType, sourceEntityId: input.sourceEntityId }, - ); - return true; +/** In AI Co-Founder mode the board reviews what the company produces before it exists; AI CEO mode ships without review. */ +export function holdForReview(deps: AgentToolDeps, ctx: AgentToolContext, target: HeldTarget, content: string, title: string): void { + const excerpt = content.length > REVIEW_EXCERPT_CHARS ? `${content.slice(0, REVIEW_EXCERPT_CHARS)}\n[... ${content.length - REVIEW_EXCERPT_CHARS} more chars]` : content; + withSavepoint(deps.db, "hold_deliverable", () => { + const heldId = createId("hld"); + const approvalId = new ApprovalService(deps.db).create( + { + orgId: ctx.orgId, + kind: "deliverable_review", + title, + details: `Produced by an agent run (${content.length} chars). Approving writes it; declining discards it.\n\n${excerpt}`, + risk: RISK_LEVEL.LOW, + requestedByType: "agent", + requestedById: ctx.agent.id, + }, + { sourceEntityType: "held_deliverable", sourceEntityId: heldId }, + ); + holdDeliverable(deps.db, { + id: heldId, approvalId, orgId: ctx.orgId, agentId: ctx.agent.id, taskId: ctx.taskId, goalId: ctx.taskGoalId, projectId: ctx.taskProjectId, target, content, + }); + }); } diff --git a/src/main/services/agent-document-tools.ts b/src/main/services/agent-document-tools.ts index 9b3d6447..db79e4ec 100644 --- a/src/main/services/agent-document-tools.ts +++ b/src/main/services/agent-document-tools.ts @@ -1,5 +1,6 @@ import { optionalEnum, optionalLimit, optionalText, requireString } from "./agent-tool-args"; -import { requestDeliverableReview } from "./agent-deliverable-tools"; +import { holdForReview } from "./agent-deliverable-tools"; +import { reviewsDeliverables } from "./held-deliverables"; import type { AgentToolContext, AgentToolDeps } from "./agent-tools"; import { DocumentService } from "./document-service"; import type { JsonRecord } from "./provider-http"; @@ -108,22 +109,20 @@ export function readDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, arg export function updateDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { const id = requireString(args.doc_id, "doc_id"); - const doc = deps.db.prepare("select title, doc_type, status from documents where id = ? and org_id = ?") - .get(id, ctx.orgId) as { title: string; doc_type: string; status: string } | undefined; + const doc = deps.db.prepare("select title, doc_type, status, version from documents where id = ? and org_id = ?") + .get(id, ctx.orgId) as { title: string; doc_type: string; status: string; version: number } | undefined; if (!doc) throw new Error(`No document with doc_id "${id}".`); const content = requireString(args.content, "content"); const title = optionalText(args.title, "title") ?? doc.title; const reason = requireString(args.reason, "reason"); + if (reviewsDeliverables(deps.db, ctx.orgId)) { + holdForReview(deps, ctx, { kind: "document_update", documentId: id, baseVersion: doc.version, title, reason }, content, `Review ${doc.doc_type} update: ${title}`); + return `The update to "${title}" is held for board review in Approvals. The document keeps its current version until the update is approved.`; + } const version = new DocumentService(deps.db).update({ orgId: ctx.orgId, documentId: id, title, content, status: doc.status, reason, changedByType: "agent", changedById: ctx.agent.id, }); - const review = requestDeliverableReview(deps, ctx, { - title: `Review ${doc.doc_type} update: ${title}`, - details: `Document "${title}" v${version} was updated by an agent run (${content.length} chars). Reason: ${reason}`, - sourceEntityType: "document", - sourceEntityId: id, - }); - return `Updated document "${title}" to v${version} (doc_id=${id}).${review ? " The update is queued for board review in Approvals." : ""}`; + return `Updated document "${title}" to v${version} (doc_id=${id}).`; } function producedBy(row: DocumentRow): string { diff --git a/src/main/services/agent-goal-tools.ts b/src/main/services/agent-goal-tools.ts index a398ada9..142dcbe4 100644 --- a/src/main/services/agent-goal-tools.ts +++ b/src/main/services/agent-goal-tools.ts @@ -7,6 +7,7 @@ import type { JsonRecord } from "./provider-http"; import type { ProviderService } from "./provider-service"; import { unreadableArguments, type AgentTurnMessage, type ToolCall, type ToolDefinition } from "./provider-tool-turn"; import { MAX_TOOL_CALLS_PER_TURN, throwIfRunStopped, untilStopped } from "./run-control"; +import { heldWorkTitles } from "./held-deliverables"; const AUDIT_MAX_ITERATIONS = 6; const STATUS_TASK_LINES = 8; @@ -78,6 +79,7 @@ export async function completeGoalTool(args: GoalCompletionArgs): Promise 0) return heldGoalRefusal(deps, goal); const evidence = requireString(call.arguments.evidence, "evidence"); const verdict = await auditGoalCompletion(args, goal, evidence); @@ -85,6 +87,8 @@ export async function completeGoalTool(args: GoalCompletionArgs): Promise 0) return heldGoalRefusal(deps, goal); deps.db.prepare("update goals set status = 'achieved', stalled_reason = null, updated_at = ? where id = ? and org_id = ?") .run(new Date().toISOString(), goal.id, ctx.orgId); new AuditService(deps.db).record({ @@ -102,6 +106,11 @@ export async function completeGoalTool(args: GoalCompletionArgs): Promise upsertFileObject(db, { + orgId: input.orgId, + name, + kind: fileKind(name), + sizeBytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + storagePath: absolute, + ownerType: input.ownerType, + ownerId: input.ownerId, + scan, + now, + })); } function grantWorkspaceAccess(db: DatabaseSync, orgId: string, fileId: string, now: string): void { diff --git a/src/main/services/held-deliverables.ts b/src/main/services/held-deliverables.ts new file mode 100644 index 00000000..7ce961eb --- /dev/null +++ b/src/main/services/held-deliverables.ts @@ -0,0 +1,117 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import type { DatabaseSync } from "node:sqlite"; +import { writeDeliverable } from "./deliverable-writer"; +import { DocumentService } from "./document-service"; +import { recordWorkspaceFile } from "./files/file-object-store"; +import { resolveWorkspacePath } from "./files/workspace-path"; + +/** What a held deliverable will become once approved. */ +export type HeldTarget = + | { kind: "file"; workspaceRoot: string; relativePath: string } + | { kind: "document_create"; title: string; docType: string } + | { kind: "document_update"; documentId: string; baseVersion: number; title: string; reason: string }; + +interface HeldRow { + id: string; + org_id: string; + kind: HeldTarget["kind"]; + agent_id: string | null; + task_id: string | null; + goal_id: string | null; + project_id: string | null; + workspace_root: string | null; + relative_path: string | null; + document_id: string | null; + base_version: number | null; + title: string | null; + doc_type: string | null; + reason: string | null; + content: string; +} + +/** Whether this org's board reviews deliverables before they are written. */ +export function reviewsDeliverables(db: DatabaseSync, orgId: string): boolean { + const org = db.prepare("select review_deliverables from organizations where id = ?").get(orgId) as { review_deliverables: number } | undefined; + if (!org) throw new Error("Organization was not found."); + return org.review_deliverables === 1; +} + +/** Keeps a deliverable out of the workspace and library until its review is approved. */ +export function holdDeliverable( + db: DatabaseSync, + input: { id: string; approvalId: string; orgId: string; agentId: string; taskId: string | null; goalId: string | null; projectId: string | null; target: HeldTarget; content: string }, +): void { + const target = input.target; + // A file path that could never be written is refused now, not at approval. + if (target.kind === "file") resolveWorkspacePath(target.workspaceRoot, target.relativePath); + db.prepare(` + insert into held_deliverables( + id, org_id, approval_id, kind, agent_id, task_id, goal_id, project_id, workspace_root, relative_path, + document_id, base_version, title, doc_type, reason, content, state, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'held', ?) + `).run( + input.id, input.orgId, input.approvalId, target.kind, input.agentId, input.taskId, input.goalId, input.projectId, + target.kind === "file" ? target.workspaceRoot : null, + target.kind === "file" ? target.relativePath : null, + target.kind === "document_update" ? target.documentId : null, + target.kind === "document_update" ? target.baseVersion : null, + target.kind === "file" ? null : target.title, + target.kind === "document_create" ? target.docType : null, + target.kind === "document_update" ? target.reason : null, + input.content, + new Date().toISOString(), + ); + console.info(`[deliverables] held ${target.kind} ${input.id} for review ${input.approvalId}`); +} + +/** Writes an approved deliverable where it was meant to go; a declined one is never written. */ +export function settleHeldDeliverable(db: DatabaseSync, heldId: string, approved: boolean): void { + const row = db.prepare("select * from held_deliverables where id = ? and state = 'held'").get(heldId) as HeldRow | undefined; + if (!row) throw new Error("The held deliverable of this review was not found."); + db.prepare("update held_deliverables set state = ? where id = ?").run(approved ? "published" : "declined", heldId); + // Publication is the last write, so a file reaches disk only once every database change before it has succeeded. + if (approved) publish(db, row); + console.info(`[deliverables] ${row.kind} ${heldId} ${approved ? "published" : "declined"}`); +} + +function publish(db: DatabaseSync, row: HeldRow): void { + if (row.kind === "file") { + const { absolute } = resolveWorkspacePath(row.workspace_root!, row.relative_path!); + const previous = existsSync(absolute) ? readFileSync(absolute) : null; + try { + const written = writeDeliverable({ workspaceRoot: row.workspace_root!, relativePath: row.relative_path!, content: row.content }); + recordWorkspaceFile(db, { orgId: row.org_id, ownerType: "agent", ownerId: row.agent_id, workspaceRoot: row.workspace_root!, relativePath: written }); + } catch (error) { + // A publication that fails leaves the workspace as it was: the old file back, or none. + if (previous) writeFileSync(absolute, previous); + else rmSync(absolute, { force: true }); + throw error; + } + return; + } + const documents = new DocumentService(db); + if (row.kind === "document_create") { + documents.create({ + orgId: row.org_id, title: row.title!, docType: row.doc_type!, content: row.content, authorAgentId: row.agent_id, + reviewerAgentId: null, projectId: row.project_id, goalId: row.goal_id, taskId: row.task_id, + }); + return; + } + const current = db.prepare("select version, status from documents where id = ? and org_id = ?").get(row.document_id, row.org_id) as { version: number; status: string } | undefined; + if (!current) throw new Error("The document this update was for has been deleted; decline the review."); + if (current.version !== row.base_version) { + throw new Error(`The document moved from v${row.base_version} to v${current.version} after this draft was held; decline the review and ask for a fresh draft.`); + } + documents.update({ + orgId: row.org_id, documentId: row.document_id!, title: row.title!, content: row.content, status: current.status, + reason: row.reason!, changedByType: "agent", changedById: row.agent_id!, + }); +} + +/** Held work of a task or goal still waiting for review, by title; done and achieved wait for it. */ +export function heldWorkTitles(db: DatabaseSync, scope: { taskId: string } | { goalId: string }): string[] { + const rows = "taskId" in scope + ? db.prepare("select coalesce(title, relative_path) as name from held_deliverables where task_id = ? and state = 'held'").all(scope.taskId) + : db.prepare("select coalesce(title, relative_path) as name from held_deliverables where goal_id = ? and state = 'held'").all(scope.goalId); + return (rows as Array<{ name: string }>).map((row) => row.name); +} diff --git a/src/main/services/task-mutations.ts b/src/main/services/task-mutations.ts index 7d2c6b8c..26121d80 100644 --- a/src/main/services/task-mutations.ts +++ b/src/main/services/task-mutations.ts @@ -3,6 +3,7 @@ import { RISK_LEVEL, TASK_STATUS, type TaskPriority } from "../../shared/domain" import type { CreateTaskInput, DeleteTaskInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; import { withSavepoint } from "../database/connection"; +import { heldWorkTitles } from "./held-deliverables"; type StatusActor = { type: "system" } | { type: "agent"; id: string }; @@ -98,6 +99,8 @@ export class TaskMutations { const assignee = task.assignee_agent_id ? (this.db.prepare("select name from agents where id = ?").get(task.assignee_agent_id) as { name: string }).name : "the CEO"; throw new Error(`Only ${assignee}, their managers or the open reviewer can change "${task.title}".`); } + const held = input.status === TASK_STATUS.DONE ? heldWorkTitles(this.db, { taskId: task.id }) : []; + if (held.length > 0) throw new Error(`"${task.title}" has work awaiting board review (${held.join(", ")}); it is not written yet, so the task cannot be done.`); const finishing = input.status === TASK_STATUS.DONE || input.status === TASK_STATUS.REVIEW; if (finishing && !input.evidence) { throw new Error('"evidence" is required to move work to review or done: say what was produced and where (file_ids, document ids, test results).'); diff --git a/tests/unit/agent-document-tools.test.ts b/tests/unit/agent-document-tools.test.ts index 5d2cbeff..bdb6a126 100644 --- a/tests/unit/agent-document-tools.test.ts +++ b/tests/unit/agent-document-tools.test.ts @@ -91,12 +91,14 @@ describe("agent document tools", () => { h.connection.close(); }); - test("in review mode an update goes to Approvals", () => { - const h = harness(true); + test("in review mode an update is held in Approvals and the document keeps its version", () => { + const h = harness(false); const docId = /doc_id=(doc_\w+)/.exec(h.run("create_document", { title: "Policy", doc_type: "policy", content: "v1" }))![1]; - expect(h.run("update_document", { doc_id: docId, content: "v2", reason: "Tighter wording" })).toContain("queued for board review"); - const approvals = h.connection.db.prepare("select title from approvals where org_id = ? and kind = 'deliverable_review' order by created_at").all(h.orgId); - expect(approvals).toEqual([{ title: "Review policy: Policy" }, { title: "Review policy update: Policy" }]); + h.connection.db.prepare("update organizations set review_deliverables = 1 where id = ?").run(h.orgId); + expect(h.run("update_document", { doc_id: docId, content: "v2", reason: "Tighter wording" })).toContain("held for board review"); + const approvals = h.connection.db.prepare("select title from approvals where org_id = ? and kind = 'deliverable_review'").all(h.orgId); + expect(approvals).toEqual([{ title: "Review policy update: Policy" }]); + expect(h.connection.db.prepare("select version, content from documents where id = ?").get(docId)).toEqual({ version: 1, content: "v1" }); h.connection.close(); }); }); diff --git a/tests/unit/agent-goal-tools.test.ts b/tests/unit/agent-goal-tools.test.ts index 3d07cc9a..c782ffb1 100644 --- a/tests/unit/agent-goal-tools.test.ts +++ b/tests/unit/agent-goal-tools.test.ts @@ -117,6 +117,22 @@ describe("complete_goal with independent audit", () => { h.connection.close(); }); + test("work held for board review keeps the goal open, even when held while the audit runs", async () => { + const h = createHarness(); + const hold = () => h.connection.db.prepare(` + insert into held_deliverables(id, org_id, approval_id, kind, goal_id, title, doc_type, content, state, created_at) + values (?, ?, 'app_x', 'document_create', ?, 'Launch memo', 'memo', 'draft', 'held', ?) + `).run(`hld_${randomUUID()}`, h.orgId, h.goalId, new Date().toISOString()); + const auditor = async (): Promise => { + hold(); + return turnOf(""); + }; + const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "src/index.ts works" }))); + expect(output).toBe('ERROR: Goal "Ship it" has work awaiting board review (Launch memo); it is not written yet, so the goal cannot be achieved.'); + expect(goalStatus(h)).toBe("active"); + h.connection.close(); + }); + test("no explicit approval means not achieved", async () => { const h = createHarness(); const auditor = async (): Promise => turnOf("Looks plausible I guess."); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 427a143b..c0cac246 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86]); connection.close(); }); diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index ab308719..8c2ac8bd 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -71,17 +71,87 @@ function pendingApprovals(h: Harness, kind: string): Array<{ state: string; sour } describe("execution modes", () => { - test("AI Co-Founder mode queues every deliverable and document for board review", () => { + test("AI Co-Founder mode holds every deliverable and document until the board approves it", () => { const h = harness("ai_cofounder"); + const db = h.connection.db; + const root = h.ctx.workspace!.localPath; const wrote = dispatchAgentTool(h.deps, h.ctx, call("write_deliverable", { path: "plan/business-plan.md", content: "# Plan" })); - expect(wrote).toContain("queued for board review"); + expect(wrote).toContain("held for board review"); const doc = dispatchAgentTool(h.deps, h.ctx, call("create_document", { title: "Launch memo", doc_type: "memo", content: "Ship it." })); - expect(doc).toContain("queued for board review"); + expect(doc).toContain("held for board review"); + // Nothing exists for anyone to use while the reviews wait. + expect(existsSync(join(root, "plan", "business-plan.md"))).toBe(false); + expect(db.prepare("select count(*) as n from documents where org_id = ?").get(h.orgId)).toEqual({ n: 0 }); - const reviews = pendingApprovals(h, "deliverable_review"); - expect(reviews).toHaveLength(2); - expect(reviews.every((review) => review.state === "pending")).toBe(true); - expect(reviews.map((review) => review.source_entity_type).sort()).toEqual(["document", "file"]); + const reviews = db.prepare("select id, state, source_entity_type, details from approvals where org_id = ? and kind = 'deliverable_review' order by rowid").all(h.orgId) as Array<{ id: string; state: string; source_entity_type: string; details: string }>; + expect(reviews.map((review) => [review.state, review.source_entity_type])).toEqual([["pending", "held_deliverable"], ["pending", "held_deliverable"]]); + expect(reviews[0].details).toContain("# Plan"); + expect(reviews[1].details).toContain("Ship it."); + const employeeId = (db.prepare("select id from employees where org_id = ?").get(h.orgId) as { id: string }).id; + for (const review of reviews) { + h.connection.transaction(() => new ApprovalService(db).decide({ orgId: h.orgId, approvalId: review.id, state: "approved", decidedByEmployeeId: employeeId })); + } + expect(readFileSync(join(root, "plan", "business-plan.md"), "utf8")).toBe("# Plan"); + expect(db.prepare("select title, content from documents where org_id = ?").all(h.orgId)).toEqual([{ title: "Launch memo", content: "Ship it." }]); + }); + + test("a declined deliverable is never written, and held work keeps its task open", () => { + const h = harness("ai_cofounder"); + const db = h.connection.db; + const employeeId = (db.prepare("select id from employees where org_id = ?").get(h.orgId) as { id: string }).id; + const taskId = h.connection.transaction(() => h.deps.organization.createTask({ + orgId: h.orgId, title: "Write the plan", description: "d", status: "in_progress", assigneeAgentId: h.ceoId, risk: "low", + projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false, + })); + const ctx = { ...h.ctx, taskId }; + dispatchAgentTool(h.deps, ctx, call("write_deliverable", { path: "plan.md", content: "draft" })); + const finish = () => h.deps.organization.agentSetTaskStatus({ orgId: h.orgId, taskId, status: "done", agentId: h.ceoId, evidence: "plan.md" }); + expect(finish).toThrow('"Write the plan" has work awaiting board review (plan.md); it is not written yet, so the task cannot be done.'); + const review = (db.prepare("select id from approvals where org_id = ? and kind = 'deliverable_review'").get(h.orgId) as { id: string }).id; + h.connection.transaction(() => new ApprovalService(db).decide({ orgId: h.orgId, approvalId: review, state: "declined", decidedByEmployeeId: employeeId })); + expect(existsSync(join(h.ctx.workspace!.localPath, "plan.md"))).toBe(false); + expect(db.prepare("select state from held_deliverables").get()).toEqual({ state: "declined" }); + finish(); + }); + + test("an approved file whose registration fails leaves the workspace as it was", () => { + const h = harness("ai_cofounder"); + const db = h.connection.db; + const root = h.ctx.workspace!.localPath; + writeFileSync(join(root, "kept.md"), "old"); + dispatchAgentTool(h.deps, h.ctx, call("write_deliverable", { path: "kept.md", content: "new" })); + dispatchAgentTool(h.deps, h.ctx, call("write_deliverable", { path: "fresh.md", content: "new" })); + db.exec("create trigger registry_down before insert on file_objects begin select raise(abort, 'registry down'); end;"); + const employeeId = (db.prepare("select id from employees where org_id = ?").get(h.orgId) as { id: string }).id; + for (const review of db.prepare("select id from approvals where kind = 'deliverable_review'").all() as Array<{ id: string }>) { + expect(() => h.connection.transaction(() => new ApprovalService(db).decide({ orgId: h.orgId, approvalId: review.id, state: "approved", decidedByEmployeeId: employeeId }))) + .toThrow("registry down"); + } + expect(readFileSync(join(root, "kept.md"), "utf8")).toBe("old"); + expect(existsSync(join(root, "fresh.md"))).toBe(false); + expect(db.prepare("select state from held_deliverables order by rowid").all()).toEqual([{ state: "held" }, { state: "held" }]); + // A failing state change is caught before anything is written. + db.exec("drop trigger registry_down; create trigger held_down before update on held_deliverables begin select raise(abort, 'held store down'); end;"); + const first = (db.prepare("select id from approvals where kind = 'deliverable_review' order by rowid").get() as { id: string }).id; + expect(() => h.connection.transaction(() => new ApprovalService(db).decide({ orgId: h.orgId, approvalId: first, state: "approved", decidedByEmployeeId: employeeId }))) + .toThrow("held store down"); + expect(readFileSync(join(root, "kept.md"), "utf8")).toBe("old"); + }); + + test("a held document update is refused at approval if the document moved on meanwhile", () => { + const h = harness("ai_ceo"); + const db = h.connection.db; + const created = dispatchAgentTool(h.deps, h.ctx, call("create_document", { title: "Memo", doc_type: "memo", content: "v1" })); + const docId = /doc_id=([^,]+)/.exec(created)![1]; + db.prepare("update organizations set review_deliverables = 1 where id = ?").run(h.orgId); + expect(dispatchAgentTool(h.deps, h.ctx, call("update_document", { doc_id: docId, content: "v2 draft", reason: "tighten" }))).toContain("held for board review"); + expect(db.prepare("select version, content from documents where id = ?").get(docId)).toEqual({ version: 1, content: "v1" }); + db.prepare("update documents set version = 2 where id = ?").run(docId); + const employeeId = (db.prepare("select id from employees where org_id = ?").get(h.orgId) as { id: string }).id; + const review = (db.prepare("select id from approvals where org_id = ? and kind = 'deliverable_review'").get(h.orgId) as { id: string }).id; + expect(() => h.connection.transaction(() => new ApprovalService(db).decide({ orgId: h.orgId, approvalId: review, state: "approved", decidedByEmployeeId: employeeId }))) + .toThrow("The document moved from v1 to v2 after this draft was held; decline the review and ask for a fresh draft."); + expect(db.prepare("select state from approvals where id = ?").get(review)).toEqual({ state: "pending" }); }); test("AI CEO mode ships deliverables without review", () => {