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>
199 lines
6.4 KiB
TypeScript
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}`;
|
|
}
|