Propagate summary.duty_policy into deliverables/AST and show 编制人兼任 on the action bar. Co-authored-by: Cursor <cursoragent@cursor.com>
252 lines
8.4 KiB
TypeScript
252 lines
8.4 KiB
TypeScript
import { fetchControlledDocumentContent } from '../api/projectDocuments';
|
|
import { getRevisionState } from '../api/documentRevision';
|
|
import { markdownToBlockAST } from '../engine/markdownToBlock';
|
|
import { applyHeadBlockMap } from '../features/documentRevision/overlayMatch';
|
|
import {
|
|
isLegacyLocalMockDocId,
|
|
shouldMountDocumentRevisionToolbar,
|
|
} from '../features/documentRevision/revisionToolbarMount';
|
|
import type { RevisionState } from '../features/documentRevision/types';
|
|
import type { DocumentAST } from '../types/block';
|
|
import type { DeliverableItem } from '../types/workbench';
|
|
import { revisionEtagFromHydrate } from './revisionEtag';
|
|
|
|
/** Tests may stub a slice; production getRevisionState returns the full ledger. */
|
|
export type HydrateRevisionState = Pick<RevisionState, 'etag'> &
|
|
Partial<Pick<RevisionState, 'head_block_map'>>;
|
|
|
|
export function isControlledWorkflowDeliverable(doc: DeliverableItem): boolean {
|
|
return (
|
|
doc.isFixedWorkflow !== false &&
|
|
doc.governanceMode !== 'freeform' &&
|
|
!!doc.taskKey &&
|
|
doc.taskKey !== 'freeform_doc'
|
|
);
|
|
}
|
|
|
|
/** 服务端已有正文、应拉取 output.md 而非展示本地模板。
|
|
* 资格必须与 revisionToolbarMountMode 的 server-backed 挂载一致:
|
|
* doc-001 等本地 legacy/mock 与未注册 orphan 绝不发 content/revision。 */
|
|
export function shouldLoadControlledContent(doc: DeliverableItem): boolean {
|
|
if (!isControlledWorkflowDeliverable(doc) || doc.isTemplateOnly) return false;
|
|
if (isLegacyLocalMockDocId(doc.docId)) return false;
|
|
return shouldMountDocumentRevisionToolbar(doc);
|
|
}
|
|
|
|
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,
|
|
dutyPolicy: doc.dutyPolicy,
|
|
});
|
|
ast.governanceMode = 'controlled';
|
|
ast.editLease = doc.editLease;
|
|
ast.dutyPolicy = doc.dutyPolicy;
|
|
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,
|
|
dutyPolicy: doc.dutyPolicy,
|
|
});
|
|
ast.governanceMode = 'controlled';
|
|
ast.editLease = doc.editLease;
|
|
ast.dutyPolicy = doc.dutyPolicy;
|
|
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,
|
|
dutyPolicy: doc.dutyPolicy,
|
|
});
|
|
fullAST.governanceMode = 'controlled';
|
|
fullAST.editLease = doc.editLease;
|
|
fullAST.dutiesAuthoritative = doc.dutiesAuthoritative ?? true;
|
|
fullAST.dutyPolicy = doc.dutyPolicy;
|
|
return fullAST;
|
|
}
|
|
|
|
export function controlledHydrateFingerprint(doc: DeliverableItem): string {
|
|
return [
|
|
doc.docId,
|
|
String(doc.isTemplateOnly),
|
|
doc.taskKey ?? '',
|
|
String(doc.stage ?? ''),
|
|
doc.updatedAt ?? '',
|
|
].join('|');
|
|
}
|
|
|
|
const inflightHydrates = new Map<string, Promise<DocumentAST | null>>();
|
|
|
|
export async function hydrateControlledDocumentContent(
|
|
docId: string,
|
|
doc: DeliverableItem,
|
|
deps?: {
|
|
fetchContent?: typeof fetchControlledDocumentContent;
|
|
fetchRevisionState?: (id: string) => Promise<HydrateRevisionState>;
|
|
},
|
|
): Promise<DocumentAST | null> {
|
|
if (isLegacyLocalMockDocId(docId) || !shouldLoadControlledContent({ ...doc, docId })) {
|
|
return null;
|
|
}
|
|
const key = `${docId}::${controlledHydrateFingerprint(doc)}`;
|
|
const existing = inflightHydrates.get(key);
|
|
if (existing) return existing;
|
|
|
|
const pending = (async () => {
|
|
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 }),
|
|
),
|
|
]);
|
|
// Typed consistency (missing/drift/empty) is not a 404. Only the absence
|
|
// of ledger markdown fails hydrate; never refetch to "repair" a missing projection.
|
|
if (detail.consistency === 'ledger_missing' || !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 = applyHeadBlockMap(
|
|
buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version),
|
|
rev.st.head_block_map,
|
|
);
|
|
ast.revisionEtag = etag;
|
|
ast.dutiesAuthoritative = doc.dutiesAuthoritative ?? true;
|
|
ast.dutyPolicy = doc.dutyPolicy;
|
|
return ast;
|
|
})().finally(() => {
|
|
if (inflightHydrates.get(key) === pending) {
|
|
inflightHydrates.delete(key);
|
|
}
|
|
});
|
|
inflightHydrates.set(key, pending);
|
|
return pending;
|
|
}
|
|
|
|
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}`;
|
|
}
|