/** * 流程引擎的确定性流转 —— 纯逻辑,零 IO、**零 LLM**(编码规范 §5.3;语义出处 OS 04)。 * * 「流程引擎 —— 确定性流转:接收调用接口的起单、按结构调度节点、把审批点卡到人的工位、 * 按条件出边选路、把每次流转记入事件。**零 LLM、零临场**。」 */ import type { Edge, ProcessNode, ProcessPackage } from '@contracts'; export type PredicateEvaluator = (expression: string, scope: Readonly>) => boolean; /** 一次流转要做的事 */ export type Dispatch = /** 起一单任务 */ | { readonly kind: 'start-task'; readonly node: Extract; readonly payload: Record } /** 把审批点卡到人的工位 */ | { readonly kind: 'raise-approval'; readonly node: Extract; readonly payload: Record } /** 流程包显式终点:按输出声明交付、按来源回执 */ | { readonly kind: 'finish'; readonly node: Extract; readonly payload: Record }; /** 工作流作用域 —— 数据按**字段名**流动(对齐 Mastra):边不带映射,约束力在两端契约 schema 上 */ export type AssembleScope = { readonly ports: Readonly>>; readonly vars: Readonly>; readonly initiation: Readonly>; }; /** * 铺平作用域:起单入参垫底,沿途各输出口的字段依次盖上(后发生的赢), * 最后是**刚发射的载荷**(Mastra 语义:下一步的 input 首先是上一步的 output)。 * `__` 前缀是运行时自留地(胶合层的 __runSeq / 终点的 __output),不入业务作用域。 */ export function flatScope(scope: AssembleScope, latest: Record = {}): Record { const out: Record = { ...scope.initiation }; for (const [port, payload] of Object.entries(scope.ports)) { if (port.startsWith('__')) continue; if (payload !== null && typeof payload === 'object') Object.assign(out, payload); } Object.assign(out, latest); for (const key of Object.keys(out)) if (key.startsWith('__')) delete out[key]; return out; } /** * 从某节点出发,算出接下来该走的边。 * XOR:条件求值,**恰取一条**;AND:全部出边同时走(分叉)。 * 条件声明式、静态可检,**不得内嵌自由逻辑**。 */ export function selectEdges( pkg: ProcessPackage, fromNodeId: string, scope: Readonly>, evaluate: PredicateEvaluator, ): Edge[] { const outgoing = pkg.edges.filter((e) => e.from === fromNodeId); /* * **多出边一律异或,不分节点种类**(与 Mastra 的 `.branch` 同构:条件挂在步与步之间)。 * 条件命中优先(按声明序),无条件边作默认兜底,恰取一条。 * 并行(AND)没有声明机制 —— 要并行时以「并行组」加回(对应 `.parallel`,债 M5-4)。 */ if (outgoing.length <= 1) return outgoing; 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]; } /** 节点该被怎么调度 */ export function dispatchOf(node: ProcessNode, payload: Record): 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 } | { readonly kind: 'terminate' }; export function rejectRouteOf( node: Extract, scope: AssembleScope, ): RejectRoute { if (node.reject.kind === 'terminate') return { kind: 'terminate' }; // 重开载荷 = 铺平作用域 —— 重入的节点按自己的输入契约取字段,不靠这里挑 return { kind: 'reopen', nodeId: node.reject.nodeId, payload: flatScope(scope) }; } /** 起点:找出流程包的入口节点 */ 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(); 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(); 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]; }