Route every document save through one lifecycle pipeline

Desktop IPC saves used to fire only automation and the reviewer wake,
while HTTP API saves fired automation, thread integration, checkpoint
engine, deliverable-review approval, and the reviewer wake. Two
divergent paths produced two different lifecycle states for the same
document, and a future fix to either branch would miss the other.

Introduce `document-lifecycle.ts` as the single owner of document
creation side effects: save, derive task binding from the originating
run, fan out to automation/thread/checkpoint, wake the reviewer, then
raise a deliverable-review approval when the document qualifies. The
HTTP route and the IPC handler both call the pipeline — the API
server's `onDocumentCreated` callback is replaced with a
`createDocument` hook that delegates to the pipeline.

Wire `checkpointEngine` through `bootstrap-application` →
`main-runtime` → `main-runtime-support` so the IPC handler bag carries
both thread integration and the checkpoint engine required by the
pipeline. The document-run-provenance integration test harness is
updated to pass a `createDocument` implementation backed by the same
pipeline the production path uses.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 15:13:53 -04:00
1 parent ff9a52324e
commit 686140ae55
10 files changed
+357 -91

No files matched your search

+10 -67
View File
@@ -1,6 +1,5 @@
import log from "electron-log/main.js";
import { z } from "zod";
import { IMPORTANT_DOC_TYPE_SET } from "@shared/document-categories";
import { requireParam, type AgentApiServer } from "./server-core";
const createDocumentSchema = z.object({
@@ -60,7 +59,6 @@ export function registerFileRoutes(server: AgentApiServer) {
const db = server.db;
const callbacks = server.callbacks;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
server.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
@@ -87,7 +85,13 @@ export function registerFileRoutes(server: AgentApiServer) {
}
const docType = data.type ?? "knowledge_article";
const docTitle = data.title ?? "Untitled";
const id = db.saveDocument({
// Single source of truth for document-creation side effects lives in
// `document-lifecycle.ts`. Persistence, thread/checkpoint/automation
// fan-out, reviewer wake, and the deliverable-review gate all fire
// from inside the pipeline so this HTTP route and the desktop IPC
// handler stay byte-for-byte consistent.
const outcome = callbacks.createDocument({
id: null,
companyId: token.companyId,
type: docType,
title: docTitle,
@@ -96,74 +100,13 @@ export function registerFileRoutes(server: AgentApiServer) {
reviewerAgentId,
projectId,
goalId,
parentDocId: null,
version: 1,
status: data.status ?? "draft",
tagsJson: data.tagsJson ?? "[]",
sourceRunId: token.runId ?? null,
});
onDomainChanged();
// Derive taskId from the agent's current run so thread integration
// and checkpoint engine can bind the document to its originating task.
let taskId: string | null = null;
if (token.runId) {
try {
const run = db.getRun(token.runId);
if (run.companyId === token.companyId) {
taskId = run.taskId ?? null;
}
} catch (error) {
log.warn(
`[api/documents] getRun(${token.runId}) failed while deriving taskId: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Fire document creation automation rules
callbacks.onDocumentCreated(token.companyId, token.agentId, projectId, id, docTitle, docType, taskId);
// Wake document reviewer if specified
if (reviewerAgentId) {
onWakeAgent(reviewerAgentId, token.companyId, "assignment");
}
// Deliverable review gate: check at document creation time (most reliable).
// In ai_cofounder mode, checkpoint-engine handles document reviews via the
// onDocumentCreated callback, so skip the direct approval here to avoid duplicates.
if (IMPORTANT_DOC_TYPE_SET.has(docType)) {
try {
const companySettings = db.getCompanyApprovalSettings(token.companyId);
const company = companySettings ? { reviewDeliverables: companySettings.reviewDeliverables } : null;
const isCofounderMode = db.getCompanyExecutionMode(token.companyId) === "ai_cofounder";
if (company?.reviewDeliverables && !isCofounderMode) {
if (!db.hasPendingDeliverableReviews(token.companyId)) {
db.requestApproval({
companyId: token.companyId,
relatedTaskId: taskId,
requestedByAgentId: token.agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280),
impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400),
payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }),
});
try {
const agent = db.getAgent(token.agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120);
}
} catch (error) {
log.warn(
`[api/documents] heartbeat pause for agent ${token.agentId} failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
onDomainChanged();
}
}
} catch (error) {
log.warn(
`[api/documents] deliverable review gate failed for company ${token.companyId}, document ${id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
server.json(res, 201, { id });
server.json(res, 201, { id: outcome.id });
});
server.addRoute("GET", "/api/companies/:companyId/documents", (_req, res, params, token) => {
+11 -2
View File
@@ -3,6 +3,7 @@ import log from "electron-log/main.js";
import { validateAgentToken, type TokenPayload } from "../agent-auth";
import type { AppDatabase } from "../database";
import type { AgentMessageRecord, DesktopEvent, SectionId } from "@shared/types";
import type { DocumentLifecycleInput, DocumentLifecycleOutcome } from "../document-lifecycle";
/**
* Decode an HTTP request body to a UTF-8 string, handling the common Windows
@@ -61,7 +62,14 @@ export interface ApiServerEventCallbacks {
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void;
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null, documentId: string, documentTitle: string, documentType: string, taskId: string | null) => void;
/**
* Persist a document through the shared `document-lifecycle` pipeline.
* Collapses the two previous entry points (this HTTP route and the
* desktop IPC handler) onto a single fan-out so thread, checkpoint,
* automation, and deliverable-review side effects land consistently
* regardless of which surface authored the write.
*/
createDocument: (input: DocumentLifecycleInput) => DocumentLifecycleOutcome;
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void;
/**
@@ -149,7 +157,8 @@ export class AgentApiServer {
onCommentPosted: callbacks?.onCommentPosted ?? (() => {}),
onApprovalCreated: callbacks?.onApprovalCreated ?? (() => {}),
onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}),
onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}),
createDocument: callbacks?.createDocument
?? (() => { throw new Error("createDocument callback is not wired."); }),
onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null),
onHumanMessage: callbacks?.onHumanMessage ?? (() => {}),
injectRuntimeMessage: callbacks?.injectRuntimeMessage ?? (() => false),