Files
Agent-System/web/src/lib/controlledDocHydrate.ts
T
weichenandCursor 4dbd9eecb9 feat(web): fail-closed controlled save without hydrate ETag
Reject revision-state network/5xx and missing ledger on controlled
hydrate; never PATCH 0:0 unless the document is freeform.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 17:08:35 +00:00

199 lines
6.4 KiB
TypeScript

import { fetchControlledDocumentContent } from '../api/projectDocuments';
import { getRevisionState } from '../api/documentRevision';
import { markdownToBlockAST } from '../engine/markdownToBlock';
import type { DocumentAST } from '../types/block';
import type { DeliverableItem } from '../types/workbench';
import { revisionEtagFromHydrate } from './revisionEtag';
export function isControlledWorkflowDeliverable(doc: DeliverableItem): boolean {
return (
doc.isFixedWorkflow !== false &&
doc.governanceMode !== 'freeform' &&
!!doc.taskKey &&
doc.taskKey !== 'freeform_doc'
);
}
/** 服务端已有正文、应拉取 output.md 而非展示本地模板 */
export function shouldLoadControlledContent(doc: DeliverableItem): boolean {
return isControlledWorkflowDeliverable(doc) && !doc.isTemplateOnly;
}
export function buildControlledLoadErrorSkeleton(doc: DeliverableItem): DocumentAST {
const skeleton = `# ${doc.title}\n\n> 加载失败,请点击重试或稍后再试。\n`;
const ast = markdownToBlockAST(skeleton, {
docId: doc.docId,
title: doc.title,
taskKey: doc.taskKey,
version: doc.stage,
category: doc.category,
creatorId: doc.creatorId,
creatorName: doc.creatorName,
lifecycleState: doc.lifecycleState,
isClaimed: doc.isClaimed,
author: doc.author,
authorId: doc.authorId,
reviewer: doc.reviewer,
reviewerId: doc.reviewerId,
approver: doc.approver,
approverId: doc.approverId,
dutiesAuthoritative: doc.dutiesAuthoritative ?? true,
});
ast.governanceMode = 'controlled';
ast.editLease = doc.editLease;
return ast;
}
export function buildControlledLoadingSkeleton(doc: DeliverableItem): DocumentAST {
const skeleton = `# ${doc.title}\n\n> 正在从服务端加载文档正文…\n`;
const ast = markdownToBlockAST(skeleton, {
docId: doc.docId,
title: doc.title,
taskKey: doc.taskKey,
version: doc.stage,
category: doc.category,
creatorId: doc.creatorId,
creatorName: doc.creatorName,
lifecycleState: doc.lifecycleState,
isClaimed: doc.isClaimed,
author: doc.author,
authorId: doc.authorId,
reviewer: doc.reviewer,
reviewerId: doc.reviewerId,
approver: doc.approver,
approverId: doc.approverId,
dutiesAuthoritative: doc.dutiesAuthoritative ?? true,
});
ast.governanceMode = 'controlled';
ast.editLease = doc.editLease;
return ast;
}
export function buildControlledDocumentAST(
markdown: string,
doc: DeliverableItem,
docId: string,
version?: number,
): DocumentAST {
const fullAST = markdownToBlockAST(markdown, {
docId,
title: doc.title,
taskKey: doc.taskKey,
version: `${version ?? doc.stage}`,
category: doc.category,
creatorId: doc.creatorId,
creatorName: doc.creatorName,
lifecycleState: doc.lifecycleState,
isClaimed: doc.isClaimed,
author: doc.author,
authorId: doc.authorId,
reviewer: doc.reviewer,
reviewerId: doc.reviewerId,
approver: doc.approver,
approverId: doc.approverId,
dutiesAuthoritative: doc.dutiesAuthoritative ?? true,
});
fullAST.governanceMode = 'controlled';
fullAST.editLease = doc.editLease;
fullAST.dutiesAuthoritative = doc.dutiesAuthoritative ?? true;
return fullAST;
}
export async function hydrateControlledDocumentContent(
docId: string,
doc: DeliverableItem,
deps?: {
fetchContent?: typeof fetchControlledDocumentContent;
fetchRevisionState?: (id: string) => Promise<{ etag: string }>;
},
): Promise<DocumentAST | null> {
const loadContent = deps?.fetchContent ?? fetchControlledDocumentContent;
const loadRevision = deps?.fetchRevisionState ?? getRevisionState;
const [detail, rev] = await Promise.all([
loadContent(docId),
loadRevision(docId).then(
(st) => ({ ok: true as const, st }),
(err: unknown) => ({ ok: false as const, err }),
),
]);
if (!detail?.markdown) return null;
if (!rev.ok) {
throw rev.err instanceof Error ? rev.err : new Error('revision-state hydrate failed');
}
const etag = revisionEtagFromHydrate(rev.st);
if (!etag) {
throw new Error('受控文档打开时未取得修订基线,请重试');
}
const ast = buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version);
ast.revisionEtag = etag;
ast.dutiesAuthoritative = doc.dutiesAuthoritative ?? true;
return ast;
}
const LEGACY_DOC_P = /^doc-p(\d+)-(.+)$/;
const LEGACY_DOC_PROJ = /^doc-proj(\d+)-(.+)$/;
function isLegacyLocalDocId(docId: string): boolean {
return LEGACY_DOC_P.test(docId) || LEGACY_DOC_PROJ.test(docId);
}
function legacyDocIdToCanonical(docId: string): string | null {
const legacyP = docId.match(LEGACY_DOC_P);
if (legacyP) return `doc_proj${legacyP[1]}_${legacyP[2]}`;
const legacyProj = docId.match(LEGACY_DOC_PROJ);
if (legacyProj) return `doc_proj${legacyProj[1]}_${legacyProj[2]}`;
return null;
}
/** 将 legacy doc-p{projectId}-{taskKey} 映射到服务端 doc_id */
export function resolveCanonicalDocId(
docId: string,
deliverables: DeliverableItem[],
): string {
const direct = deliverables.find((d) => d.docId === docId);
if (direct) return docId;
const legacyCanonical = legacyDocIdToCanonical(docId);
if (!legacyCanonical) return docId;
const projectId = Number(legacyCanonical.match(/^doc_proj(\d+)_/)?.[1] ?? 0);
const taskKey = legacyCanonical.replace(/^doc_proj\d+_/, '');
const slot = deliverables.find(
(d) => d.projectId === projectId && d.taskKey === taskKey,
);
if (slot && !isLegacyLocalDocId(slot.docId)) {
return slot.docId;
}
return legacyCanonical;
}
/** 任务请求使用的权威 doc_id:绝不透传 legacy doc-p* 本地 ID */
export function canonicalDocIdForTask(
projectId: number | undefined,
taskKey: string | undefined,
deliverables?: DeliverableItem[],
rawDocId?: string,
): string | undefined {
if (!projectId || projectId <= 0 || !taskKey) return undefined;
const slot = deliverables?.find(
(d) => d.projectId === projectId && d.taskKey === taskKey,
);
if (slot && !isLegacyLocalDocId(slot.docId)) {
return slot.docId;
}
if (rawDocId?.trim()) {
const resolved = deliverables?.length
? resolveCanonicalDocId(rawDocId, deliverables)
: rawDocId.trim();
if (!isLegacyLocalDocId(resolved)) {
return resolved;
}
const fromRaw = legacyDocIdToCanonical(rawDocId.trim());
if (fromRaw) return fromRaw;
}
return `doc_proj${projectId}_${taskKey}`;
}