Files
Agent-System/web/src/lib/deliverableSync.ts
T
weichenandCursor baca45ab9c fix(web): merge workflow documents by canonical task key
本地旧键模板与服务端 bd_* 行按同一发布键合并,未认领的旧键克隆在服务端已覆盖时丢掉。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 06:37:10 +00:00

193 lines
6.2 KiB
TypeScript

import type { WorkflowDocumentSummary } from '../api/projectDocuments';
import type { DeliverableItem } from '../types/workbench';
import type { DocLifecycleState } from '../types/docgov';
import { isDemoSeedDocId } from '../data/demoSeedIds';
import { canonicalTaskKey } from './taskKeySpace';
function applyAuthoritativeDuties(
base: DeliverableItem,
s: WorkflowDocumentSummary,
isNewSlot: boolean,
): void {
const hasDuties =
s.author_id != null ||
s.author_name != null ||
s.reviewer_id != null ||
s.reviewer_name != null ||
s.approver_id != null ||
s.approver_name != null;
if (hasDuties) {
base.authorId = s.author_id;
base.author = s.author_name || '';
base.reviewerId = s.reviewer_id;
base.reviewer = s.reviewer_name || '';
base.approverId = s.approver_id;
base.approver = s.approver_name || '';
base.dutyPolicy = s.duty_policy || '';
return;
}
if (isNewSlot) {
base.author = '';
base.reviewer = '';
base.approver = '';
base.dutyPolicy = '';
}
}
function mapLifecycleState(raw: string): DocLifecycleState {
switch (raw) {
case 'reviewing':
case 'approving':
case 'approved':
case 'drafting':
return raw;
default:
return 'drafting';
}
}
function preferServerDoc(
current: WorkflowDocumentSummary,
candidate: WorkflowDocumentSummary,
): WorkflowDocumentSummary {
const currentPublished = current.task_key === canonicalTaskKey(current.task_key);
const candidatePublished = candidate.task_key === canonicalTaskKey(candidate.task_key);
if (currentPublished !== candidatePublished) {
return candidatePublished ? candidate : current;
}
if (current.is_template_only !== candidate.is_template_only) {
return candidate.is_template_only ? current : candidate;
}
return candidate.doc_id < current.doc_id ? candidate : current;
}
/** 同一发布键只留一行。发布键优先于旧键,已有正文优先于模板。 */
function collapseServerDocs(
serverDocs: WorkflowDocumentSummary[],
projectId: number,
): WorkflowDocumentSummary[] {
const byCanon = new Map<string, WorkflowDocumentSummary>();
for (const doc of serverDocs) {
if (doc.project_id !== projectId || !doc.task_key) continue;
const canon = canonicalTaskKey(doc.task_key);
const prev = byCanon.get(canon);
byCanon.set(canon, prev ? preferServerDoc(prev, doc) : doc);
}
return [...byCanon.values()];
}
/**
* 按 (projectId, 发布任务键) 将服务端受控文档状态合并进本地 deliverables。
* 旧键 intake 与发布键 bd_intake 视为同一槽。命中后 taskKey 与 docId 以服务端为准。
* 调用方仅在「服务端清单已成功返回」(含空数组)时调用;请求失败不得调用,以免误清。
* 权威响应下会 drop 该项目上的全部 demo-seed 本地项,以及已被服务端覆盖的未认领旧键模板克隆。
*/
export function mergeWorkflowDocumentsFromServer(
prev: DeliverableItem[],
serverDocs: WorkflowDocumentSummary[],
projectId: number,
fallbackUser: { id: number; name: string },
): DeliverableItem[] {
let updated = prev.filter(
(d) => !(d.projectId === projectId && isDemoSeedDocId(d.docId)),
);
const authoritative = collapseServerDocs(serverDocs, projectId);
if (authoritative.length === 0) {
return updated;
}
for (const s of authoritative) {
const canon = canonicalTaskKey(s.task_key);
const slotIdx = updated.findIndex(
(d) =>
d.projectId === projectId &&
Boolean(d.taskKey) &&
canonicalTaskKey(d.taskKey) === canon &&
d.isFixedWorkflow !== false &&
d.governanceMode !== 'freeform',
);
const base: DeliverableItem =
slotIdx >= 0
? { ...updated[slotIdx] }
: {
docId: s.doc_id,
projectId,
taskKey: s.task_key,
title: s.title,
stage: 'V0.5',
category: 'workflow',
creatorId: fallbackUser.id,
creatorName: fallbackUser.name,
isFixedWorkflow: true,
isTemplateOnly: s.is_template_only,
lifecycleState: mapLifecycleState(s.lifecycle_state),
isClaimed: !s.is_template_only,
author: '',
reviewer: '',
approver: '',
updatedAt: (s.updated_at || '').replace('T', ' ').substring(0, 16),
governanceMode: 'controlled',
};
applyAuthoritativeDuties(base, s, slotIdx < 0);
base.dutiesAuthoritative = true;
base.docId = s.doc_id;
base.taskKey = s.task_key;
base.title = s.title || base.title;
base.isTemplateOnly = s.is_template_only;
base.lifecycleState = mapLifecycleState(s.lifecycle_state);
base.isClaimed = !s.is_template_only;
base.governanceMode = 'controlled';
base.isFixedWorkflow = true;
base.editLease = s.lease_holder_type
? {
holderType: s.lease_holder_type,
holderId: s.lease_holder_id,
expiresAt: s.lease_expires_at,
heartbeatAt: s.lease_heartbeat_at,
}
: undefined;
if (s.updated_at) {
base.updatedAt = s.updated_at.replace('T', ' ').substring(0, 16);
}
if (slotIdx >= 0) {
updated[slotIdx] = base;
} else {
updated.push(base);
}
}
const serverByCanon = new Map(
authoritative.map((doc) => [canonicalTaskKey(doc.task_key), doc]),
);
// 服务端已覆盖该发布键时,丢掉未认领的旧键模板克隆。已认领或服务端没有的槽保留。
updated = updated.filter((d) => {
if (d.projectId !== projectId || !d.taskKey || d.governanceMode === 'freeform') {
return true;
}
const server = serverByCanon.get(canonicalTaskKey(d.taskKey));
if (!server || d.docId === server.doc_id) return true;
return !(d.isTemplateOnly && !d.isClaimed);
});
// 同一发布键只留一行(intake 与 bd_intake 不再各占一条)
const seenTaskKeys = new Set<string>();
updated = updated.filter((d) => {
if (d.projectId !== projectId || !d.taskKey || d.governanceMode === 'freeform') {
return true;
}
const key = `${d.projectId}:${canonicalTaskKey(d.taskKey)}`;
if (seenTaskKeys.has(key)) {
return false;
}
seenTaskKeys.add(key);
return true;
});
return updated;
}