M5(2/3): process 用例与流转 —— 流程引擎的确定性调度

- routing domain:选边(XOR 恰取一条 + 默认分支兜底;AND 全走)、边映射装配(四类来源 +
  S-FEEL 转换)、AND 汇合回齐(容忍分支不阻汇合)、审批点驳回走向声明式
- process-service:起单 → 调度节点 → 负责人四策略解析(结果与依据留痕)→
  活动起单 / 审批点卡工位 / 终点收束按来源回执;任务终态到达继续流转或走失败走向四选一;
  审批驳回按声明回指定节点重开新实例;状态每次变动后重算聚合投影
- adapter:流程包(id × version)、工作流实例、节点实例三表持久 —— 崩溃后可续跑

ADR 待补项登记为 debt M5-1:§6.1 的 Mastra workflow 编译器作为可替换 adapter 后补,
当前由自持确定性状态机承担,语义等价、usecase 不动

325 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 02:01:08 +08:00
co-authored by Claude Opus 5
parent 291fc39179
commit a8f6eef610
15 changed files with 4072 additions and 10 deletions
@@ -0,0 +1,135 @@
/**
* 流程引擎的确定性流转 —— 纯逻辑,零 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 inbound = pkg.edges.filter((e) => e.to === joinNodeId);
// 容忍分支以「已消化」计,不阻汇合
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');
}