merge: fix/workflow-doc-key-unification-2026-09-21

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 06:38:25 +00:00
co-authored by Cursor
11 changed files with 583 additions and 12 deletions
+2 -1
View File
@@ -13,6 +13,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor | 2026-09-21-workflow-doc-key-unification | fix/workflow-doc-key-unification-2026-09-21 | .worktrees/fix-workflow-doc-key-unification-2026-09-21 | docgov+web | in_progress |
| Cursor | 2026-09-21-dev-deep-test-four-issues | fix/dev-deep-test-four-issues-2026-09-21 | - | web+intent+taskrun | done |
| Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done |
| Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done |
@@ -176,7 +177,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | 2026-09-21 | 四问题修复已重建 dev;`/health` schema_version=59;pi-agent healthy | - |
| Cursor | merging | 2026-09-21 | fix/workflow-doc-key-unification-2026-09-21:doc_proj 发布键唯一与列表归并 | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -0,0 +1,51 @@
---
title: "工作流文档键统一(项目 6579)"
status: in_progress
created: 2026-09-21
updated: 2026-09-21
owner: Cursor
branch: fix/workflow-doc-key-unification-2026-09-21
worktree: .worktrees/fix-workflow-doc-key-unification-2026-09-21
mutex: docgov+web
---
# 工作流文档键统一(项目 6579)
## 目标
管理员与成员看到的工作流文档都是 7 份 `bd_*`,不再因旧键 `intake` 与发布键 `bd_intake` 各算一行而变成 8 或 14。
## 范围
- 迁移 00060:归档并存的旧键正式台账,并为 `doc_proj*` 加发布键唯一索引。
- 前端按发布键合并本地模板克隆。
- 文档列表按发布键归并,物化时不再插入已有旧键的兄弟行。
## 非目标
- 不改项目 143。
- 不清理 `doc_test_db_*` 测试残留。
- 不重做「编写用户初始需求收集表」的作用域解析(2026-09-21 早间修复已覆盖,本任务只复测)。
## 步骤
1. [x] 从 `dev` 开 `fix/workflow-doc-key-unification-2026-09-21`
2. [x] 迁移、前端合并、列表归并与单测
3. [ ] 合入 dev、重建、核对项目 6579 为 7 条 `bd_*`
4. [ ] 测试报告与 CURRENT / FEATURE-MATRIX
## 验收标准
- [ ] `GET /api/v1/projects/6579/documents?category=workflow` 恰好 7 条 `bd_*`
- [ ] 项目 143 文档数不变
- [ ] 「编写用户初始需求收集表」返回 202,而不是任务作用域 409
## 相关链接
- Checklist:
- Handoff:
- Test report: `.doc/test-reports/2026-09-21-workflow-doc-key-unification-test-report.md`
## 证据 / 备注
迁移已在 dev 库事务内演练并回滚:归档 1 行后项目 6579 为 7 条 `bd_*`,项目 143 仍为 1 行。唯一索引只覆盖 `doc_proj*`,因为项目 1 有大量同键 `doc_test_db_*`。
@@ -0,0 +1,176 @@
-- +goose Up
-- 正式台账 doc_proj* 上,旧任务键与发布键(intake 与 bd_intake)不能再并存。
-- 只归档「同一项目里已经有发布键行」的旧键行;孤立旧键行保留,避免晋升时丢掉唯一数据。
-- 唯一索引不覆盖 doc_test_db_*:dev 库项目 1 有大量同键测试残留,全表唯一会误伤。
-- document_snapshots / document_content_blobs 有不可变触发器,不删除;正文副本写入归档 payload。
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION canonical_document_task_key(task_key text)
RETURNS text
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE
AS $$
SELECT CASE btrim(COALESCE(task_key, ''))
WHEN 'intake' THEN 'bd_intake'
WHEN 'research' THEN 'bd_research'
WHEN 'feasibility_dev' THEN 'bd_feasibility_dev'
WHEN 'feasibility_reg' THEN 'bd_feasibility_reg'
WHEN 'user_requirements' THEN 'bd_user_requirements'
WHEN 'prelim_design' THEN 'bd_prelim_design'
WHEN 'cdo_quote' THEN 'bd_cdo_quote'
WHEN 'system_reqs' THEN 'pm_system_reqs'
WHEN 'test_manager' THEN 'tm_system_verification'
WHEN 'software_charter' THEN 'sw_software_charter'
WHEN 'source_inventory' THEN 'sw_source_inventory'
WHEN 'current_state_audit' THEN 'sw_current_state_audit'
WHEN 'architecture_baseline' THEN 'sw_architecture_baseline'
WHEN 'software_risk_register' THEN 'sw_software_risk_register'
WHEN 'product_requirements' THEN 'sw_product_requirements'
WHEN 'scope_backlog' THEN 'sw_scope_backlog'
WHEN 'target_architecture' THEN 'sw_target_architecture'
WHEN 'decision_log' THEN 'sw_decision_log'
WHEN 'bootstrap_roadmap' THEN 'sw_bootstrap_roadmap'
WHEN 'verification_strategy' THEN 'sw_verification_strategy'
WHEN 'operations_baseline' THEN 'sw_operations_baseline'
WHEN 'bootstrap_readiness' THEN 'sw_bootstrap_readiness'
WHEN 'change_brief' THEN 'sw_change_brief'
WHEN 'implementation_plan' THEN 'sw_implementation_plan'
WHEN 'change_manifest' THEN 'sw_change_manifest'
WHEN 'adr_delta' THEN 'sw_adr_delta'
WHEN 'test_report' THEN 'sw_test_report'
WHEN 'security_review' THEN 'sw_security_review'
WHEN 'release_plan' THEN 'sw_release_plan'
WHEN 'release_notes' THEN 'sw_release_notes'
WHEN 'retrospective' THEN 'sw_retrospective'
WHEN 'backlog_delta' THEN 'sw_backlog_delta'
ELSE btrim(COALESCE(task_key, ''))
END;
$$;
-- +goose StatementEnd
CREATE TABLE document_records_legacy_key_archive (LIKE document_records INCLUDING DEFAULTS);
ALTER TABLE document_records_legacy_key_archive ALTER COLUMN id DROP DEFAULT;
ALTER TABLE document_records_legacy_key_archive
ADD COLUMN archive_id BIGSERIAL PRIMARY KEY,
ADD COLUMN archived_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN archive_reason TEXT NOT NULL DEFAULT '',
ADD COLUMN related_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE document_content_projections_legacy_key_archive (LIKE document_content_projections INCLUDING DEFAULTS);
ALTER TABLE document_content_projections_legacy_key_archive
ADD COLUMN archive_id BIGSERIAL PRIMARY KEY,
ADD COLUMN archived_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN archive_reason TEXT NOT NULL DEFAULT '';
CREATE TEMP TABLE legacy_key_docs_to_archive ON COMMIT DROP AS
SELECT legacy.doc_id
FROM document_records legacy
JOIN document_records canon
ON canon.project_id = legacy.project_id
AND canon.doc_id <> legacy.doc_id
AND canon.doc_id LIKE 'doc_proj%'
AND legacy.doc_id LIKE 'doc_proj%'
AND canonical_document_task_key(canon.task_key) = canonical_document_task_key(legacy.task_key)
AND canon.task_key = canonical_document_task_key(canon.task_key)
AND legacy.task_key <> canonical_document_task_key(legacy.task_key);
INSERT INTO document_records_legacy_key_archive
SELECT d.*,
nextval('document_records_legacy_key_archive_archive_id_seq'),
now(),
'coexisting_legacy_task_key',
'{}'::jsonb
FROM document_records d
JOIN legacy_key_docs_to_archive x ON x.doc_id = d.doc_id;
UPDATE document_records_legacy_key_archive a
SET related_payload = jsonb_build_object(
'comments', COALESCE((
SELECT jsonb_agg(to_jsonb(c)) FROM document_comments c WHERE c.doc_id = a.doc_id
), '[]'::jsonb),
'change_proposals', COALESCE((
SELECT jsonb_agg(to_jsonb(p)) FROM document_change_proposals p WHERE p.doc_id = a.doc_id
), '[]'::jsonb),
'audit_logs', COALESCE((
SELECT jsonb_agg(to_jsonb(l)) FROM document_audit_logs l WHERE l.doc_id = a.doc_id
), '[]'::jsonb),
'versions', COALESCE((
SELECT jsonb_agg(to_jsonb(v)) FROM document_versions v WHERE v.doc_id = a.doc_id
), '[]'::jsonb),
'snapshots', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', s.id,
'content_sha256', s.content_sha256,
'source_kind', s.source_kind,
'created_at', s.created_at,
'markdown', b.content_markdown
))
FROM document_snapshots s
JOIN document_content_blobs b ON b.content_sha256 = s.content_sha256
WHERE s.doc_id = a.doc_id
), '[]'::jsonb)
);
INSERT INTO document_content_projections_legacy_key_archive
SELECT p.*,
nextval('document_content_projections_legacy_key_archive_archive_id_seq'),
now(),
'coexisting_legacy_task_key'
FROM document_content_projections p
JOIN legacy_key_docs_to_archive x ON x.doc_id = p.doc_id;
DELETE FROM document_content_projections p
USING legacy_key_docs_to_archive x
WHERE p.doc_id = x.doc_id;
DELETE FROM document_records d
USING legacy_key_docs_to_archive x
WHERE d.doc_id = x.doc_id;
-- +goose StatementBegin
DO $$
DECLARE
dup text;
BEGIN
SELECT string_agg(project_id::text || ':' || canon, ', ')
INTO dup
FROM (
SELECT project_id, canonical_document_task_key(task_key) AS canon
FROM document_records
WHERE btrim(task_key) <> '' AND doc_id LIKE 'doc_proj%'
GROUP BY project_id, canonical_document_task_key(task_key)
HAVING count(*) > 1
) s;
IF dup IS NOT NULL THEN
RAISE EXCEPTION 'doc_proj canonical task key still duplicated: %', dup;
END IF;
END $$;
-- +goose StatementEnd
CREATE UNIQUE INDEX uq_document_records_doc_proj_canonical_task_key
ON document_records (project_id, canonical_document_task_key(task_key))
WHERE btrim(task_key) <> '' AND doc_id LIKE 'doc_proj%';
-- +goose Down
DROP INDEX IF EXISTS uq_document_records_doc_proj_canonical_task_key;
INSERT INTO document_records
SELECT (jsonb_populate_record(
NULL::document_records,
to_jsonb(a) - 'archive_id' - 'archived_at' - 'archive_reason' - 'related_payload'
)).*
FROM document_records_legacy_key_archive a
ON CONFLICT (doc_id) DO NOTHING;
INSERT INTO document_content_projections
SELECT (jsonb_populate_record(
NULL::document_content_projections,
to_jsonb(a) - 'archive_id' - 'archived_at' - 'archive_reason'
)).*
FROM document_content_projections_legacy_key_archive a
ON CONFLICT (doc_id, snapshot_id) DO NOTHING;
DROP TABLE IF EXISTS document_content_projections_legacy_key_archive;
DROP TABLE IF EXISTS document_records_legacy_key_archive;
DROP FUNCTION IF EXISTS canonical_document_task_key(text);
+59
View File
@@ -3,9 +3,14 @@ package docgov
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"chat-agent-system/internal/workflowdef"
"github.com/jackc/pgx/v5"
)
// TemplateDocSpec 是激活后要落到 document_records 的受控文档槽。
@@ -48,6 +53,12 @@ func (e *GovernanceEngine) MaterializeTemplateDocs(ctx context.Context, projectI
func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int64, spec TemplateDocSpec) error {
taskKey := strings.TrimSpace(spec.TaskKey)
docID := ControlledDocID(projectID, taskKey)
if taken, err := e.hasCanonicalSibling(ctx, projectID, taskKey, docID); err != nil {
return err
} else if taken {
// 同一发布键已有另一行(常见于旧键 intake 仍是唯一数据)。不再插入 bd_* 兄弟行。
return nil
}
title := strings.TrimSpace(spec.Title)
if title == "" {
title = taskKey
@@ -155,3 +166,51 @@ func dutyDisplayName(userID int64, name string) string {
}
return ""
}
func siblingTaskKeys(taskKey string) []string {
canon := workflowdef.CanonicalTaskKey(taskKey)
legacy := workflowdef.ImplementationTaskKey(canon)
var out []string
if canon != "" && canon != taskKey {
out = append(out, canon)
}
if legacy != "" && legacy != taskKey && legacy != canon {
out = append(out, legacy)
}
return out
}
func (e *GovernanceEngine) hasCanonicalSibling(ctx context.Context, projectID int64, taskKey, docID string) (bool, error) {
siblings := siblingTaskKeys(taskKey)
if e == nil || len(siblings) == 0 {
return false, nil
}
if e.pool != nil {
var existing string
err := e.pool.QueryRow(ctx, `
SELECT doc_id FROM document_records
WHERE project_id = $1 AND task_key = ANY($2::text[]) AND doc_id <> $3
LIMIT 1
`, projectID, siblings, docID).Scan(&existing)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("docgov: lookup canonical sibling: %w", err)
}
return existing != "", nil
}
e.mu.RLock()
defer e.mu.RUnlock()
for _, doc := range e.memDocs {
if doc == nil || doc.ProjectID != projectID || doc.DocID == docID {
continue
}
for _, key := range siblings {
if doc.TaskKey == key {
return true, nil
}
}
}
return false, nil
}
+26
View File
@@ -65,3 +65,29 @@ func TestMaterializeTemplateDocsIdempotentAndSkipsRealContent(t *testing.T) {
t.Fatalf("real document was overwritten: %+v", got)
}
}
func TestMaterializeTemplateDocsSkipsCanonicalSibling(t *testing.T) {
ctx := context.Background()
engine := docgov.NewGovernanceEngine(nil)
if _, err := engine.CreateOrGetDocument(
ctx, "doc_proj70_intake", 70, "intake", "用户初始需求收集表", "V0.1", "",
1, "编制人", 0, "", 0, "",
); err != nil {
t.Fatal(err)
}
if err := engine.MaterializeTemplateDocs(ctx, 70, []docgov.TemplateDocSpec{{
TaskKey: "bd_intake",
Title: "用户初始需求收集表",
StageKey: "V0.1_triage",
Enabled: true,
}}); err != nil {
t.Fatal(err)
}
docs, err := engine.ListWorkflowDocumentsByProject(ctx, 70)
if err != nil {
t.Fatal(err)
}
if len(docs) != 1 || docs[0].DocID != "doc_proj70_intake" || docs[0].TaskKey != "intake" {
t.Fatalf("sibling bd_intake was inserted: %+v", docs)
}
}
+5 -2
View File
@@ -98,7 +98,10 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
}
out = append(out, item)
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
return collapseWorkflowDocuments(out), nil
}
e.mu.Lock()
@@ -130,7 +133,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
})
}
}
return out, nil
return collapseWorkflowDocuments(out), nil
}
// ReadDocumentContent 读取受控文档 output.md 并返回 typed ContentError。
+38
View File
@@ -79,3 +79,41 @@ func TestListWorkflowDocumentsIncludesAuthoritativeDuties(t *testing.T) {
t.Fatalf("stranger must not become author: %+v", got)
}
}
func TestListWorkflowDocumentsCollapsesLegacyAlias(t *testing.T) {
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
const projectID = int64(6579)
seeds := []struct {
docID string
taskKey string
title string
}{
{"doc_proj6579_intake", "intake", "用户初始需求收集表"},
{"doc_proj6579_bd_intake", "bd_intake", "用户初始需求收集表"},
{"doc_proj6579_bd_research", "bd_research", "调研报告"},
}
for _, seed := range seeds {
if _, err := engine.CreateOrGetDocument(
ctx, seed.docID, projectID, seed.taskKey, seed.title, "V0.1", "",
398, "陈威", 0, "", 0, "",
); err != nil {
t.Fatal(err)
}
}
docs, err := engine.ListWorkflowDocumentsByProject(ctx, projectID)
if err != nil {
t.Fatal(err)
}
if len(docs) != 2 {
t.Fatalf("documents=%d, want bd_intake and bd_research only", len(docs))
}
for _, doc := range docs {
if doc.TaskKey == "intake" {
t.Fatalf("legacy intake row leaked into the list: %+v", doc)
}
}
if docs[0].TaskKey != "bd_intake" || docs[0].DocID != "doc_proj6579_bd_intake" {
t.Fatalf("first doc=%+v", docs[0])
}
}
+60
View File
@@ -0,0 +1,60 @@
package docgov
import (
"sort"
"chat-agent-system/internal/workflowdef"
)
// collapseWorkflowDocuments 把同一发布键的旧键行与新键行收成一行。
// 发布键优先;都是发布键时保留已脱离模板的那条;再按 doc_id 稳定选取。
func collapseWorkflowDocuments(docs []WorkflowDocumentSummary) []WorkflowDocumentSummary {
if len(docs) < 2 {
return docs
}
best := make(map[string]WorkflowDocumentSummary, len(docs))
for _, doc := range docs {
canon := workflowdef.CanonicalTaskKey(doc.TaskKey)
if canon == "" {
continue
}
prev, ok := best[canon]
if !ok {
best[canon] = doc
continue
}
best[canon] = preferWorkflowDocument(prev, doc)
}
out := make([]WorkflowDocumentSummary, 0, len(best))
for _, doc := range best {
out = append(out, doc)
}
sort.Slice(out, func(i, j int) bool {
if out[i].TaskKey == out[j].TaskKey {
return out[i].DocID < out[j].DocID
}
return out[i].TaskKey < out[j].TaskKey
})
return out
}
func preferWorkflowDocument(current, candidate WorkflowDocumentSummary) WorkflowDocumentSummary {
currentPublished := current.TaskKey == workflowdef.CanonicalTaskKey(current.TaskKey)
candidatePublished := candidate.TaskKey == workflowdef.CanonicalTaskKey(candidate.TaskKey)
if currentPublished != candidatePublished {
if candidatePublished {
return candidate
}
return current
}
if current.IsTemplateOnly != candidate.IsTemplateOnly {
if candidate.IsTemplateOnly {
return current
}
return candidate
}
if candidate.DocID < current.DocID {
return candidate
}
return current
}
@@ -277,4 +277,71 @@ describe('mergeWorkflowDocumentsFromServer', () => {
expect(merged).toHaveLength(1);
expect(merged[0].docId).toBe('doc_proj6001_intake');
});
it('collapses seven legacy local clones and a coexisting intake row into seven bd_ docs', () => {
const legacy = [
['intake', '用户初始需求收集表'],
['research', '调研报告(竞品/技术/专利/市场/对比/法规)'],
['feasibility_reg', '注册路径可行性评估报告(法规细则)'],
['feasibility_dev', '设计开发可行性评估报告(研发细则)'],
['user_requirements', '用户需求规格说明书(URS 长标题)'],
['prelim_design', '初步设计说明书(长标题)'],
['cdo_quote', 'CDO报价核算表(长标题)'],
] as const;
const prev: DeliverableItem[] = legacy.map(([taskKey, title]) => ({
docId: `doc-p6579-${taskKey}`,
projectId: 6579,
taskKey,
title,
stage: 'V0.5',
category: 'workflow',
creatorId: 398,
creatorName: '陈威',
isFixedWorkflow: true,
isTemplateOnly: true,
lifecycleState: 'drafting',
isClaimed: false,
author: '',
reviewer: '',
approver: '',
updatedAt: '2026-09-21',
governanceMode: 'controlled',
}));
prev.push({
...prev[0],
docId: 'doc-p99-intake',
projectId: 99,
title: '未激活项目的唯一模板',
});
const server = legacy.map(([taskKey, title]) => ({
doc_id: `doc_proj6579_bd_${taskKey}`,
project_id: 6579,
task_key: `bd_${taskKey}`,
title: title.split('(')[0],
lifecycle_state: 'drafting' as const,
is_template_only: true,
}));
server.push({
doc_id: 'doc_proj6579_intake',
project_id: 6579,
task_key: 'intake',
title: '用户初始需求收集表',
lifecycle_state: 'drafting',
is_template_only: false,
});
const merged = mergeWorkflowDocumentsFromServer(prev, server, 6579, { id: 398, name: '陈威' });
const projectDocs = merged.filter((item) => item.projectId === 6579);
expect(projectDocs).toHaveLength(7);
expect(projectDocs.map((item) => item.taskKey).sort()).toEqual(
legacy.map(([taskKey]) => `bd_${taskKey}`).sort(),
);
expect(new Set(projectDocs.map((item) => item.docId)).size).toBe(7);
expect(projectDocs.some((item) => item.taskKey === 'intake' || item.taskKey === 'research')).toBe(false);
expect(projectDocs.some((item) => item.title.includes('竞品'))).toBe(false);
const intake = projectDocs.find((item) => item.taskKey === 'bd_intake');
expect(intake?.docId).toBe('doc_proj6579_bd_intake');
expect(merged.find((item) => item.projectId === 99)?.docId).toBe('doc-p99-intake');
});
});
+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;
}