仿真测试台(后端):节点指派 + 全链 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
+129 -17
View File
@@ -9,6 +9,7 @@ import type { EventService } from '@modules/event';
import type { LedgerService } from '@modules/ledger';
import type { MemoryService } from '@modules/memory';
import type { ToolboxService, ToolInvoker } from '@modules/toolbox';
import type { TraceService } from '@modules/trace';
import { compileToolBus } from '@modules/toolbox';
import type { HumanToolService } from '@modules/human-tool';
import { renderView } from '@modules/interaction';
@@ -25,7 +26,8 @@ import { createLoopStateStore, createTurnRunner } from '@modules/loop-engine';
import { createSkillEngineService } from '@modules/skill-engine';
import { createTaskService, createTaskStore } from '@modules/task';
import type { TaskService } from '@modules/task';
import { createProcessService, createProcessStore } from '@modules/process';
import { createProcessService, createProcessStore, createSelfHostedRuntime } from '@modules/process';
import type { FlowRuntime, ProcessServiceDeps } from '@modules/process';
import type { ProcessService } from '@modules/process';
import { createTriggerService, createTriggerStore } from '@modules/trigger';
import type { TriggerService } from '@modules/trigger';
@@ -56,6 +58,8 @@ export type EngineWiringDeps = {
readonly skills: SkillEngineService;
readonly release: ReleaseService;
readonly schemas: { validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] } };
/** 全链观测:节点起落、工具/模型/人卡各打一个 span —— 观测尽力而为,不掀翻业务 */
readonly trace: TraceService;
};
export type EngineWiring = {
@@ -251,6 +255,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
}) {
const context = { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap };
const title = String(input.card['title'] ?? '请处理');
// 人卡一张一个 span:发给谁、问了什么、什么工单 —— 「等人」这一段也要在时间轴上
const cardSpan = deps.trace.beginAction({
taskId: input.taskId,
name: `人卡:${title}`,
kind: 'human',
input: { workOrder: input.workOrder, ownerId: input.ownerId },
});
/*
* 依据快照:优先用活动给的结构化 `basis`(`{k,v}[]`);没给才把卡面其余键铺成依据。
@@ -300,6 +311,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
}),
});
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
await cardSpan?.end({ output: { cardId: issued.order.id, arrival: issued.arrival } });
return { id: issued.order.id };
}
const fields = declaredFields ?? [{ key: 'result', label: '结果', kind: 'textarea' as const, required: true }];
@@ -311,19 +323,28 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
});
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
return { id: issued.order.id };
await cardSpan?.end({ output: { cardId: issued.order.id, arrival: issued.arrival } });
return { id: issued.order.id };
},
};
const toolGateway = {
invoke: async (input: { tool: string; input: Record<string, unknown>; activityId: string; taskId: TaskId; stepNo?: number }) => {
const result = await deps.toolInvoker.invoke({
tool: input.tool,
input: input.input,
activityId: input.activityId,
taskId: input.taskId,
...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }),
});
// 每次工具调用一个 span —— 观测面要看得见活动内部到底调了什么、花了多久
const span = deps.trace.beginAction({ taskId: input.taskId, name: `工具:${input.tool}`, kind: 'tool', input: input.input });
const result = await deps.toolInvoker
.invoke({
tool: input.tool,
input: input.input,
activityId: input.activityId,
taskId: input.taskId,
...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }),
})
.catch(async (error: unknown) => {
await span?.fail(error);
throw error;
});
await span?.end({ output: result.ok ? (result.output ?? {}) : { error: result.error?.message } });
return {
ok: result.ok,
...(result.output === undefined ? {} : { output: result.output }),
@@ -400,6 +421,37 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
clock,
});
/**
* 模型网关加观测:每次模型调用一个 span(用途名、标签、用量)——
* 「活动内部到底问了几次模型」在时间轴上要看得见。
* 包一层不改语义:录制回放、四元绑定、凭证隔离全在原网关里,这里只旁观。
*/
const tracedGateway: ModelGateway = {
...deps.modelGateway,
generate: async (call) => {
const span =
call.taskId === undefined
? undefined
: deps.trace.beginAction({
taskId: call.taskId,
name: `模型:${call.label ?? call.model}`,
kind: 'model',
input: { model: call.model, label: call.label },
});
try {
const result = await deps.modelGateway.generate(call);
await span?.end({
output: { chars: result.text.length },
attributes: { tokens: result.usage.inputTokens + result.usage.outputTokens, mode: result.mode },
});
return result;
} catch (error) {
await span?.fail(error);
throw error;
}
},
};
/* ── Agent 回路:单拍 or 开环(工具总线)────────────────────────── */
/** 活动声明的自定义模型名 —— 活动只见用途名,不见厂商型号 */
@@ -455,7 +507,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
const agentRunner =
globalThis.process.env['JCAI_AGENT_OPEN_LOOP'] === '1'
? createMastraAgentRunner({
gateway: deps.modelGateway,
gateway: tracedGateway,
modelOf,
busOf,
onUsage: onAgentUsage,
@@ -479,7 +531,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
},
})
: createAgentRunner({ gateway: deps.modelGateway, onUsage: onAgentUsage, modelOf });
: createAgentRunner({ gateway: tracedGateway, onUsage: onAgentUsage, modelOf });
/* ── loop-engine ──────────────────────────────────────────────── */
const loop = createTurnRunner({
@@ -725,7 +777,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
});
/* ── process(L7) ─────────────────────────────────────────────── */
const process: ProcessService = createProcessService({
const processDeps: ProcessServiceDeps = {
store: createProcessStore(db),
activities: {
async faces() {
@@ -776,8 +828,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
},
tasks: {
start: async (input) =>
task.start({
start: async (input) => {
const started = await task.start({
activityId: input.activityId,
ownerId: input.ownerId,
title: input.title,
@@ -785,11 +837,29 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
nodeId: input.nodeId,
data: input.data,
...(input.message === undefined ? {} : { message: input.message }),
}),
});
// 节点 span:活动内的每一步、每次调用都会挂在它下面
deps.trace.beginNode({
workflowId: input.workflowId,
nodeId: input.nodeId,
label: input.title,
taskId: started.id,
assigneeId: input.ownerId,
input: input.data,
});
return started;
},
},
approvals: {
async raise(input) {
// 审批点:卡到审批人工位 —— 与活动级的放行不同层
deps.trace.beginNode({
workflowId: input.workflowId,
nodeId: input.nodeId,
label: `审批:${input.subject}`,
assigneeId: input.approverId,
input: Object.fromEntries(input.basis.map((b) => [b.k, b.v])),
});
const issued = await deps.humanTool.issueExecution(
{ taskId: input.workflowId as never, ownerId: input.approverId, humanCallCap: 99 },
{
@@ -842,7 +912,24 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
clock,
ids,
});
};
/*
* 观测的挂点:**root span 必须先于任何节点 span**。
* `runtime.begin` 正是「单已建好、还没进第一个节点」那一刻 —— 挂在这儿,
* 后面所有节点/工具/模型/人卡的 span 才认得到父亲。挂在 `start` 返回之后就晚了:
* 起单是同步走到第一个节点的,节点 span 会先出生、另起一棵孤树(实测)。
*/
const selfRuntime = createSelfHostedRuntime(processDeps);
const tracedRuntime: FlowRuntime = {
...selfRuntime,
async begin(record, pkg) {
deps.trace.beginWorkflow({ workflowId: record.id, subject: record.subject, input: record.initiation });
await selfRuntime.begin(record, pkg);
},
};
const process: ProcessService = createProcessService({ ...processDeps, runtime: tracedRuntime });
/* ── trigger(L7) ────────────────────────────────────────────── */
const trigger = createTriggerService({
@@ -897,5 +984,30 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
// 延后绑定落定:此后任务终态才报得到流程层
processRef.bind(process);
return { task, process, trigger };
/**
* 流程服务加观测:起单开一条 trace(root span),终态收尾。
* 包一层不改语义 —— 节点、工具、模型、人卡的 span 都会挂进这条 trace。
*/
const tracedProcess: ProcessService = {
...process,
async onTaskTerminal(taskId, terminal, ports) {
// 节点 span 先收(这一步办完了),再让流程往下走 —— 时间轴上不倒序
await deps.trace.endNode({ taskId, output: { terminal } });
const state = await process.onTaskTerminal(taskId, terminal, ports);
return state;
},
async onApprovalVerdict(workflowId, nodeId, verdict, payload) {
await deps.trace.endNode({ workflowId, nodeId, output: { verdict } });
const state = await process.onApprovalVerdict(workflowId, nodeId, verdict, payload);
if (state === 'completed' || state === 'terminated') await deps.trace.endWorkflow({ workflowId, state });
return state;
},
async refreshState(workflowId) {
const state = await process.refreshState(workflowId);
if (state === 'completed' || state === 'terminated') await deps.trace.endWorkflow({ workflowId, state });
return state;
},
};
return { task, process: tracedProcess, trigger };
}