Q108: deliverables under review are held until approved instead of written first
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4765683b86
commit
34c5557a32
13 files changed
+325
-87
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string
|
||||
return "ERROR: Only the CEO or the goal's owner can complete a goal. Report your evidence to them instead.";
|
||||
}
|
||||
if (goal.status !== "active") return `Goal "${goal.title}" is already ${goal.status}.`;
|
||||
if (heldWorkTitles(deps.db, { goalId: goal.id }).length > 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<string
|
||||
return `The independent audit rejected closing the goal: ${verdict.reason} The goal stays active — close that gap or create the tasks that will.`;
|
||||
}
|
||||
throwIfRunStopped();
|
||||
// Work held for review while the audit ran keeps the goal open too.
|
||||
if (heldWorkTitles(deps.db, { goalId: goal.id }).length > 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<string
|
||||
}
|
||||
}
|
||||
|
||||
function heldGoalRefusal(deps: GoalCompletionArgs["deps"], goal: { id: string; title: string }): string {
|
||||
const held = heldWorkTitles(deps.db, { goalId: goal.id });
|
||||
return `ERROR: Goal "${goal.title}" has work awaiting board review (${held.join(", ")}); it is not written yet, so the goal cannot be achieved.`;
|
||||
}
|
||||
|
||||
interface AuditVerdict {
|
||||
approved: boolean;
|
||||
reason: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { settleHire } from "./agent-admission";
|
||||
import { settleHeldDeliverable } from "./held-deliverables";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { OrgGovernanceService } from "./org-governance-service";
|
||||
|
||||
@@ -101,6 +102,10 @@ export class ApprovalService {
|
||||
action: input.state,
|
||||
payload: { decidedAt: now },
|
||||
});
|
||||
// Last, so nothing after it can fail once a file is on disk.
|
||||
if (approval.source_entity_type === "held_deliverable" && approval.source_entity_id) {
|
||||
settleHeldDeliverable(this.db, approval.source_entity_id, input.state === APPROVAL_STATE.APPROVED);
|
||||
}
|
||||
}
|
||||
|
||||
private assertRequesterBelongsToOrg(input: CreateApprovalInput): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DatabaseSync } from "node:sqlite";
|
||||
import { FILE_SCAN_STATE, type FileScanState } from "../../../shared/domain";
|
||||
import { createId } from "../../../shared/ids";
|
||||
import { resolveWorkspacePath } from "./workspace-path";
|
||||
import { withSavepoint } from "../../database/connection";
|
||||
|
||||
export interface FileScanResult {
|
||||
state: FileScanState;
|
||||
@@ -155,26 +156,19 @@ export function recordWorkspaceFile(db: DatabaseSync, input: {
|
||||
const name = basename(input.relativePath);
|
||||
const scan = scanFileContent(name, bytes);
|
||||
const now = new Date().toISOString();
|
||||
db.exec("begin immediate");
|
||||
try {
|
||||
const id = 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,
|
||||
});
|
||||
db.exec("commit");
|
||||
return id;
|
||||
} catch (error) {
|
||||
db.exec("rollback");
|
||||
throw error;
|
||||
}
|
||||
// A savepoint, not a transaction: an approved deliverable is recorded inside the approval's own transaction.
|
||||
return withSavepoint(db, "record_workspace_file", () => 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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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).');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<AuditorTurn> => {
|
||||
hold();
|
||||
return turnOf("<approved/>");
|
||||
};
|
||||
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<AuditorTurn> => turnOf("Looks plausible I guess.");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user