Files
AgentCompany/src/main/document-lifecycle.ts
T
ZacharyZhang-NY 118e3d8795 Remove final AppDatabase pass-through methods with ESLint guard (C1 part 3/3)
Migrate all remaining pass-through call sites (social, skills, budget,
entityCrud, documents, knowledge, automation, wakeup, backup, threads,
settings, snapshot, connectors, workspaces, activity) to their owning
domain modules. `AppDatabase` now carries only three methods:

  - createHireRequest (multi-module composite: agents + approvalsMod)
  - rollbackConfigRevision (multi-module composite: documents + entityCrud)
  - queryAll (raw-SQL escape hatch, @internal)

The vault/env-binding infrastructure previously passed argument-by-
argument into `entityCrud.saveSecret`/`resolveSecretEnv` now binds at
module construction, letting callers invoke `db.entityCrud.saveSecret({...})`
without carrying vault plumbing.

Structural types that narrowed AppDatabase to a bag of flat methods
(`ConnectorModelPolicyDeps` callers, `CapabilityMatchDb`, test mocks in
budget-enforcement / budget-incidents / task-quality-gates /
failure-escalation / task-quality-gates-sweep) are reshaped to the new
namespace. `connectorPolicyDepsFromDb` replaces the old pattern of
handing the whole AppDatabase to `validateConnectorModelSelection`.

A new ESLint rule in eslint.config.mjs blocks new top-level methods on
AppDatabase so the pass-through layer cannot silently grow back.
`AppDatabase.ts` dropped from 193 lines to 58; `AppDatabaseBase` lost
the 270-line trailing pass-through block.
2026-04-20 13:31:39 -04:00

225 lines
7.7 KiB
TypeScript

/**
* Unified document-creation lifecycle.
*
* Two invariants:
*
* 1. DB-writing hooks (thread events, checkpoint approval,
* deliverable-review approval) commit atomically with the
* document row. A failure in any of them rolls back the save so
* the operator never sees a live document with a missing thread
* or a skipped approval.
*
* 2. Cross-process side effects (automation dispatch, reviewer wake)
* are durable via the `document_lifecycle_events` ledger. Every
* hook attempt lands a row and the processor stamps its outcome;
* a crash between "document saved" and "reviewer woken" is
* observable as a `failed` ledger row instead of a silent gap.
*
* The HTTP API route and the desktop IPC handler both call
* `createDocumentWithLifecycle`, so both surfaces end up in the same
* state after a save — no more divergence between the two entry points.
*/
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";
import type { DocumentLifecycleProcessor } from "./document-lifecycle-processor";
import { classifyUnknownError } from "./errors/index.js";
export type CheckpointEngineLike = Pick<
ReturnType<typeof createCheckpointEngine>,
"onDocumentDrafted"
>;
export interface DocumentLifecycleDeps {
db: AppDatabase;
threadIntegration: ThreadIntegrationService;
checkpointEngine: CheckpointEngineLike;
/**
* Drains the ledger rows this save enqueued. The processor reads from
* `document_lifecycle_events` (not from an in-memory closure) so a
* crash between commit and dispatch is self-healing — the next sweep
* picks up anything left as `pending`. Callers still see the immediate
* happy-path outcome because `drainForDocument` is synchronous.
*/
lifecycleProcessor: Pick<DocumentLifecycleProcessor, "drainForDocument">;
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;
sourceRunId: string | null;
}
export interface DocumentLifecycleOutcome {
id: string;
isNew: boolean;
taskId: string | null;
deliverableReviewApprovalId: string | null;
}
export type SideEffectHook = "automation_dispatch" | "reviewer_wake";
export function createDocumentWithLifecycle(
deps: DocumentLifecycleDeps,
input: DocumentLifecycleInput,
): DocumentLifecycleOutcome {
const outcome = deps.db.withTransaction(() => {
const isNew = !input.id;
const id = deps.db.documentsMod.saveDocument(input);
const taskId = deriveTaskIdFromRun(deps.db, input.sourceRunId, input.companyId, deps.logger);
let deliverableReviewApprovalId: string | null = null;
if (isNew) {
// DB-writing hooks run inside the transaction. Any throw rolls
// back `saveDocument` along with the thread event / approvals.
deps.threadIntegration.onDocumentCreated(
input.companyId,
input.authorAgentId,
taskId,
input.title,
);
deps.checkpointEngine.onDocumentDrafted(
input.companyId,
id,
input.title,
input.type,
input.authorAgentId,
taskId,
);
if (IMPORTANT_DOC_TYPE_SET.has(input.type)) {
deliverableReviewApprovalId = maybeRaiseDeliverableReviewApproval(deps, input, id, taskId);
}
// Side effects (cross-process / advisory) land in the ledger as
// `pending` rows. The processor drains them post-commit — not a
// memoised closure — so a crash between this commit and the first
// handler attempt is self-healing: the periodic sweeper picks the
// row up on its next tick.
deps.db.documentLifecycleEvents.enqueue({
companyId: input.companyId,
documentId: id,
hookType: "automation_dispatch",
payloadJson: JSON.stringify({
trigger: "document_created",
agentId: input.authorAgentId,
projectId: input.projectId,
}),
});
if (input.reviewerAgentId && input.reviewerAgentId !== input.authorAgentId) {
deps.db.documentLifecycleEvents.enqueue({
companyId: input.companyId,
documentId: id,
hookType: "reviewer_wake",
payloadJson: JSON.stringify({ reviewerAgentId: input.reviewerAgentId }),
});
}
}
return { id, isNew, taskId, deliverableReviewApprovalId };
});
if (outcome.isNew) {
// Immediate synchronous drain so the happy path behaves identically
// to the pre-processor layout. A failure inside the processor is
// recorded on the ledger row — the ledger, not an in-memory list,
// is what the periodic sweeper retries against.
try {
deps.lifecycleProcessor.drainForDocument(outcome.id);
} catch (error) {
deps.logger.warn(
`[document-lifecycle] drainForDocument(${outcome.id}) threw: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
deps.publishDomainChanged();
return outcome;
}
function deriveTaskIdFromRun(
db: AppDatabase,
runId: string | null,
companyId: string,
logger: DocumentLifecycleDeps["logger"],
): string | null {
if (!runId) return null;
try {
const run = db.runs.getRun(runId);
if (run.companyId !== companyId) return null;
return run.taskId ?? null;
} catch (error) {
const classified = classifyUnknownError(error, {
kind: "recoverable",
code: "document_lifecycle.get_run.failed",
message: `getRun(${runId}) failed while deriving taskId`,
context: { runId, companyId },
});
logger.warn(
`[${classified.code}] ${classified.message}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
function maybeRaiseDeliverableReviewApproval(
deps: DocumentLifecycleDeps,
input: DocumentLifecycleInput,
documentId: string,
taskId: string | null,
): string | null {
const companySettings = deps.db.company.getCompanyApprovalSettings(input.companyId);
if (!companySettings?.reviewDeliverables) return null;
if (deps.db.company.getCompanyExecutionMode(input.companyId) === "ai_cofounder") return null;
if (deps.db.tasks.hasPendingDeliverableReviews(input.companyId)) return null;
const approvalId = deps.db.approvalsMod.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.entityCrud.getAgent(input.authorAgentId);
if (agent.heartbeatEnabled) {
deps.db.agents.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;
}
export const defaultLogger: DocumentLifecycleDeps["logger"] = log;