仿真测试台(后端):节点指派 + 全链 Trace —— 起单定向到指定账号,真实任务落工作台

用户定的形态:测试台是**后端检测活动与流程**的,真实任务照旧到工作台
(不然指定账号干嘛)—— 所以测试台只管「起单 + 看穿」,人那一环还是真的。

**节点指派**(迁移 0016 workflow.assignments):
- StartWorkflowInput 增 `assignments {节点id → 员工id}`;分派改三级取人:
  ①节点指派(点名的人在岗就是他)②员工组轮值 ③全员轮值 —— 指派是加法不是替换,
  点名的人不在岗退回轮值(单不能没人接);事件里如实记 `(节点指派)`
- **顺带修一个真 bug**:`node_instance.assignee_id` 写了但 `listInstances` 从没读回来
  (审批箱收件、员工面链条、治理列表全按它认人 —— 写了不读等于没写)

**全链 Trace**(新模块 trace,L2;迁移 0017 sys.trace_span):
- span 由 **Mastra Observability 组件**产出(执行不经 Mastra 图,所以手动打点),
  一开就落库、一收就补 —— 观测面不该比被观测的事晚(不走 exporter 的批量异步路)
- 打点覆盖:工作流 root → 节点(记着派给了谁)→ 活动内每次工具调用 / 模型调用 / 人卡
- **root span 挂在 `runtime.begin`**:那是「单已建好、还没进第一个节点」那一刻 ——
  挂在 start 返回之后就晚了,起单同步走到第一个节点,节点 span 会先出生、另起孤树(实测)
- 观测尽力而为:写 span 失败吞掉,不掀翻业务
- 三样东西各管各的:trace(机器读时间轴)/ 任务记忆(人读留痕)/
  **工作区报告(活动自持的 HTML)** —— 谁也不替代谁

路由:`POST /api/gov/testbed/run`(带指派起单)、`GET /api/gov/workflows/:id/trace`。

端到端六例(真库):指派生效 / 任务真落到被指派人手上并由他答单 / 下游节点同样生效 /
全链 span 逐层挂得上且耗时齐备 / 一条流一个 traceId / 不指派照旧轮值。五闸全过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-29 01:43:49 +08:00
co-authored by Claude Fable 5
parent 9df2294f6e
commit 5fc6dfdfe1
34 changed files with 1301 additions and 41 deletions
+42
View File
@@ -25,6 +25,8 @@ import {
listTriggersRoute,
registerToolRoute,
saveProcessRoute,
testRunRoute,
traceOfWorkflowRoute,
setProcessStateRoute,
reassignTaskRoute,
resumeTaskRoute,
@@ -213,6 +215,46 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
return c.json({ approvals: perWorkflow.flat() });
});
/**
* 仿真测试台 · 起一条流 —— **带节点指派**把整条流定向到指定账号。
*
* 这是「后端检测活动与流程」的入口:真实任务照旧落到被指派人的工作台
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
*/
app.post(testRunRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = testRunRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
return c.json(body, 400);
}
const me = await container.org.findByAccount(c.get('account'));
try {
const record = await container.process.start({
packageId: parsed.data.packageId,
subject: parsed.data.subject,
// 凡单必有来源:测试台起的单来源是「设计台试跑」,登记人即操作者
source: { kind: 'registration', ref: 'testbed', ...(me === undefined ? {} : { by: me.id }) },
data: parsed.data.data,
...(Object.keys(parsed.data.assignments).length === 0 ? {} : { assignments: parsed.data.assignments }),
});
return c.json({ workflowId: record.id, state: record.state });
} catch (error) {
// 起不来的原因要如实说(不合调用接口 / 无生效版 / 岗位空缺),别让人对着空白猜
const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 400);
}
});
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
app.get(traceOfWorkflowRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const spans = await container.trace.ofWorkflow(c.req.param('id'));
return c.json({ spans });
});
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
app.get(govWorkflowRoute.path, async (c) => {
const denied = await readTask(c.get('account'));