forked from ChaiHao/BPOS-MVP
用户定的机制对齐:Mastra 的边不传数据,所有约束力靠输入/输出字段(schema 咬合)完成。
照此把「边映射」整个退役:
· 契约:edge 去 mapping,审批 reject 去 mapping —— 边只剩 顺序 + 条件(读上游
输出字段)+ 标签/回边。fieldMapping 只留给触发器的起单装配(系统边界)。
存量包无需迁移:zod 解析时把旧 jsonb 里的 mapping 静默剥掉,图照常有效。
· 运行时:assemble(边装配)→ flatScope(铺平作用域)——
起单入参垫底 ← 沿途各输出口字段依次盖上 ← 刚发射的载荷(Mastra 语义:
下一步的 input 首先是上一步的 output)。下游按自己的契约取字段;
「__」前缀是运行时自留地,不入业务作用域。审批驳回重开、失败改道同一条路。
· 闭合校验:边未映射/必填未映射/容忍依赖三条映射类检查退役
(宽进之下必填有没有着落是运行时补全单的事,不拦激活);
激活闸只留一条硬伤 —— **同名不同型**:名字即管道,名同型异必坏,
容忍依赖改按名判。改名映射不存在:名不对改契约,不是加映射。
· 种子:A2 的入参 material 更名 pn(它就是 PN;上游 A1 发的名就得是它收的名),
契约 0.3.0、槽位 0.8.0;流程种子边全部去映射。
· 前端:边属性卡砍掉「映射 · 载荷装配」整节,只剩 E号/方向/条件/标签;
节点属性卡新增「数据接口 · 按字段名流动」——每个输入字段标出运行时从哪来
(起单入参 / 上游 X / 补全单),输出口列出发什么名;沿边回溯推导,设计期看得见。
画布保存与依赖文件导出同步去映射。
E2E 顺带把宽进接进真流:快速归档流的 A3 要 outcome 而流里没人产 ——
测试如实走补全路(等人 → 答卡 outcome=won → 开跑 → completed)。
实测走查:A2 节点卡上 pn←上游A1、rfqRef←起单入参、scenario←补全单,推导全对。
五闸绿;契约快照随本提交入库。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
142 lines
6.7 KiB
TypeScript
142 lines
6.7 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> };
|
|
|
|
/** 工作流作用域 —— 数据按**字段名**流动(对齐 Mastra):边不带映射,约束力在两端契约 schema 上 */
|
|
export type AssembleScope = {
|
|
readonly ports: Readonly<Record<string, Record<string, unknown>>>;
|
|
readonly vars: Readonly<Record<string, unknown>>;
|
|
readonly initiation: Readonly<Record<string, unknown>>;
|
|
};
|
|
|
|
/**
|
|
* 铺平作用域:起单入参垫底,沿途各输出口的字段依次盖上(后发生的赢),
|
|
* 最后是**刚发射的载荷**(Mastra 语义:下一步的 input 首先是上一步的 output)。
|
|
* `__` 前缀是运行时自留地(胶合层的 __runSeq / 终点的 __output),不入业务作用域。
|
|
*/
|
|
export function flatScope(scope: AssembleScope, latest: Record<string, unknown> = {}): Record<string, unknown> {
|
|
const out: Record<string, unknown> = { ...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<Record<string, unknown>>,
|
|
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<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,
|
|
): 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<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];
|
|
}
|