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:
1 parent
ff9a52324e
commit
686140ae55
10 files changed
+357
-91
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<typeof createServiceGraph>;
|
||||
ipcSupport: ReturnType<typeof createIpcSupport>;
|
||||
threadIntegration: ReturnType<typeof createThreadIntegration>;
|
||||
checkpointEngine: ReturnType<typeof createCheckpointEngine>;
|
||||
}) => 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) => {
|
||||
|
||||
@@ -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<typeof createServiceGraph>;
|
||||
type ThreadIntegration = ReturnType<typeof createThreadIntegration>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof createCheckpointEngine>,
|
||||
"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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -51,6 +53,13 @@ export interface RegisterDomainHandlersDependencies {
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService> | 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";
|
||||
@@ -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<ReturnType<typeof bootstrapApplication>>;
|
||||
|
||||
@@ -21,6 +22,7 @@ interface MainRuntimeState {
|
||||
serviceGraph: BootstrapResult["serviceGraph"] | null;
|
||||
ipcSupport: BootstrapResult["ipcSupport"] | null;
|
||||
threadIntegration: ThreadIntegrationService | null;
|
||||
checkpointEngine: ReturnType<typeof createCheckpointEngine> | 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<BootstrapResult["ipcSupport"]["emitEvent"]>[0]) {
|
||||
requireIpcSupport().emitEvent(event);
|
||||
}
|
||||
@@ -191,6 +207,8 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
}
|
||||
publishDomainChanged();
|
||||
},
|
||||
threadIntegration: requireThreadIntegration(),
|
||||
checkpointEngine: requireCheckpointEngine(),
|
||||
});
|
||||
|
||||
const piRuntime = requireServiceGraph().getPiRuntime();
|
||||
|
||||
@@ -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<ReturnType<typeof bootstrapApplication>>["serviceGraph"] | null;
|
||||
ipcSupport: Awaited<ReturnType<typeof bootstrapApplication>>["ipcSupport"] | null;
|
||||
threadIntegration: ThreadIntegrationService | null;
|
||||
checkpointEngine: ReturnType<typeof createCheckpointEngine> | 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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user