Files
JX-BPOS/apps/api/src/modules/trace/internal/usecase/trace-service.ts
T
ChaiHaoandClaude Fable 5 5fc6dfdfe1 仿真测试台(后端):节点指派 + 全链 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
2026-07-29 01:43:49 +08:00

186 lines
7.1 KiB
TypeScript

/**
* trace 用例 —— **手动打点的全链观测**(编码规范 §5.3;`@mastra` 只准 adapter/app §6.2)。
*
* 我们的执行不经 Mastra 图(自持流程运行时 + 自持范式引擎),所以 Mastra 自带的
* 自动 tracing 不会产出任何 span。这里把它的**观测组件**当作产 span 的工具用:
* `Observability` 实例出 span(标准形状:traceId / parentSpanId / input / output /
* errorInfo / 起止时刻),我们在执行链路的关键点手动开合。
*
* 落库不用 Mastra 的 storage —— exporter 那条路是**批量异步**的(缓冲 + 定时冲刷),
* 而测试台要的是「点一下就看得见」。所以 span 一开就落一行、一收就补一行,
* 两笔写都在 usecase 里同步做:**观测面不该比被观测的事晚**。
*
* Mastra 侧只用来生成 id 与保证 span 形状 —— 换掉它只需改 `spanFactory`。
*/
import type { SpanHandle, SpanKind, TraceService, TraceSpan } from '../../contract.ts';
import type { Clock, TraceStore } from '../../port.ts';
/** span 工厂:出 id 与父子关系 —— 由 app 层用 Mastra 的 Observability 供给 */
export type SpanFactory = {
root(name: string): { spanId: string; traceId: string };
child(parent: { traceId: string; spanId: string }, name: string): { spanId: string; traceId: string };
};
export type TraceServiceDeps = {
readonly store: TraceStore;
readonly clock: Clock;
readonly spans: SpanFactory;
};
export function createTraceService(deps: TraceServiceDeps): TraceService {
/** 落一行「开」;失败不掀翻被观测的事 —— 观测坏了不该让业务停摆 */
const open = async (span: TraceSpan): Promise<void> => {
try {
await deps.store.insert(span);
} catch {
/* 观测尽力而为:trace 写不进去也不能让流程跑不动 */
}
};
const close = async (
id: string,
patch: { output?: Record<string, unknown>; error?: { message: string }; attributes?: Record<string, unknown> },
): Promise<void> => {
try {
await deps.store.finish(id, { ...patch, endedAt: deps.clock.now().toISOString() });
} catch {
/* 同上 */
}
};
const handleOf = (
ids: { spanId: string; traceId: string },
scope: { workflowId?: string; nodeId?: string; taskId?: string },
): SpanHandle => ({
id: ids.spanId,
traceId: ids.traceId,
child(input) {
const born = deps.spans.child({ traceId: ids.traceId, spanId: ids.spanId }, input.name);
// 归属向下继承:子 span 没说自己属于谁,就跟父亲同一条流、同一个节点
const childScope = {
...scope,
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
};
void open({
id: born.spanId,
traceId: born.traceId,
parentId: ids.spanId,
name: input.name,
kind: input.kind,
...childScope,
...(input.input === undefined ? {} : { input: input.input }),
...(input.attributes === undefined ? {} : { attributes: input.attributes }),
startedAt: deps.clock.now().toISOString(),
});
return handleOf(born, childScope);
},
async end(out) {
await close(ids.spanId, { ...(out?.output === undefined ? {} : { output: out.output }), ...(out?.attributes === undefined ? {} : { attributes: out.attributes }) });
},
async fail(error) {
await close(ids.spanId, { error: { message: error instanceof Error ? error.message : String(error) } });
},
});
/*
* 作用域索引:workflowId → 工作流 span,taskId → 节点 span。
* 打点散在装配层十几处,让每处自己拎 handle 传来传去,一处漏传整棵树就断了 ——
* 所以由服务记着「这一单现在挂在哪儿」,调用方只报 id。
* 进程内存活即可:trace 已经落库了,这张表只是为了拼父子。
*/
const workflowSpans = new Map<string, SpanHandle>();
const nodeSpans = new Map<string, SpanHandle>();
const service: TraceService = {
beginWorkflow({ workflowId, subject, input }) {
const span = service.start({
name: `流程:${subject}`,
kind: 'workflow',
workflowId,
...(input === undefined ? {} : { input }),
});
workflowSpans.set(workflowId, span);
},
beginNode({ workflowId, nodeId, label, taskId, approvalId, assigneeId, input }) {
// 派给谁、卡了哪张审批单 —— 开 span 那一刻就写清「这一步在谁手上」
const shape = {
name: `${nodeId} ${label}`,
kind: 'node' as const,
nodeId,
...(taskId === undefined ? {} : { taskId }),
...(input === undefined ? {} : { input }),
attributes: {
...(assigneeId === undefined ? {} : { assigneeId }),
...(approvalId === undefined ? {} : { approvalId }),
},
};
// 工作流 span 不在手上(进程重启过)就另起一棵 —— 观测尽力而为,不让业务停摆
const parent = workflowSpans.get(workflowId);
const span = parent === undefined ? service.start({ ...shape, workflowId }) : parent.child(shape);
nodeSpans.set(taskId ?? `${workflowId}:${nodeId}`, span);
},
beginAction({ taskId, name, kind, input }) {
const parent = nodeSpans.get(taskId);
if (parent === undefined) return undefined;
return parent.child({ name, kind, taskId, ...(input === undefined ? {} : { input }) });
},
async endNode({ taskId, workflowId, nodeId, output }) {
const key = taskId ?? `${workflowId}:${nodeId}`;
const span = nodeSpans.get(key);
if (span === undefined) return;
nodeSpans.delete(key);
await span.end(output === undefined ? undefined : { output });
},
async endWorkflow({ workflowId, state }) {
const span = workflowSpans.get(workflowId);
if (span === undefined) return;
workflowSpans.delete(workflowId);
await span.end({ output: { state } });
},
start(input) {
const born =
input.parent === undefined ? deps.spans.root(input.name) : deps.spans.child(input.parent, input.name);
const scope = {
...(input.workflowId === undefined ? {} : { workflowId: input.workflowId }),
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
};
void open({
id: born.spanId,
traceId: born.traceId,
...(input.parent === undefined ? {} : { parentId: input.parent.spanId }),
name: input.name,
kind: input.kind as SpanKind,
...scope,
...(input.input === undefined ? {} : { input: input.input }),
...(input.attributes === undefined ? {} : { attributes: input.attributes }),
startedAt: deps.clock.now().toISOString(),
});
return handleOf(born, scope);
},
async ofWorkflow(workflowId) {
return deps.store.listByWorkflow(workflowId);
},
async ofTask(taskId) {
return deps.store.listByTask(taskId);
},
async flush() {
/* 同步落库,没有缓冲要冲 —— 留这个口子是给将来换成批量 exporter 用的 */
},
};
return service;
}