diff --git a/.doc/test-reports/2026-09-11-task-finalize-projection-test-report.md b/.doc/test-reports/2026-09-11-task-finalize-projection-test-report.md new file mode 100644 index 00000000..093c74cb --- /dev/null +++ b/.doc/test-reports/2026-09-11-task-finalize-projection-test-report.md @@ -0,0 +1,64 @@ +# 任务完成投影与文档权威同步 测试报告 + +## 一、 测试背景 (Background) + +异步长任务 `run_1789111889557885000` 已在工作区成功产出 `output.md`,但聊天消息仍保留「任务执行中…」占位符,`workflow_runs.output_doc_id` 为空,前端文档列表仍显示 localStorage 模板状态。根因为 Executor 丢弃 `TaskRunResult`、Finalize 缺失、文档 ID 双轨。 + +## 二、 测试目标 (Test Objectives) + +- 任务完成后助手消息正文被摘要替换,metadata 含 artifact +- `workflow_runs` 写入 `output_doc_id` / `output_summary` +- 质量未过时终态为 `quality_failed`,SSE 不再误报「审核通过」 +- 前端按 `(projectId, taskKey)` 合并服务端受控文档,消除 `doc-p*` / `doc_proj*` 双实体 +- 刷新后 `mapRecordToMessage` 恢复完成消息而非占位符 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: darwin;Go 1.27;Vitest 5 +- 单元/集成:`internal/server/task_finalize_test.go`、前端 `deliverableSync.test.ts`、`chatWindowArtifact.test.ts` +- 回填脚本:`scripts/backfill_task_run_projection.go`(需 dev Postgres) + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| FinalizeTaskRun | content ≠ 占位符;artifact.doc_id 非空 | +| quality_failed | run status=quality_failed;摘要含待整改 | +| mergeWorkflowDocuments | doc-p56-intake → doc_proj56_intake | +| mapRecordToMessage | taskArtifact 映射成功 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 Go:`TestFinalizeTaskRun_ReplacesPlaceholderContent` + +- **Input**: 内存 ctxmgr + mem workflow run + quality_failed TaskRunResult +- **Actual Output**: `go test -run Finalize ./internal/server` PASS;content 含「已生成」;metadata.artifact.doc_id=`doc_proj56_intake` +- **Verdict**: PASS +- **Duration**: ~0.9s + +### 5.2 Go:`TestAsyncTaskRun_FinalizesAfterWorkerCompletes` + +- **Input**: Stub Executor 返回 completed + DocRecord +- **Actual Output**: worker 结束后消息正文已替换 +- **Verdict**: PASS + +### 5.3 前端:deliverableSync / chatWindowArtifact + +- **Input**: vitest 直接运行 +- **Actual Output**: 2/2 PASS +- **Verdict**: PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go test ./internal/server/... -run 'Finalize|AsyncTaskRun|CreateTaskRun' → PASS +go test ./internal/ctxmgr/... ./internal/docgov/... → PASS +npx vitest run src/components/chat/deliverableSync.test.ts → PASS +npx vitest run src/components/chat/chatWindowArtifact.test.ts → PASS +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- P0 完成投影链路已实现,可合并 dev。 +- 待运维:在 dev 数据库执行 `go run ./scripts/backfill_task_run_projection.go` 回填事故数据。 +- 下一轮:workflow_run_events 持久化、SSE 跨重启重放、reconciliation job。 diff --git a/web/src/App.tsx b/web/src/App.tsx index 2c2483e6..babd6a32 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,7 +18,12 @@ import { deleteFreeformDoc, } from './api/freeformDoc'; import { fetchChatSessions, upsertChatSession } from './api/chatSessions'; +import { + fetchControlledDocumentContent, + listProjectWorkflowDocuments, +} from './api/projectDocuments'; import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow'; +import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync'; import { DocumentLineage } from './types/projectWorkflow'; import { WorkflowPrerequisiteBanner } from './components/workflow/WorkflowPrerequisiteBanner'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; @@ -1157,6 +1162,34 @@ export default function App() { .catch((err) => { console.warn('获取自由文档详情失败,保持本地草稿骨架:', err); }); + } else if ( + targetDoc.isFixedWorkflow && + !targetDoc.isTemplateOnly && + targetDoc.governanceMode !== 'freeform' + ) { + fetchControlledDocumentContent(id) + .then((detail) => { + if (!detail?.markdown) return; + const fullAST = markdownToBlockAST(detail.markdown, { + docId: detail.doc_id, + title: targetDoc.title, + taskKey: targetDoc.taskKey, + version: `${detail.version || targetDoc.stage}`, + category: targetDoc.category, + creatorId: targetDoc.creatorId, + creatorName: targetDoc.creatorName, + lifecycleState: targetDoc.lifecycleState, + isClaimed: targetDoc.isClaimed, + author: targetDoc.author, + reviewer: targetDoc.reviewer, + approver: targetDoc.approver, + }); + fullAST.governanceMode = 'controlled'; + setDocCache((prev) => ({ ...prev, [id]: fullAST })); + }) + .catch((err) => { + console.warn('获取受控文档正文失败,保持本地模板:', err); + }); } } } @@ -1390,6 +1423,26 @@ export default function App() { }; }, [currentProjectId, currentUser.id]); + // 挂载或项目切换时,拉取服务端工作流受控文档并覆盖本地模板状态 + useEffect(() => { + if (!currentProjectId || currentProjectId <= 0) return; + let active = true; + const syncWorkflowDocs = async () => { + try { + const workflowDocs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []); + if (!active || workflowDocs.length === 0) return; + setDeliverables((prev) => + mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser), + ); + } catch (e) { + console.warn('[deliverables] 工作流受控文档同步失败:', e); + } + }; + void syncWorkflowDocs(); + return () => { + active = false; + }; + }, [currentProjectId, currentUser.id, currentUser.name]); // 模态弹窗与侧栏 const [editingBlock, setEditingBlock] = useState(null); @@ -2397,6 +2450,10 @@ export default function App() { const currentProj = sessions.find((s) => s.sessionId === activeSessionId); // 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续 const clientTurnId = `turn_${Date.now()}`; + const projId = currentProj?.projectId || 1; + const matchedDeliverable = deliverables.find( + (d) => d.projectId === projId && d.taskKey === matchedTemplate.taskKey, + ); const baseTask: TaskRunContext = { taskKey: matchedTemplate.taskKey, projectName: currentProj?.title || '医疗器械研发演示项目', @@ -2404,9 +2461,10 @@ export default function App() { deviceType: currentProj?.deviceType || '医疗器械', sessionId: activeSessionId, userId: currentUser.id, - projectId: currentProj?.projectId || 1, + projectId: projId, clientTurnId, userMessage: text, + docId: matchedDeliverable?.docId, }; const placeholder: ChatMessage = { id: asstId, diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 2c0b58e2..9be24226 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -83,12 +83,18 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage { r.role === 'assistant' ? meta.task_status === 'running' ? 'streaming_status' - : meta.task_status === 'failed' + : meta.task_status === 'failed' || meta.task_status === 'finalizing_failed' ? 'failed' - : 'completed' + : meta.task_status === 'quality_failed' + ? 'completed' + : 'completed' : undefined, taskRunId: typeof meta.run_id === 'string' ? meta.run_id : undefined, taskWorkspaceId: typeof meta.workspace_id === 'string' ? meta.workspace_id : undefined, + taskArtifact: + meta.artifact && typeof meta.artifact === 'object' + ? (meta.artifact as ChatMessage['taskArtifact']) + : undefined, contentKind: r.content_kind, exportCapability: r.export_capability, }; diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts new file mode 100644 index 00000000..af0070f0 --- /dev/null +++ b/web/src/api/projectDocuments.ts @@ -0,0 +1,44 @@ +/** 项目工作流受控文档 API */ + +export type WorkflowDocumentSummary = { + doc_id: string; + project_id: number; + task_key: string; + title: string; + lifecycle_state: string; + is_template_only: boolean; + content_sha256?: string; + work_dir?: string; + version?: number; + updated_at?: string; +}; + +export type ControlledDocumentContent = { + doc_id: string; + version: number; + content_sha256: string; + markdown: string; + is_template_only: boolean; +}; + +export async function listProjectWorkflowDocuments( + projectId: number, +): Promise { + const resp = await fetch( + `/api/v1/projects/${projectId}/documents?category=workflow`, + ); + if (!resp.ok) { + throw new Error(`list project workflow documents failed: ${resp.status}`); + } + return resp.json(); +} + +export async function fetchControlledDocumentContent( + docId: string, +): Promise { + const resp = await fetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`); + if (!resp.ok) { + throw new Error(`fetch controlled document content failed: ${resp.status}`); + } + return resp.json(); +} diff --git a/web/src/api/taskStream.ts b/web/src/api/taskStream.ts index 0f523544..23638e08 100644 --- a/web/src/api/taskStream.ts +++ b/web/src/api/taskStream.ts @@ -63,6 +63,7 @@ export function buildTaskRequest(task: TaskRunContext): Record if (task.materials && task.materials.length > 0) { body.materials = task.materials; } + if (task.docId) body.doc_id = task.docId; return body; } diff --git a/web/src/components/chat/chatWindowArtifact.test.ts b/web/src/components/chat/chatWindowArtifact.test.ts new file mode 100644 index 00000000..5b5b75e9 --- /dev/null +++ b/web/src/components/chat/chatWindowArtifact.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { mapRecordToMessage } from '../../api/chatWindow'; + +describe('mapRecordToMessage artifact projection', () => { + it('maps completed task message with artifact metadata', () => { + const msg = mapRecordToMessage({ + id: 25548, + session_id: 'sess-p56-u3', + role: 'assistant', + content: '《用户初始需求收集表》已生成。\n\n当前状态:**质量检查未通过,待整改**', + metadata: { + task_status: 'quality_failed', + run_id: 'run_1789111889557885000', + artifact: { + doc_id: 'doc_proj56_intake', + title: '用户初始需求收集表', + quality_passed: false, + quality_score: 40, + }, + }, + created_at: '2026-09-11T15:42:50+08:00', + }); + + expect(msg.content).not.toBe('任务执行中…'); + expect(msg.taskArtifact?.doc_id).toBe('doc_proj56_intake'); + expect(msg.status).toBe('completed'); + }); +}); diff --git a/web/src/components/chat/deliverableSync.test.ts b/web/src/components/chat/deliverableSync.test.ts new file mode 100644 index 00000000..48a03625 --- /dev/null +++ b/web/src/components/chat/deliverableSync.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { mergeWorkflowDocumentsFromServer } from '../../lib/deliverableSync'; +import type { DeliverableItem } from '../../types/workbench'; + +const USER = { id: 1, name: '张工' }; + +describe('mergeWorkflowDocumentsFromServer', () => { + it('maps local doc-p56-intake slot to server doc_proj56_intake', () => { + const prev: DeliverableItem[] = [ + { + docId: 'doc-p56-intake', + projectId: 56, + taskKey: 'intake', + title: '用户初始需求收集表', + stage: 'V0.5', + category: 'workflow', + creatorId: 1, + creatorName: '张工', + isFixedWorkflow: true, + isTemplateOnly: true, + lifecycleState: 'drafting', + isClaimed: false, + author: '张工', + reviewer: '李主管', + approver: '王总监', + updatedAt: '2026-09-01', + governanceMode: 'controlled', + }, + ]; + + const merged = mergeWorkflowDocumentsFromServer( + prev, + [ + { + doc_id: 'doc_proj56_intake', + project_id: 56, + task_key: 'intake', + title: '用户初始需求收集表', + lifecycle_state: 'drafting', + is_template_only: false, + content_sha256: 'abc123', + updated_at: '2026-09-11T15:42:50+08:00', + }, + ], + 56, + USER, + ); + + expect(merged).toHaveLength(1); + expect(merged[0].docId).toBe('doc_proj56_intake'); + expect(merged[0].isTemplateOnly).toBe(false); + expect(merged[0].isClaimed).toBe(true); + }); +}); diff --git a/web/src/lib/deliverableSync.ts b/web/src/lib/deliverableSync.ts new file mode 100644 index 00000000..4c3a0ab1 --- /dev/null +++ b/web/src/lib/deliverableSync.ts @@ -0,0 +1,96 @@ +import type { WorkflowDocumentSummary } from '../api/projectDocuments'; +import type { DeliverableItem, DocLifecycleState } from '../types/workbench'; + +function mapLifecycleState(raw: string): DocLifecycleState { + switch (raw) { + case 'reviewing': + case 'approving': + case 'approved': + case 'drafting': + return raw; + default: + return 'drafting'; + } +} + +/** 按 (projectId, taskKey) 将服务端受控文档状态合并进本地 deliverables。 */ +export function mergeWorkflowDocumentsFromServer( + prev: DeliverableItem[], + serverDocs: WorkflowDocumentSummary[], + projectId: number, + fallbackUser: { id: number; name: string }, +): DeliverableItem[] { + if (serverDocs.length === 0) { + return prev; + } + + let updated = [...prev]; + + for (const s of serverDocs) { + if (s.project_id !== projectId || !s.task_key) continue; + + const slotIdx = updated.findIndex( + (d) => + d.projectId === projectId && + d.taskKey === s.task_key && + 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: fallbackUser.name, + reviewer: '李主管', + approver: '王总监', + updatedAt: (s.updated_at || '').replace('T', ' ').substring(0, 16), + governanceMode: 'controlled', + }; + + base.docId = s.doc_id; + 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; + if (s.updated_at) { + base.updatedAt = s.updated_at.replace('T', ' ').substring(0, 16); + } + + if (slotIdx >= 0) { + updated[slotIdx] = base; + } else { + updated.push(base); + } + } + + // 去除同一 (projectId, taskKey) 的重复本地 ID(如 doc-p56-intake 与 doc_proj56_intake 并存) + const seenTaskKeys = new Set(); + updated = updated.filter((d) => { + if (d.projectId !== projectId || !d.taskKey || d.governanceMode === 'freeform') { + return true; + } + const key = `${d.projectId}:${d.taskKey}`; + if (seenTaskKeys.has(key)) { + return false; + } + seenTaskKeys.add(key); + return true; + }); + + return updated; +} diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index d8896a0f..6d05831b 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -147,6 +147,8 @@ export type TaskRunContext = { userMessage?: string; /** 用户上传的参考文档材料,注入任务工作区 materials_text/ 目录 */ materials?: TaskRunMaterial[]; + /** 服务端权威受控文档 ID(如 doc_proj56_intake) */ + docId?: string; }; export interface ChatMessage { @@ -180,6 +182,17 @@ export interface ChatMessage { taskRunId?: string; /** P2: 最近一次任务成功/收尾时的工作区目录(done.workspace_id) */ taskWorkspaceId?: string; + /** 任务完成产物引用(服务端 metadata.artifact 投影) */ + taskArtifact?: { + doc_id?: string; + title?: string; + task_key?: string; + sha256?: string; + workspace_id?: string; + quality_passed?: boolean; + quality_score?: number; + quality_violations?: string[]; + }; /** 客户端回合 ID(一次发送的用户+助手共享) */ clientTurnId?: string;