Archived
仿真测试台(后端):节点指派 + 全链 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:
@@ -32,6 +32,8 @@ export const workflowTable = sys.table(
|
||||
initiation: jsonb('initiation').notNull(),
|
||||
/** 工作组绑定快照(ADR-011)—— 起单时装载,之后改组不影响这条单 */
|
||||
workGroup: jsonb('work_group'),
|
||||
/** 节点指派:{ 节点id → 员工id } —— 测试台定向;生产起单不传,照旧轮值 */
|
||||
assignments: jsonb('assignments'),
|
||||
ports: jsonb('ports').notNull().default({}),
|
||||
vars: jsonb('vars').notNull().default({}),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -21,6 +21,7 @@ function toWorkflow(row: WorkflowRow): WorkflowRecord {
|
||||
source: row.source as WorkflowRecord['source'],
|
||||
initiation: row.initiation as Record<string, unknown>,
|
||||
...(row.workGroup === null ? {} : { workGroup: row.workGroup as Record<string, string> }),
|
||||
...(row.assignments === null ? {} : { assignments: row.assignments as Record<string, string> }),
|
||||
ports: row.ports as Record<string, Record<string, unknown>>,
|
||||
vars: row.vars as Record<string, unknown>,
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
@@ -107,6 +108,7 @@ export function createProcessStore(db: Db): ProcessStore {
|
||||
source: record.source,
|
||||
initiation: record.initiation,
|
||||
workGroup: record.workGroup ?? null,
|
||||
assignments: record.assignments ?? null,
|
||||
ports: record.ports,
|
||||
vars: record.vars,
|
||||
startedAt: new Date(record.startedAt),
|
||||
@@ -151,6 +153,8 @@ export function createProcessStore(db: Db): ProcessStore {
|
||||
...(r.failureDigested === null ? {} : { failureDigested: r.failureDigested }),
|
||||
...(r.taskId === null ? {} : { taskId: r.taskId }),
|
||||
...(r.approvalId === null ? {} : { approvalId: r.approvalId }),
|
||||
// 派给谁:审批箱收件、员工面链条、治理列表全按它认人 —— 写了不读回来等于没写
|
||||
...(r.assigneeId === null ? {} : { assigneeId: r.assigneeId }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -101,21 +101,31 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim
|
||||
}
|
||||
|
||||
/*
|
||||
* 谁来干:**编排不带岗位**(2026-07-28 第五轮,活动点与审批点同一口径)——
|
||||
* 负责人是**运营时分配**的:工作流绑了员工组就按组轮值(组员并集里最闲者先),
|
||||
* 没绑组就全员轮值;staffId 稳定排序保证同输入必同结果。
|
||||
* 分派结果记在节点实例上(assignee)—— 审批箱、链条、治理列表都按它认人。
|
||||
* 谁来干:**编排不带岗位**(活动点与审批点同一口径)——负责人是**运营时分配**的。
|
||||
* 三级取人,先到先得:
|
||||
* ① **节点指派**:起单时点名了这个节点归谁(测试台定向)—— 人在岗就是他;
|
||||
* ② 员工组轮值:工作流绑了组,在组员并集里挑最闲的;
|
||||
* ③ 全员轮值:在岗候选并集里挑最闲的。
|
||||
* staffId 稳定排序保证同输入必同结果;分派结果记在节点实例上(assignee)——
|
||||
* 审批箱、链条、治理列表都按它认人。
|
||||
*/
|
||||
const seen = new Map<string, { staffId: string; name: string; openTasks: number }>();
|
||||
for (const position of await deps.org.positions()) {
|
||||
for (const c of await deps.org.candidates(position)) if (!seen.has(c.staffId)) seen.set(c.staffId, c);
|
||||
}
|
||||
const everyone = [...seen.values()];
|
||||
|
||||
// ① 点名优先:指的人得在岗 —— 不在岗就退回轮值,单不能没人接
|
||||
const named = record.assignments?.[node.id];
|
||||
const namedHit = named === undefined ? undefined : everyone.find((c) => c.staffId === named);
|
||||
|
||||
const group = new Set(Object.values(record.workGroup ?? {}));
|
||||
const groupPool = everyone.filter((c) => group.has(c.staffId));
|
||||
// 组员全不在岗时退回全员 —— 单不能没人接
|
||||
const pool = groupPool.length > 0 ? groupPool : everyone;
|
||||
const spec = { position: groupPool.length > 0 ? '(员工组轮值)' : '(全员轮值)', strategy: 'sole' as const };
|
||||
const pool = namedHit !== undefined ? [namedHit] : groupPool.length > 0 ? groupPool : everyone;
|
||||
const spec = {
|
||||
position: namedHit !== undefined ? '(节点指派)' : groupPool.length > 0 ? '(员工组轮值)' : '(全员轮值)',
|
||||
strategy: 'sole' as const,
|
||||
};
|
||||
const resolution = resolveOwner(spec, { candidates: pool });
|
||||
if (resolution.kind === 'vacant') throw new PositionVacantError(spec.position);
|
||||
|
||||
|
||||
@@ -201,6 +201,8 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
vars: {},
|
||||
startedAt: deps.clock.now().toISOString(),
|
||||
...(binding === undefined ? {} : { workGroup: { ...binding } }),
|
||||
// 节点指派:起单时定死某几个节点归谁(测试台定向);不传即照旧轮值
|
||||
...(input.assignments === undefined ? {} : { assignments: { ...input.assignments } }),
|
||||
};
|
||||
await deps.store.insertWorkflow(record);
|
||||
await deps.events.publish({ workflowId: record.id, kind: 'workflow-started', detail: { source: input.source } });
|
||||
|
||||
Reference in New Issue
Block a user