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:
committed by
weichen
co-authored by
Cursor
parent
68765023a2
commit
9d40a91f9d
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user