This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/api/src/modules/process/internal/domain/routing.ts
T
ChaiHaoandClaude Opus 5 58d914b8a2 活动伪函数占位:机器步落台账 + 一张验收单 —— 顺手揪出三个引擎 bug
按你定的形态做:占位一律机器步,每个活动只留一张验收单要人签;
**不碰活动内部的业务判断** —— 打标、定价、比价那些一概不做。

## 占位怎么占

一枚纯工具 `slot.placeholder`:只穿透上游给的值、给显眼的默认(数值一律 0,
主键给「PN-占位」这类),行上写 `status: '占位'`。它不判断任何事。
骨架 = N 个机器步各落一行台账 + 末步一张验收单。
验收单的卡面要人填的正是**该活动该产出、而占位产不出的判断**(如两枚去向旗)——
人类步的回传值同样经 writeOutput 落台账、步序在后,所以人填的覆盖占位的默认。
占位期要走哪个分支,在验收时说,不必改代码。

六个槽位一律 flow 范式(A2/A3/A6 本该是 loop,但 loop 要 Agent 真调模型;
占位是固定序列,声明成 flow 才诚实),那份没人用的 loop 骨架顺手删了。
预算改成固定 humanCalls: 2(机器步不吃人工调用)。

## 三个引擎 bug,都是「无声卡住」

**① 异或汇合在等一条永远不来的分支。** `joinReady` 按并行汇合写(全部责任分支收敛才放行),
可 G2J 是异或 —— 两条分支互斥、结构上只来一条。于是「不需要寻源」这条路上永远在等 A3。
最坑的是**连死路事件都没有**:边是选中了的,只在汇合判定里被 continue 跳过,
从外面看就是 A1 办结后什么都不再发生。已改为异或汇合不等,加了三条测试。

**② 出口载荷取的是落地前的草稿值,不是落地事实。** `releaseWrite` 直接回 `draft.patch`。
人的回传值天生是字符串 `"true"`,列是 boolean,PG 存的时候转了,可发出去的口里还是字符串
—— 下游活动入参校验当场拒收「/needSourcing must be boolean」,节点报 blocked,
而台账里明明是对的。改为合并后**读回来**:出口载荷与台账逐字一致,类型由列定义说了算。

**③ 审批箱按岗位码筛,无视工作组绑定。** 引擎派审批时「绑定先于策略」(ADR-011),
装了组的单实际派给被绑定的人;而收件与裁决两处都只比 `me.position.code` ——
于是「审批派给了我、我的审批箱里没有」,单子谁也看不见,流永远卡在审批点(实测)。
`toChain` 早为同一件事修过一次,这是漏的两处,一并按派单口径改。

## 顺带

`cards.issue` 的验收分支写死 `fields: []`,而 `acceptanceWorkOrderSchema` 本来就有 `fields`
—— 是没填,不是设计上不要。执行与验收现在共用同一份字段解析。

我自己也踩了一个:验收步的出参映射键写成驼峰 `needSourcing`,但**键是台账列名**
(小写蛇形),draftWrite 抛「非法标识符」,而且是在放行之后抛 —— 任务卡在 running、
台账一行没有、推进器一秒一次无限重试。测试里加了「所有出参键必须匹配 ^[a-z][a-z0-9_]*$」。

## 实测走到哪

起单 → A1(两机器步落台账,一张验收单)→ 人填 needAlternative=false →
G1 判「不需要选型」→ G2 判「不需要寻源」→ **G2J 通过** → A4 成本定价办结 →
A5 审批点(审批箱可见)→ 裁决通过 → **G3 判到 A6**(链位 6/7)。
A6 的三个去向要选择组(T2),到那儿为止。

typecheck ✓ lint ✓ depcruise 374 模块 ✓ api:check ✓ 498 测试 ✓

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-28 13:16:17 +08:00

207 lines
8.9 KiB
TypeScript

/**
* 流程引擎的确定性流转 —— 纯逻辑,零 IO、**零 LLM**(编码规范 §5.3;语义出处 OS 04)。
*
* 「流程引擎 —— 确定性流转:接收调用接口的起单、按结构调度节点、把审批点卡到人的工位、
* 执行网关的分合、把每次流转记入事件。**零 LLM、零临场**。」
*/
import type { Edge, ProcessNode, ProcessPackage } from '@contracts';
export type PredicateEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
/** 一次流转要做的事 */
export type Dispatch =
/** 起一单任务 */
| { readonly kind: 'start-task'; readonly node: Extract<ProcessNode, { kind: 'activity' }>; readonly payload: Record<string, unknown> }
/** 把审批点卡到人的工位 */
| { readonly kind: 'raise-approval'; readonly node: Extract<ProcessNode, { kind: 'approval' }>; readonly payload: Record<string, unknown> }
/** 流程包显式终点:按输出声明交付、按来源回执 */
| { readonly kind: 'finish'; readonly node: Extract<ProcessNode, { kind: 'end' }>; readonly payload: Record<string, unknown> };
/** 边映射装配:上游输出口载荷 / 流程变量 / 起单载荷 / 常量 → 下游 data 字段 */
export type AssembleScope = {
readonly ports: Readonly<Record<string, Record<string, unknown>>>;
readonly vars: Readonly<Record<string, unknown>>;
readonly initiation: Readonly<Record<string, unknown>>;
};
export function assemble(
edge: Edge,
scope: AssembleScope,
evaluateExpression: (sfeel: string, scope: Readonly<Record<string, unknown>>) => unknown,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const flat: Record<string, unknown> = { ...scope.initiation, ...scope.vars, ...scope.ports };
for (const mapping of edge.mapping) {
switch (mapping.source.from) {
case 'output-port':
out[mapping.target] = pick(scope.ports[mapping.source.port], mapping.source.path);
break;
case 'workflow-var':
out[mapping.target] = scope.vars[mapping.source.name];
break;
case 'initiation':
out[mapping.target] = pick(scope.initiation, mapping.source.path);
break;
case 'constant':
out[mapping.target] = mapping.source.value;
break;
case 'expression':
out[mapping.target] = evaluateExpression(mapping.source.sfeel, flat);
break;
}
}
return out;
}
function pick(source: Readonly<Record<string, unknown>> | undefined, path: string): unknown {
if (source === undefined) return undefined;
return path.split('.').reduce<unknown>((acc, seg) => {
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[seg];
}, source);
}
/**
* 从某节点出发,算出接下来该走的边。
* XOR:条件求值,**恰取一条**;AND:全部出边同时走(分叉)。
* 条件声明式、静态可检,**不得内嵌自由逻辑**。
*/
export function selectEdges(
pkg: ProcessPackage,
fromNodeId: string,
scope: Readonly<Record<string, unknown>>,
evaluate: PredicateEvaluator,
): Edge[] {
const node = pkg.nodes.find((n) => n.id === fromNodeId);
const outgoing = pkg.edges.filter((e) => e.from === fromNodeId);
if (node?.kind === 'gateway' && node.gateway === 'exclusive' && node.mode === 'fork') {
// 判定依据入事件记录;无条件的边作默认分支兜底
const hit = outgoing.find((e) => e.condition !== undefined && evaluate(e.condition, scope));
const fallback = outgoing.find((e) => e.condition === undefined);
const chosen = hit ?? fallback;
return chosen === undefined ? [] : [chosen];
}
// 并行网关分叉、活动节点、审批点:全部出边
return outgoing;
}
/**
* AND 汇合是否回齐:全部**责任分支**的来源都已收敛。
*
* 网关本身不是责任单元、永远不会有实例,所以来源是网关的入边(分叉直连汇合的**直通道**)
* 不参与等待 —— 分叉一发生它就算过了。若把它也算进去,汇合会永远等一个不可能到来的
* 「网关收敛」,整条流卡死在这里。
*/
export function joinReady(
pkg: ProcessPackage,
joinNodeId: string,
converged: ReadonlySet<string>,
tolerated: ReadonlySet<string>,
): boolean {
const node = pkg.nodes.find((n) => n.id === joinNodeId);
/*
* **异或汇合不等。**
*
* 上面那句「回齐」是按并行汇合写的:AND 分叉出去的每一支都要跑,所以汇合处要等齐。
* 异或汇合是另一回事 —— 两条分支互斥,**结构上只会来一条**,另一条永远不激活。
* 拿「全部入边都收敛」去要求它,就是在等一个不可能到来的分支:
* 实测 G2J(寻源汇合)在「不需要寻源」这条路上永远 false,流无声停在 G2J,
* 而且**连死路事件都没有** —— 边是选中了的,只是在汇合判定里被 continue 跳过,
* 从外面看就是 A1 办结后什么都不再发生。
*/
if (node?.kind === 'gateway' && node.gateway === 'exclusive') return true;
const isGateway = (nodeId: string): boolean =>
pkg.nodes.find((n) => n.id === nodeId)?.kind === 'gateway';
const inbound = pkg.edges.filter((e) => e.to === joinNodeId && !isGateway(e.from));
// 容忍分支以「已消化」计,不阻汇合
return inbound.every((e) => converged.has(e.from) || tolerated.has(e.from));
}
/** 节点该被怎么调度 */
export function dispatchOf(node: ProcessNode, payload: Record<string, unknown>): Dispatch | undefined {
if (node.kind === 'activity') return { kind: 'start-task', node, payload };
if (node.kind === 'approval') return { kind: 'raise-approval', node, payload };
if (node.kind === 'end') return { kind: 'finish', node, payload };
return undefined;
}
/** 审批点的驳回走向:声明式,**不由临场决定** */
export type RejectRoute =
| { readonly kind: 'reopen'; readonly nodeId: string; readonly payload: Record<string, unknown> }
| { readonly kind: 'terminate' };
export function rejectRouteOf(
node: Extract<ProcessNode, { kind: 'approval' }>,
scope: AssembleScope,
evaluateExpression: (sfeel: string, scope: Readonly<Record<string, unknown>>) => unknown,
): RejectRoute {
if (node.reject.kind === 'terminate') return { kind: 'terminate' };
return {
kind: 'reopen',
nodeId: node.reject.nodeId,
payload: assemble(
{ id: `${node.id}-reject`, from: node.id, to: node.reject.nodeId, mapping: node.reject.mapping, reopen: true },
scope,
evaluateExpression,
),
};
}
/** 起点:找出流程包的入口节点 */
export function startNodeOf(pkg: ProcessPackage): ProcessNode | undefined {
return pkg.nodes.find((n) => n.kind === 'start');
}
/**
* 推进次序 —— 按**边**做拓扑序,不看图纸坐标。
*
* 「链」要回答的是「这一单接下来往哪走」,那是边的事实。曾经拿 `layout.col` 排,
* 因为 v1 的包是一行从左画到右,列位恰好等于推进次序 —— 但 MVP 图是**回形走线**
* (第 0 行左→右、第 1 行右→左、第 2 行折回最左),A7 的列位跟 A1 并列,链就读成了
* A1 → A7 → A2 → A6 → A3(实测)。图纸声明为「不影响语义」,那它也不配决定次序。
*
* 回边(`reopen`)排除在外:它是**重新入场**,不是往前推进。留着它图上就有环,
* 拓扑序无从下手(G4 → A4 会让 A4 等自己的下游)。
*
* 真并行的分支(G2 分出的 A2 / A3)在图上本无先后,链却必须挑一个顺序:
* 按**节点编号**定,于是同样的包在谁手上都读作同一条链。
* 进不了拓扑序的节点(不可达、或排除回边后仍有环)一律按编号缀在末尾 —— 少排序,不丢节点。
*/
export function advanceOrder(pkg: ProcessPackage): readonly ProcessNode[] {
const byId = new Map(pkg.nodes.map((n) => [n.id, n]));
const byNodeId = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const forward = pkg.edges.filter((e) => e.reopen !== true && byId.has(e.from) && byId.has(e.to));
const indegree = new Map(pkg.nodes.map((n) => [n.id, 0]));
const next = new Map<ProcessNode['id'], ProcessNode['id'][]>();
for (const e of forward) {
indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1);
next.set(e.from, [...(next.get(e.from) ?? []), e.to]);
}
// Kahn:每轮从「无未满足前驱」的节点里取编号最小的,顺序才与书写无关
const ready = pkg.nodes.filter((n) => indegree.get(n.id) === 0).map((n) => n.id);
const ordered: ProcessNode[] = [];
const placed = new Set<string>();
while (ready.length > 0) {
ready.sort(byNodeId);
const id = ready.shift()!;
if (placed.has(id)) continue;
placed.add(id);
const node = byId.get(id);
if (node !== undefined) ordered.push(node);
for (const to of next.get(id) ?? []) {
const left = (indegree.get(to) ?? 0) - 1;
indegree.set(to, left);
if (left === 0) ready.push(to);
}
}
const stranded = pkg.nodes.filter((n) => !placed.has(n.id)).sort((a, b) => byNodeId(a.id, b.id));
return [...ordered, ...stranded];
}