fix(web): merge workflow documents by canonical task key

本地旧键模板与服务端 bd_* 行按同一发布键合并,未认领的旧键克隆在服务端已覆盖时丢掉。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 06:37:10 +00:00
co-authored by Cursor
parent 7bb46d9ec8
commit baca45ab9c
2 changed files with 99 additions and 9 deletions
+56 -9
View File
@@ -2,6 +2,7 @@ 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,
@@ -45,10 +46,41 @@ function mapLifecycleState(raw: string): DocLifecycleState {
}
}
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, taskKey) 将服务端受控文档状态合并进本地 deliverables。
* 按 (projectId, 发布任务键) 将服务端受控文档状态合并进本地 deliverables。
* 旧键 intake 与发布键 bd_intake 视为同一槽。命中后 taskKey 与 docId 以服务端为准。
* 调用方仅在「服务端清单已成功返回」(含空数组)时调用;请求失败不得调用,以免误清。
* 权威响应下会 drop 该项目上的全部 demo-seed 本地项。
* 权威响应下会 drop 该项目上的全部 demo-seed 本地项,以及已被服务端覆盖的未认领旧键模板克隆。
*/
export function mergeWorkflowDocumentsFromServer(
prev: DeliverableItem[],
@@ -60,17 +92,18 @@ export function mergeWorkflowDocumentsFromServer(
(d) => !(d.projectId === projectId && isDemoSeedDocId(d.docId)),
);
if (serverDocs.length === 0) {
const authoritative = collapseServerDocs(serverDocs, projectId);
if (authoritative.length === 0) {
return updated;
}
for (const s of serverDocs) {
if (s.project_id !== projectId || !s.task_key) continue;
for (const s of authoritative) {
const canon = canonicalTaskKey(s.task_key);
const slotIdx = updated.findIndex(
(d) =>
d.projectId === projectId &&
d.taskKey === s.task_key &&
Boolean(d.taskKey) &&
canonicalTaskKey(d.taskKey) === canon &&
d.isFixedWorkflow !== false &&
d.governanceMode !== 'freeform',
);
@@ -102,6 +135,7 @@ export function mergeWorkflowDocumentsFromServer(
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);
@@ -127,13 +161,26 @@ export function mergeWorkflowDocumentsFromServer(
}
}
// 去除同一 (projectId, taskKey) 的重复本地 ID(如 doc-p56-intake 与 doc_proj56_intake 并存)
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}:${d.taskKey}`;
const key = `${d.projectId}:${canonicalTaskKey(d.taskKey)}`;
if (seenTaskKeys.has(key)) {
return false;
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 旧任务键 → 发布键。必须与 internal/workflowdef/keyspace.go 的 legacyToCanonical 逐项一致。
* 交付物池里的后缀对齐仍用 templateMatch.normalizeTaskKeyToProject,不要在这里再写一套池逻辑。
*/
const LEGACY_TO_CANONICAL: Record<string, string> = {
intake: 'bd_intake',
research: 'bd_research',
feasibility_dev: 'bd_feasibility_dev',
feasibility_reg: 'bd_feasibility_reg',
user_requirements: 'bd_user_requirements',
prelim_design: 'bd_prelim_design',
cdo_quote: 'bd_cdo_quote',
system_reqs: 'pm_system_reqs',
test_manager: 'tm_system_verification',
software_charter: 'sw_software_charter',
source_inventory: 'sw_source_inventory',
current_state_audit: 'sw_current_state_audit',
architecture_baseline: 'sw_architecture_baseline',
software_risk_register: 'sw_software_risk_register',
product_requirements: 'sw_product_requirements',
scope_backlog: 'sw_scope_backlog',
target_architecture: 'sw_target_architecture',
decision_log: 'sw_decision_log',
bootstrap_roadmap: 'sw_bootstrap_roadmap',
verification_strategy: 'sw_verification_strategy',
operations_baseline: 'sw_operations_baseline',
bootstrap_readiness: 'sw_bootstrap_readiness',
change_brief: 'sw_change_brief',
implementation_plan: 'sw_implementation_plan',
change_manifest: 'sw_change_manifest',
adr_delta: 'sw_adr_delta',
test_report: 'sw_test_report',
security_review: 'sw_security_review',
release_plan: 'sw_release_plan',
release_notes: 'sw_release_notes',
retrospective: 'sw_retrospective',
backlog_delta: 'sw_backlog_delta',
};
export function canonicalTaskKey(key: string): string {
const trimmed = key.trim();
return LEGACY_TO_CANONICAL[trimmed] ?? trimmed;
}