diff --git a/src/main/api-server/routes-files.ts b/src/main/api-server/routes-files.ts index 4ef8f0e6..c797fb14 100644 --- a/src/main/api-server/routes-files.ts +++ b/src/main/api-server/routes-files.ts @@ -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) => { diff --git a/src/main/api-server/server-core.ts b/src/main/api-server/server-core.ts index 21842711..fd38005c 100644 --- a/src/main/api-server/server-core.ts +++ b/src/main/api-server/server-core.ts @@ -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), diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts index a9ff2e31..95028599 100644 --- a/src/main/bootstrap-application.ts +++ b/src/main/bootstrap-application.ts @@ -6,6 +6,7 @@ import { prepareHostEnvironment } from "./host-environment-service"; import type { createServiceGraph } from "./service-graph"; import type { createIpcSupport } from "./ipc-support"; import type { createThreadIntegration } from "./thread-integration"; +import type { createCheckpointEngine } from "./checkpoint-engine"; import { prepareLogger } from "./bootstrap/prepare-logger"; import { prepareDatabase } from "./bootstrap/prepare-database"; import { prepareServices } from "./bootstrap/prepare-services"; @@ -37,6 +38,7 @@ interface BootstrapApplicationDependencies { serviceGraph: ReturnType; ipcSupport: ReturnType; threadIntegration: ReturnType; + checkpointEngine: ReturnType; }) => void; } @@ -95,7 +97,7 @@ export async function bootstrapApplication(deps: BootstrapApplicationDependencie apiServerRef.current = apiServer; await apiServer.start(); - deps.onRuntimeReady?.({ db, apiServer, serviceGraph, ipcSupport, threadIntegration }); + deps.onRuntimeReady?.({ db, apiServer, serviceGraph, ipcSupport, threadIntegration, checkpointEngine }); if (apiServer && serviceGraph.getBrowserActionService()) { apiServer.setBrowserActionHandler(async (actionId, companyId) => { diff --git a/src/main/bootstrap/wire-api-server.ts b/src/main/bootstrap/wire-api-server.ts index 5f0e298d..e5a479ea 100644 --- a/src/main/bootstrap/wire-api-server.ts +++ b/src/main/bootstrap/wire-api-server.ts @@ -7,6 +7,11 @@ import type { createServiceGraph } from "../service-graph"; import type { createThreadIntegration } from "../thread-integration"; import type { createCheckpointEngine } from "../checkpoint-engine"; import type { AutomationEventContext } from "../automation/types"; +import { + createDocumentWithLifecycle, + type DocumentLifecycleInput, + type DocumentLifecycleOutcome, +} from "../document-lifecycle"; type ServiceGraph = ReturnType; type ThreadIntegration = ReturnType; @@ -21,7 +26,7 @@ export interface CreateApiServerFn { 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; + createDocument: (input: DocumentLifecycleInput) => DocumentLifecycleOutcome; onAutoAssignTask: (taskId: string, companyId: string) => string | null; onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void; injectRuntimeMessage: (recipientAgentId: string, companyId: string, message: AgentMessageRecord) => boolean; @@ -99,11 +104,19 @@ export function wireApiServer(deps: WireApiServerDeps): AgentApiServer { deps.threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary); }, onApprovalResolved, - onDocumentCreated: (companyId, authorAgentId, projectId, documentId, documentTitle, documentType, taskId) => { - dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined }); - deps.threadIntegration.onDocumentCreated(companyId, authorAgentId, taskId, documentTitle); - deps.checkpointEngine.onDocumentDrafted(companyId, documentId, documentTitle, documentType, authorAgentId, taskId); - }, + createDocument: (input) => + createDocumentWithLifecycle( + { + db: deps.db, + threadIntegration: deps.threadIntegration, + checkpointEngine: deps.checkpointEngine, + dispatchAutomation, + wakeAgentIfPossible, + publishDomainChanged: deps.publishDomainChanged, + logger: log, + }, + input, + ), onAutoAssignTask: (taskId, companyId) => deps.db.autoAssignTask(taskId, companyId), onHumanMessage: (companyId, toAgentId, threadId, waitStateId) => { deps.threadIntegration.onHumanMessage(companyId, toAgentId, threadId, waitStateId); diff --git a/src/main/document-lifecycle.ts b/src/main/document-lifecycle.ts new file mode 100644 index 00000000..5c7a0cce --- /dev/null +++ b/src/main/document-lifecycle.ts @@ -0,0 +1,231 @@ +/** + * Unified document-creation lifecycle. + * + * Previous layout had two divergent entry points: the HTTP API + * (`routes-files.ts`) fired thread, checkpoint, automation, reviewer-wake, + * and deliverable-review side effects, while the desktop IPC save path + * (`ipc-content-operations-handlers.ts`) only fired automation and the + * reviewer wake. A document created from the API and one created from the + * desktop reviewer ended up in inconsistent thread-event and checkpoint + * states, so the UI showed different lifecycle progress depending on + * which surface produced the row. + * + * This module is the single owner of "what happens when a new document + * lands in the DB". Both entry points call `createDocumentWithLifecycle` + * and rely on its deterministic fan-out. Update-only edits (when `id` is + * present) skip the creation-time side effects but still publish the + * domain-change event via `publishDomainChanged`. + */ + +import log from "electron-log/main.js"; +import { IMPORTANT_DOC_TYPE_SET } from "@shared/document-categories"; +import type { AppDatabase } from "./database"; +import type { ThreadIntegrationService } from "./thread-integration"; +import type { createCheckpointEngine } from "./checkpoint-engine"; + +export type CheckpointEngineLike = Pick< + ReturnType, + "onDocumentDrafted" +>; + +export interface DocumentLifecycleDeps { + db: AppDatabase; + threadIntegration: ThreadIntegrationService; + checkpointEngine: CheckpointEngineLike; + dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void; + wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null; + publishDomainChanged: () => void; + logger: { + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; +} + +export interface DocumentLifecycleInput { + id?: string | null; + companyId: string; + type: string; + title: string; + content: string; + authorAgentId: string | null; + reviewerAgentId: string | null; + projectId: string | null; + goalId: string | null; + parentDocId: string | null; + version: number; + status: string; + tagsJson: string; + /** Run that produced the save call, or null for operator-authored saves. */ + sourceRunId: string | null; +} + +export interface DocumentLifecycleOutcome { + id: string; + isNew: boolean; + /** Task derived from the originating run, if any — used for thread/approval binding. */ + taskId: string | null; + /** Approval raised by the deliverable-review gate, when applicable. */ + deliverableReviewApprovalId: string | null; +} + +/** + * Save a document and dispatch every downstream side effect associated + * with its creation. Side effects are fired inside individual `try`s + * because this is the authoritative fanout — an early throw should not + * prevent later hooks from running (e.g. an automation failure must not + * suppress the thread event). + */ +export function createDocumentWithLifecycle( + deps: DocumentLifecycleDeps, + input: DocumentLifecycleInput, +): DocumentLifecycleOutcome { + const isNew = !input.id; + const id = deps.db.saveDocument(input); + const taskId = deriveTaskIdFromRun(deps.db, input.sourceRunId, input.companyId, deps.logger); + + if (isNew) { + fireCreationHooks(deps, input, id, taskId); + } + + let deliverableReviewApprovalId: string | null = null; + if (isNew && IMPORTANT_DOC_TYPE_SET.has(input.type)) { + deliverableReviewApprovalId = maybeRaiseDeliverableReviewApproval(deps, input, id, taskId); + } + + deps.publishDomainChanged(); + return { id, isNew, taskId, deliverableReviewApprovalId }; +} + +function fireCreationHooks( + deps: DocumentLifecycleDeps, + input: DocumentLifecycleInput, + documentId: string, + taskId: string | null, +): void { + try { + deps.dispatchAutomation("document_created", { + companyId: input.companyId, + agentId: input.authorAgentId ?? undefined, + projectId: input.projectId ?? undefined, + }); + } catch (error) { + deps.logger.warn( + `[document-lifecycle] dispatchAutomation failed for document ${documentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + deps.threadIntegration.onDocumentCreated( + input.companyId, + input.authorAgentId, + taskId, + input.title, + ); + } catch (error) { + deps.logger.warn( + `[document-lifecycle] threadIntegration.onDocumentCreated failed for ${documentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + deps.checkpointEngine.onDocumentDrafted( + input.companyId, + documentId, + input.title, + input.type, + input.authorAgentId, + taskId, + ); + } catch (error) { + deps.logger.warn( + `[document-lifecycle] checkpointEngine.onDocumentDrafted failed for ${documentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (input.reviewerAgentId && input.reviewerAgentId !== input.authorAgentId) { + try { + deps.wakeAgentIfPossible(input.reviewerAgentId, input.companyId, "assignment"); + } catch (error) { + deps.logger.warn( + `[document-lifecycle] reviewer wake failed for ${documentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +function deriveTaskIdFromRun( + db: AppDatabase, + runId: string | null, + companyId: string, + logger: DocumentLifecycleDeps["logger"], +): string | null { + if (!runId) return null; + try { + const run = db.getRun(runId); + if (run.companyId !== companyId) return null; + return run.taskId ?? null; + } catch (error) { + logger.warn( + `[document-lifecycle] getRun(${runId}) failed while deriving taskId: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} + +/** + * Raises a `deliverable_review` approval when the company has opted in, + * the document type is important, execution mode is `ai_ceo` (cofounder + * mode handles reviews via the checkpoint engine), and no pending + * deliverable review already exists. Mirrors the gate that used to live + * inline in `routes-files.ts`; consolidating it here ensures operator- + * created documents get the same reviewer treatment. + */ +function maybeRaiseDeliverableReviewApproval( + deps: DocumentLifecycleDeps, + input: DocumentLifecycleInput, + documentId: string, + taskId: string | null, +): string | null { + try { + const companySettings = deps.db.getCompanyApprovalSettings(input.companyId); + if (!companySettings?.reviewDeliverables) return null; + if (deps.db.getCompanyExecutionMode(input.companyId) === "ai_cofounder") return null; + if (deps.db.hasPendingDeliverableReviews(input.companyId)) return null; + + const approvalId = deps.db.requestApproval({ + companyId: input.companyId, + relatedTaskId: taskId, + requestedByAgentId: input.authorAgentId ?? "", + type: "deliverable_review", + payloadSummary: `Review deliverable: ${input.title}`.slice(0, 280), + impactSummary: `New ${input.type.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400), + payloadJson: JSON.stringify({ + documentIds: [documentId], + documents: [{ id: documentId, type: input.type, title: input.title }], + }), + }); + + if (input.authorAgentId) { + try { + const agent = deps.db.getAgent(input.authorAgentId); + if (agent.heartbeatEnabled) { + deps.db.setHeartbeat(input.authorAgentId, false, agent.heartbeatIntervalSec || 120); + } + } catch (error) { + deps.logger.warn( + `[document-lifecycle] heartbeat pause failed for agent ${input.authorAgentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return approvalId; + } catch (error) { + deps.logger.warn( + `[document-lifecycle] deliverable-review gate failed for document ${documentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} + +/** Re-export for backwards-compatible logging in callers that do not have electron-log wired. */ +export const defaultLogger: DocumentLifecycleDeps["logger"] = log; diff --git a/src/main/ipc-content-operations-handlers.ts b/src/main/ipc-content-operations-handlers.ts index 92b092ab..fa9e52b5 100644 --- a/src/main/ipc-content-operations-handlers.ts +++ b/src/main/ipc-content-operations-handlers.ts @@ -7,6 +7,7 @@ import { sprintInputSchema, } from "@shared/contracts"; import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; +import { createDocumentWithLifecycle } from "./document-lifecycle"; const COMPANY_SCOPED_REFERENCE_TABLES = new Set([ "agents", @@ -32,6 +33,8 @@ export function registerContentOperationsHandlers({ publishDomainChanged, wakeAgentIfPossible, dispatchAutomation, + threadIntegration, + checkpointEngine, logger, }: Pick< RegisterDomainHandlersDependencies, @@ -42,6 +45,8 @@ export function registerContentOperationsHandlers({ | "publishDomainChanged" | "wakeAgentIfPossible" | "dispatchAutomation" + | "threadIntegration" + | "checkpointEngine" | "logger" >) { registerHandle(IPC_CHANNELS.saveMeeting, async (_event, payload) => { @@ -96,20 +101,34 @@ export function registerContentOperationsHandlers({ if (invalidReference) { return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`); } - const isNew = !parsed.id; - const id = db.saveDocument(parsed); - if (isNew) { - dispatchAutomation("document_created", { + const outcome = createDocumentWithLifecycle( + { + db, + threadIntegration, + checkpointEngine, + dispatchAutomation, + wakeAgentIfPossible, + publishDomainChanged, + logger, + }, + { + id: parsed.id ?? null, companyId: parsed.companyId, - agentId: parsed.authorAgentId ?? undefined, - projectId: parsed.projectId ?? undefined, - }); - if (parsed.authorAgentId && parsed.reviewerAgentId) { - wakeAgentIfPossible(parsed.reviewerAgentId, parsed.companyId, "assignment"); - } - } - publishDomainChanged(); - return ok(id); + type: parsed.type, + title: parsed.title, + content: parsed.content ?? "", + authorAgentId: parsed.authorAgentId ?? null, + reviewerAgentId: parsed.reviewerAgentId ?? null, + projectId: parsed.projectId ?? null, + goalId: parsed.goalId ?? null, + parentDocId: parsed.parentDocId ?? null, + version: parsed.version ?? 1, + status: parsed.status ?? "draft", + tagsJson: parsed.tagsJson ?? "[]", + sourceRunId: null, + }, + ); + return ok(outcome.id); }); registerHandle(IPC_CHANNELS.deleteDocument, async (_event, payload) => { diff --git a/src/main/ipc-domain-common.ts b/src/main/ipc-domain-common.ts index 625cbfcb..a4f85a88 100644 --- a/src/main/ipc-domain-common.ts +++ b/src/main/ipc-domain-common.ts @@ -6,6 +6,8 @@ import type { DesktopEvent, SectionId } from "@shared/types"; import type { AppDatabase } from "./database"; import type { createSkillService } from "./skill-service"; import type { createBudgetEnforcementService } from "./budget-enforcement-service"; +import type { CheckpointEngineLike } from "./document-lifecycle"; +import type { ThreadIntegrationService } from "./thread-integration"; interface BrowserActionControl { clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise; @@ -51,6 +53,13 @@ export interface RegisterDomainHandlersDependencies { budgetService: ReturnType | null; /** Called when a human sends a message to resolve thread wait states. */ onHumanMessage?: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void; + /** + * Shared document-creation lifecycle collaborators. Routed through the + * dependency bag so both the IPC save path and the HTTP save path call + * the same `createDocumentWithLifecycle` fan-out. + */ + threadIntegration: ThreadIntegrationService; + checkpointEngine: CheckpointEngineLike; } export { ok, fail } from "./ipc-core-common"; diff --git a/src/main/main-runtime-support.ts b/src/main/main-runtime-support.ts index 0ebe9519..7b064f6c 100644 --- a/src/main/main-runtime-support.ts +++ b/src/main/main-runtime-support.ts @@ -12,6 +12,7 @@ import { registerCredentialIpcHandlers } from "./ipc-credentials-handlers"; import { registerOAuthIpcHandlers } from "./ipc-oauth-handlers"; import { fail } from "./ipc-core-common"; import type { ThreadIntegrationService } from "./thread-integration"; +import type { createCheckpointEngine } from "./checkpoint-engine"; type BootstrapResult = Awaited>; @@ -21,6 +22,7 @@ interface MainRuntimeState { serviceGraph: BootstrapResult["serviceGraph"] | null; ipcSupport: BootstrapResult["ipcSupport"] | null; threadIntegration: ThreadIntegrationService | null; + checkpointEngine: ReturnType | null; } export function createMainRuntimeSupport(state: MainRuntimeState) { @@ -93,6 +95,20 @@ export function createMainRuntimeSupport(state: MainRuntimeState) { return service; } + function requireThreadIntegration() { + if (!state.threadIntegration) { + throw new Error("Thread integration service is not initialized."); + } + return state.threadIntegration; + } + + function requireCheckpointEngine() { + if (!state.checkpointEngine) { + throw new Error("Checkpoint engine is not initialized."); + } + return state.checkpointEngine; + } + function emitEvent(event: Parameters[0]) { requireIpcSupport().emitEvent(event); } @@ -191,6 +207,8 @@ export function createMainRuntimeSupport(state: MainRuntimeState) { } publishDomainChanged(); }, + threadIntegration: requireThreadIntegration(), + checkpointEngine: requireCheckpointEngine(), }); const piRuntime = requireServiceGraph().getPiRuntime(); diff --git a/src/main/main-runtime.ts b/src/main/main-runtime.ts index ae215dab..df8af993 100644 --- a/src/main/main-runtime.ts +++ b/src/main/main-runtime.ts @@ -5,6 +5,7 @@ import { bootstrapApplication } from "./bootstrap-application"; import { AppDatabase } from "./database"; import { createMainRuntimeSupport } from "./main-runtime-support"; import type { ThreadIntegrationService } from "./thread-integration"; +import type { createCheckpointEngine } from "./checkpoint-engine"; interface CreateMainRuntimeDependencies { hasSingleInstanceLock: boolean; @@ -37,6 +38,7 @@ export function createMainRuntime({ serviceGraph: Awaited>["serviceGraph"] | null; ipcSupport: Awaited>["ipcSupport"] | null; threadIntegration: ThreadIntegrationService | null; + checkpointEngine: ReturnType | null; isQuitting: boolean; } = { db: null, @@ -44,6 +46,7 @@ export function createMainRuntime({ serviceGraph: null, ipcSupport: null, threadIntegration: null, + checkpointEngine: null, isQuitting: false, }; const support = createMainRuntimeSupport(state); @@ -80,12 +83,13 @@ export function createMainRuntime({ state.isQuitting = value; }, }, - onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport, threadIntegration }) => { + onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport, threadIntegration, checkpointEngine }) => { state.db = db; state.apiServer = apiServer; state.serviceGraph = serviceGraph; state.ipcSupport = ipcSupport; state.threadIntegration = threadIntegration; + state.checkpointEngine = checkpointEngine; // Register IPC handlers immediately so the renderer can call loadSnapshot() // as soon as the window appears — before connector health checks finish. support.registerHandlers(); diff --git a/tests/integration/document-run-provenance.spec.ts b/tests/integration/document-run-provenance.spec.ts index eb7b3ace..19fa02f6 100644 --- a/tests/integration/document-run-provenance.spec.ts +++ b/tests/integration/document-run-provenance.spec.ts @@ -6,6 +6,7 @@ import { createAgentToken, initializeAgentTokenSigningKey } from "../../src/main import { createApiServer } from "../../src/main/api-server"; import { AppDatabase } from "../../src/main/database"; import { processRunCompletion } from "../../src/main/automation/automation-run-completion"; +import { createDocumentWithLifecycle } from "../../src/main/document-lifecycle"; const tempDirs: string[] = []; @@ -89,7 +90,24 @@ async function createHarness() { connectorId: "openai-codex", }); const token = createAgentToken(agentId, companyId, run.id); - const server = createApiServer(db, () => {}, () => null); + const silentLogger = { warn: () => {}, error: () => {} }; + const server = createApiServer(db, () => {}, () => null, { + createDocument: (input) => + createDocumentWithLifecycle( + { + db, + threadIntegration: { + onDocumentCreated: () => {}, + } as never, + checkpointEngine: { onDocumentDrafted: () => null } as never, + dispatchAutomation: () => {}, + wakeAgentIfPossible: () => null, + publishDomainChanged: () => {}, + logger: silentLogger, + }, + input, + ), + }); const port = await server.start(); return {