feat(web): sync workflow docs from server and persist task artifact in chat

Pass doc_id on task runs, merge server deliverable state by task_key,
fetch controlled document content when opening non-template docs, and
map metadata.artifact after refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:22 +00:00
committed by weichen
co-authored by Cursor
parent 68765023a2
commit 9d40a91f9d
9 changed files with 367 additions and 3 deletions
@@ -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。
+59 -1
View File
@@ -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<DocumentBlock | null>(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,
+8 -2
View File
@@ -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,
};
+44
View File
@@ -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<WorkflowDocumentSummary[]> {
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<ControlledDocumentContent> {
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();
}
+1
View File
@@ -63,6 +63,7 @@ export function buildTaskRequest(task: TaskRunContext): Record<string, unknown>
if (task.materials && task.materials.length > 0) {
body.materials = task.materials;
}
if (task.docId) body.doc_id = task.docId;
return body;
}
@@ -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');
});
});
@@ -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);
});
});
+96
View File
@@ -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<string>();
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;
}
+13
View File
@@ -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;