Files
AgentCompany/src/main/services/held-deliverables.ts
T

132 lines
7.2 KiB
TypeScript

import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import type { ApprovalPin } from "../../shared/approval-pin";
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;
approval_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 {
// A file path that could never be written is refused now, not at approval; one spelling of each path is kept.
const target = input.target.kind === "file"
? { ...input.target, relativePath: resolveWorkspacePath(input.target.workspaceRoot, input.target.relativePath).relativePosix }
: input.target;
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. Returns the task it belongs to. */
export function settleHeldDeliverable(db: DatabaseSync, heldId: string, approved: boolean): string | null {
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"}`);
return row.task_id;
}
function publish(db: DatabaseSync, row: HeldRow): void {
if (row.kind === "file") {
const { absolute } = resolveWorkspacePath(row.workspace_root!, row.relative_path!);
// The file's pin is known before it is written, so it is recorded first and the disk write stays last.
pinApproval(db, row, { kind: "file", storagePath: absolute, sha256: createHash("sha256").update(row.content, "utf8").digest("hex") });
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") {
const documentId = 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,
});
pinApproval(db, row, { kind: "document", documentId, version: 1 });
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.`);
}
const version = 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!,
});
pinApproval(db, row, { kind: "document", documentId: row.document_id!, version });
}
/** Records on the approval exactly what it approved, so later changes in place show against it. */
function pinApproval(db: DatabaseSync, row: HeldRow, pin: ApprovalPin): void {
db.prepare("update approvals set subject_snapshot = ? where id = ?").run(JSON.stringify(pin), row.approval_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);
}