三面拆分⑬:流程包纯编排化 —— 编排与传递契约归包,实现方式归库,激活看双判据

模型(用户定):流程包只决定活动编排 + 活动传递的契约,不决定活动的实现方式;
节点只分活动点与审批点;图只定名称与顺序,属性在卡上填,链接好实现即可运行;
激活 = ①Mastra 式图编排闭合完善 + ②活动装载,两面判据。

schema(库经外科手术清空,无历史包袱,网关彻底出局):
- contracts:删 gateway 节点变体 / GATEWAY_KINDS / GATEWAY_KIND_LABEL /
  'gateway-deprecated' 违规项 —— 节点四种:start/end(结构件)+ activity/approval
- routing:joinReady 整个删除(多入边即异或汇合,没有「等回齐」;AND 待并行组);
  selectEdges 精简;closure 删 checkGateway;process-service 删汇合等待与网关直传,
  节点一律只收装配后载荷(verdict/outcome 判据由系统注入,不走映射)

编排面(画布 = 名称 + 顺序,其余在属性卡):
- 元素盘五件:活动点(单一,不再分标准/案例 —— 那是实现方式)/审批点/起止/卡点
- 属性卡:节点类型二分(活动点/审批点);新增「链接活动实现」选择器(装载面,
  FlowNode.act 经 merge 成为语义 activityId);范式/版本降为只读装载信息
- 边属性可填:条件(S-FEEL)+ 映射(上游输出口/起单载荷/常量三种来源;
  workflow-var/expression 归系统隐性,不进设计者面)—— FlowEdge.condition/mapping
  工作副本,pkg-to-graph 回填、graph-to-pkg 合并,清空条件即变默认兜底边
- 异常看板 → 激活双判据看板:图编排(结构+属性+契约)与活动装载(unknown-activity)
  分面亮灯,两面清零激活才亮
- pkg-to-graph 去 paradigms 绿化与网关分支;图例/文案全线「活动点/审批点」

测试:closure 27(joinReady 两组 describe 随机制摘除)、advance-order 按真图形状重写
(去 as never 捏造的网关节点)、graph-to-pkg 9(新增 act 合并 + 边契约合并/清空用例)、
seed 8、端到端八例真库全绿;五闸退出码验过(api:check 提交后转绿)。

库:purge 后重种,唯一 unified-quote@v1 生效(10 节点 14 边,双面判据齐)。
债:M5-5 清账(兼容路径连 schema 一起摘);M5-4(并行组)仍挂。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 18:29:32 +08:00
co-authored by Claude Fable 5
parent 6854524784
commit 281f4ef084
22 changed files with 458 additions and 634 deletions
+17 -10
View File
@@ -1,6 +1,6 @@
# module: process
职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图即校验 · 不闭合不得保存)、
职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图不检查 · 不闭合不得激活)、
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
画布的绘制(见 apps/web 的 gov-process)
@@ -14,7 +14,7 @@
- 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停)
- 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState`
(`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011)
`isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady`
`isDigested` `nextInstanceNumber` `selectEdges` `assemble`
`rejectRouteOf` `dispatchOf` `startNodeOf` `advanceOrder`
(`advanceOrder` = 推进次序,按边拓扑排、排除回边、并行支按编号定序;
呈现用,不参与流转判断 —— 图纸坐标不配决定次序)
@@ -28,13 +28,20 @@
- **画图不检查,激活才查完整**(产品决策 2026-07-28):`savePackage` 不跑闭合校验,
随手画的半成品也存得进来(存出来即 `retired`,不生效);`setPackageState` 激活时
跑 `checkClosure`,不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。
违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、含网关、
容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。
- **无网关**(产品决策 2026-07-28):分支 = 源节点的多条条件出边(恰取一条,
无条件边作默认兜底、至多一条),汇合 = 多入边异或(上游互斥,只会来一条)。
schema 仍收 `kind: 'gateway'` 只为读历史版本 —— 在途工作流照走(运行时兼容),
但含网关的定义激活即 `gateway-deprecated` 违规,**仅可读,不可激活**。
违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、
活动未装载、岗位不存在、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。
- **纯编排模型**(产品决策 2026-07-28 第二轮):流程包只决定**活动编排**与
**活动传递的契约**,不决定活动的实现方式(范式/版本住活动库,节点仅以
`activityId` 链接)。节点只分**活动点**与**审批点**(起点/终点是结构件);
`kind: 'gateway'` 已从 schema 摘除(库经外科手术清空,无历史包袱)。
分支 = 源节点的多条条件出边(恰取一条,无条件边作默认兜底、至多一条),
汇合 = 多入边异或(上游互斥,只会来一条 —— `joinReady` 已删,没有「等回齐」)。
AND 并行暂无声明机制,将来以「并行组」加回(对应 Mastra `.parallel`,见 debt.md)。
- **激活双判据**:①图编排闭合完善(结构 + 属性 + 传递契约,`checkClosure` 的
非 unknown-activity 项)②活动装载(节点链接的实现已安装,`unknown-activity` 项)。
两面都清零才可激活;图照画照存,未装载不拦保存。
系统隐性上下文(审批 `verdict`、放行选口 `outcome` 的判据注入、起单载荷穿透)
由引擎决定,不进设计者的编排面。
- **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占
(同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。
起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。
@@ -55,8 +62,8 @@
一条都没中而该节点确有出边 = **死路**,发 `route-dead-end` 事件
(哪个节点、各出边的条件、判据里当时有哪些键 —— 只记键名不记值)。
审批点的裁决(`verdict`)由引擎注入判据,两个去向就是它自己的两条条件出边
- **异或汇合不等**(`joinReady`):互斥分支结构上只会来一条,等齐 = 等一个不可能
到来的分支,流会无声卡死;AND 回齐逻辑仅剩历史版本的并行网关在用,容忍分支以「已消化」计
- **异或汇合不等**:互斥分支结构上只会来一条,等齐 = 等一个不可能到来的分支,
流会无声卡死 —— 汇合等待机制已随网关整体摘除,容忍分支消化后直接推进
- **流程层不自动重试活动**(过程内重试归 Harness);
流级重做一律人裁决后**重开新任务实例**
- **驳回走向声明式**:审批点的驳回按 `reject` 声明回指定节点重开新实例,不由临场决定
@@ -4,6 +4,7 @@
* 这条曾经拿 `layout.col` 排,在**回形走线**的图上读错过:MVP 图第 0 行左→右、
* 第 1 行右→左、第 2 行折回最左,A7 的列位跟 A1 并列,链就读成 A1 → A7 → A2 → A6 → A3。
* 所以这里的第一个用例就是那张回形图 —— 保证它只能读作 A1 → … → A7。
* (图形状 = 现行无网关模型:分支是条件出边,条件对拓扑序无影响,这里一律省略。)
*/
import { describe, expect, it } from 'vitest';
import type { Edge, ProcessNode, ProcessPackage } from '@contracts';
@@ -21,16 +22,6 @@ const act = (id: string, col: number, row: number): ProcessNode =>
layout: { row, col },
}) as never;
const gw = (id: string, mode: 'fork' | 'join', col: number, row: number): ProcessNode =>
({
id,
kind: 'gateway',
label: id,
gateway: 'exclusive',
mode,
layout: { row, col },
}) as never;
const edge = (from: string, to: string, reopen = false): Edge =>
({ id: `e-${from}-${to}`, from, to, mapping: [], reopen }) as never;
@@ -54,40 +45,34 @@ const acts = (p: ProcessPackage): string[] =>
.map((n) => n.id);
describe('advanceOrder', () => {
/** MVP 图原样:col 是蛇形列位 —— A7(col 1)与 A1(col 1)并列,A6(col 3)与 A2(col 3)并列 */
/** MVP 图原样(无网关:分支即多出边):col 是蛇形列位 —— A7(col 1)与 A1(col 1)并列 */
const serpentine = pkg(
[
{ id: 'start', kind: 'start', label: '询价', layout: { row: 0, col: 0 } } as never,
act('A1', 1, 0),
gw('G1', 'fork', 2, 0),
act('A2', 3, 0),
gw('G2', 'fork', 4, 0),
act('A3', 5, 0),
gw('G2J', 'join', 6, 0),
act('A4', 7, 0),
act('A5', 6, 1),
act('A6', 3, 1),
gw('G4', 'fork', 2, 1),
act('A2', 2, 0),
act('A3', 3, 0),
act('A4', 4, 1),
act('A5', 3, 1),
act('A6', 2, 1),
act('A7', 1, 2),
{ id: 'end_won', kind: 'end', label: '成交', layout: { row: 1, col: 0 } } as never,
{ id: 'end_arch', kind: 'end', label: '归档', layout: { row: 2, col: 0 } } as never,
],
[
edge('start', 'A1'),
edge('A1', 'G1'),
edge('G1', 'A2'),
edge('G1', 'G2'),
edge('A2', 'G2'),
edge('G2', 'A3'),
edge('G2', 'G2J'),
edge('A3', 'G2J'),
edge('G2J', 'A4'),
edge('A1', 'A2'),
edge('A1', 'A3'),
edge('A1', 'A4'),
edge('A2', 'A3'),
edge('A2', 'A4'),
edge('A3', 'A4'),
edge('A4', 'A5'),
edge('A5', 'A6'),
edge('A6', 'G4'),
edge('G4', 'end_won'),
edge('G4', 'A4', true), // 回边:有异议退回重新定价
edge('G4', 'A7'),
edge('A5', 'A4', true), // 回边:驳回退回重新定价
edge('A6', 'end_won'),
edge('A6', 'A4', true), // 回边:有异议退回重新定价
edge('A6', 'A7'),
edge('A7', 'end_arch'),
],
);
@@ -96,12 +81,10 @@ describe('advanceOrder', () => {
expect(acts(serpentine)).toEqual(['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7']);
});
it('起点在最前、网关按边落位、两个终点都在 A7 之后', () => {
it('起点在最前,两个终点都在末段活动之后', () => {
const order = ids(serpentine);
expect(order[0]).toBe('start');
expect(order.indexOf('G1')).toBeGreaterThan(order.indexOf('A1'));
expect(order.indexOf('G2J')).toBeGreaterThan(order.indexOf('A3'));
// 两个终点谁在最后没有意义(都是终态),但都必须在最后一个活动之后
// 两个终点谁在最后没有意义(都是终态),但都必须在产出它的活动之后
expect(order.indexOf('end_won')).toBeGreaterThan(order.indexOf('A6'));
expect(order.indexOf('end_arch')).toBeGreaterThan(order.indexOf('A7'));
});
@@ -125,27 +108,26 @@ describe('advanceOrder', () => {
expect(acts(pkg(nodes, [...forward, edge('AB', 'AZ')]))).toEqual(['AB', 'AZ']);
});
it('真并行支按编号定序 —— 同一个包在谁手上都读作同一条链', () => {
const parallel = pkg(
it('互斥分支在图上本无先后 —— 链按编号定序,同一个包在谁手上都读作同一条链', () => {
const branchy = pkg(
[
{ id: 'start', kind: 'start', label: '起', layout: { row: 0, col: 0 } } as never,
gw('F', 'fork', 1, 0),
act('A0', 1, 0),
act('AZ', 2, 0),
act('AB', 2, 1),
gw('J', 'join', 3, 0),
{ id: 'end', kind: 'end', label: '终', layout: { row: 0, col: 4 } } as never,
{ id: 'end', kind: 'end', label: '终', layout: { row: 0, col: 3 } } as never,
],
[
edge('start', 'F'),
edge('F', 'AZ'),
edge('F', 'AB'),
edge('AZ', 'J'),
edge('AB', 'J'),
edge('J', 'end'),
// A0 的两条互斥出边(条件对拓扑序无影响,省略)—— 书写顺序 AZ 在前
edge('start', 'A0'),
edge('A0', 'AZ'),
edge('A0', 'AB'),
edge('AZ', 'end'),
edge('AB', 'end'),
],
);
// 书写顺序是 AZ 在前,但编号 AB < AZ —— 链按编号读
expect(acts(parallel)).toEqual(['AB', 'AZ']);
expect(acts(branchy)).toEqual(['A0', 'AB', 'AZ']);
});
it('不可达节点不丢 —— 按编号缀在末尾', () => {
@@ -1,8 +1,6 @@
/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
import { describe, expect, it } from 'vitest';
import { processPackageSchema } from '@contracts';
import type { ProcessPackage } from '@contracts';
import { joinReady } from '../internal/domain/routing.ts';
import { checkClosure } from '../internal/domain/closure.ts';
import type { ActivityContractFace } from '../internal/domain/closure.ts';
import {
@@ -126,25 +124,6 @@ describe('checkClosure —— 不闭合不得激活', () => {
expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0);
});
/*
* 网关已废除(2026-07-28):含网关的定义一律违规 —— 历史版本仅可读,不可激活。
* 分支改由源节点的条件出边表达,见下一用例。
*/
it('网关节点即违规 —— gateway-deprecated,历史版本不可激活', () => {
const withGateway = pkg({
nodes: [
...pkg().nodes,
{ id: 'G2' as never, kind: 'gateway', label: 'G2 料号分流', gateway: 'exclusive', mode: 'fork' },
],
edges: [
...pkg().edges,
{ id: 'g1', from: 'A4' as never, to: 'G2' as never, mapping: [], reopen: false },
{ id: 'g2', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: withGateway, contracts, positions }).violations;
expect(violations.filter((v) => v.kind === 'gateway-deprecated').map((v) => v.nodeId)).toEqual(['G2']);
});
it('多出边异或分支:两条无条件出边 = 都想当默认 —— multi-default-edge', () => {
const twoDefaults = pkg({
@@ -340,92 +319,8 @@ describe('失败走向的消化与重开', () => {
});
});
describe('joinReady —— 网关直通道不参与等待', () => {
const pkg = processPackageSchema.parse({
id: 'p-join',
name: '汇合测试',
version: 1,
state: 'active',
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [
{ kind: 'start', id: 'S', label: '开始' },
{ kind: 'gateway', id: 'F', label: '分叉', gateway: 'parallel', mode: 'fork' },
{ kind: 'activity', id: 'X', label: 'X', activityId: 'a-x', owner: { kind: 'position', position: 'BD' } },
{ kind: 'activity', id: 'Y', label: 'Y', activityId: 'a-y', owner: { kind: 'position', position: 'BD' } },
{ kind: 'gateway', id: 'J', label: '汇合', gateway: 'parallel', mode: 'join' },
{ kind: 'end', id: 'E', label: '结束' },
],
edges: [
{ id: 'e1', from: 'S', to: 'F', mapping: [], reopen: false },
{ id: 'e2', from: 'F', to: 'X', mapping: [], reopen: false },
{ id: 'e3', from: 'F', to: 'Y', mapping: [], reopen: false },
// 分叉直连汇合的**直通道**(TYPE-A 快路):来源是网关,永远不会有实例
{ id: 'e4', from: 'F', to: 'J', mapping: [], reopen: false },
{ id: 'e5', from: 'X', to: 'J', mapping: [], reopen: false },
{ id: 'e6', from: 'Y', to: 'J', mapping: [], reopen: false },
{ id: 'e7', from: 'J', to: 'E', mapping: [], reopen: false },
],
variables: [],
});
it('两条责任分支都收敛 → 回齐(网关直通道不算一票)', () => {
expect(joinReady(pkg, 'J', new Set(['X', 'Y']), new Set())).toBe(true);
});
it('一条容忍消化、一条收敛 → 回齐', () => {
expect(joinReady(pkg, 'J', new Set(['X']), new Set(['Y']))).toBe(true);
});
it('还差一条 → 不回齐', () => {
expect(joinReady(pkg, 'J', new Set(['X']), new Set())).toBe(false);
});
it('把网关来源也算进去就会永远等不到 —— 这是回归守卫', () => {
// 若实现改回「全部入边都要收敛」,上面第一条断言必然转 false
expect(joinReady(pkg, 'J', new Set(['X', 'Y']), new Set())).not.toBe(false);
});
});
/*
* 异或汇合不等 —— 这条曾经让流无声停住。
*
* G2J(寻源汇合)是异或:「需要寻源」走 A3、「不需要」直通,两条互斥、只来一条。
* 而 `joinReady` 原本按并行汇合写(全部责任分支收敛才放行),于是在「不需要寻源」那条路上
* 永远在等 A3 —— 一个结构上不会激活的分支。症状最坑的地方是**连死路事件都没有**:
* 边是选中了的,只是在汇合判定里被 continue 跳过,从外面看就是 A1 办结后什么都不再发生。
* joinReady 已随网关一并摘除(2026-07-28 第二轮):多入边即异或汇合 —— 上游互斥,
* 只会来一条,**没有「等回齐」这回事**;AND 并行待「并行组」声明时再带自己的汇合语义。
* 从前它守的坑(异或汇合等一个不可能到来的分支、流无声停住)从机制上不存在了。
*/
describe('joinReady —— 异或汇合只来一条,不等', () => {
const xor = processPackageSchema.parse({
id: 'p-xor-join',
name: '异或汇合测试',
version: 1,
state: 'active',
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [
{ kind: 'start', id: 'S', label: '开始' },
{ kind: 'gateway', id: 'G', label: '要不要绕', gateway: 'exclusive', mode: 'fork', basis: '判据' },
{ kind: 'activity', id: 'X', label: 'X', activityId: 'a-x', owner: { kind: 'position', position: 'BD' } },
{ kind: 'gateway', id: 'J', label: '汇合', gateway: 'exclusive', mode: 'join' },
{ kind: 'end', id: 'E', label: '结束' },
],
edges: [
{ id: 'e1', from: 'S', to: 'G', mapping: [], reopen: false },
{ id: 'e2', from: 'G', to: 'X', condition: 'go = true', mapping: [], reopen: false },
// 直通:不绕 X,从网关直接进汇合
{ id: 'e3', from: 'G', to: 'J', condition: 'go = false', mapping: [], reopen: false },
{ id: 'e4', from: 'X', to: 'J', mapping: [], reopen: false },
{ id: 'e5', from: 'J', to: 'E', mapping: [], reopen: false },
],
variables: [],
});
it('走直通那条:X 从未激活,汇合照样放行', () => {
expect(joinReady(xor, 'J', new Set(), new Set())).toBe(true);
});
it('走 X 那条:X 收敛后放行', () => {
expect(joinReady(xor, 'J', new Set(['X']), new Set())).toBe(true);
});
// 并行汇合「该等的还得等」由上一个 describe 的「还差一条 → 不回齐」守着,不在这儿重复
});
-1
View File
@@ -40,7 +40,6 @@ export {
advanceOrder,
assemble,
dispatchOf,
joinReady,
rejectRouteOf,
selectEdges,
startNodeOf,
@@ -6,8 +6,6 @@
* 存在未完成边、未满足必填或类型冲突的流程包**存不进来**。
* 多出边即异或分支(恰取一条,默认兜底至多一条);多入边即异或汇合。」
*
* 网关已废除(2026-07-28):schema 仍收 gateway 只为读历史 —— 见 checkGateway。
*
* 原文是「可存草稿、不得发布」——**已不成立**:没有草稿态也没有发布门,
* 闸落在保存上(严格检查 + 保存即生效)。画布上的半成品留在浏览器里,存不进库。
*
@@ -68,10 +66,6 @@ export function checkClosure(input: ClosureInput): ClosureReport {
message: `审批点「${node.label}」的岗位不存在:${node.approver.position}`,
});
}
if (node.kind === 'gateway') {
violations.push(...checkGateway(node, incoming.get(node.id) ?? [], outgoing.get(node.id) ?? []));
}
/*
* 多出边 = 异或分支(恰取一条):条件命中优先,无条件边作默认兜底。
* 默认兜底**至多一条** —— 两条无条件出边就是「都想当默认」,运行时按声明序
@@ -169,28 +163,6 @@ function checkActivityNode(
return out;
}
function checkGateway(
node: Extract<ProcessNode, { kind: 'gateway' }>,
inbound: readonly Edge[],
outbound: readonly Edge[],
): ClosureViolation[] {
void inbound;
void outbound;
/*
* **网关已废除**(产品决策 2026-07-28):分支 = 源节点的条件出边,汇合 = 多入边异或。
* schema 仍收 gateway 只为**读历史版本** —— 在途工作流照走(运行时兼容),
* 但含网关的定义不再可激活:这里判违规,激活闸(不闭合不得激活)就会拦下。
* 想复用旧图,在生效版上把网关的出边改画成源活动的条件出边即可。
*/
return [
{
kind: 'gateway-deprecated',
nodeId: node.id,
message: `网关「${node.label}」已废除 —— 把分支写成上游节点的条件出边(历史版本仅可读,不可激活)`,
},
];
}
/**
* 容忍(仅限 AND 分支):静态校验其**下游映射不得依赖该分支的必填字段**(OS 03 失败走向)。
*/
@@ -2,7 +2,7 @@
* 流程引擎的确定性流转 —— 纯逻辑,零 IO、**零 LLM**(编码规范 §5.3;语义出处 OS 04)。
*
* 「流程引擎 —— 确定性流转:接收调用接口的起单、按结构调度节点、把审批点卡到人的工位、
* 执行网关的分合、把每次流转记入事件。**零 LLM、零临场**。」
* 按条件出边选路、把每次流转记入事件。**零 LLM、零临场**。」
*/
import type { Edge, ProcessNode, ProcessPackage } from '@contracts';
@@ -72,66 +72,20 @@ export function selectEdges(
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);
/*
* **多出边一律异或,不分节点种类**(产品决策 2026-07-28:网关去节点化)。
*
* 分支不再是网关节点的专利 —— 活动/审批点自己的多条条件出边就是分支,
* 判据读的是它自己的出参(与 Mastra 的 `.branch` 同构:条件挂在步与步之间)。
* 语义与从前的异或网关逐字相同:条件命中优先(按声明序),无条件边作默认兜底,
* 恰取一条。并行(AND)没有节点了也没有声明机制 —— 要并行时以「并行组」声明加回
* (对应 Mastra 的 `.parallel` 结构块),见债务簿。
*
* 遗留的网关节点(历史版本里还有)走同一条逻辑 —— 异或网关的语义本来就是这个;
* 并行网关分叉的「全发」不再支持,历史包若有,激活时已被闭合校验拦下。
* **多出边一律异或,不分节点种类**(与 Mastra 的 `.branch` 同构:条件挂在步与步之间)。
* 条件命中优先(按声明序),无条件边作默认兜底,恰取一条。
* 并行(AND)没有声明机制 —— 要并行时以「并行组」加回(对应 `.parallel`,债 M5-4)。
*/
if (outgoing.length <= 1) {
// 并行网关的遗留直通不受损:单出边不判条件,原样走
void node;
return outgoing;
}
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];
}
/**
* 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 };
@@ -23,7 +23,7 @@ import type {
import { checkClosure } from '../domain/closure.ts';
import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts';
import type { NodeInstance } from '../domain/owner-resolution.ts';
import { assemble, dispatchOf, joinReady, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts';
import { assemble, dispatchOf, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts';
export type ProcessServiceDeps = {
readonly store: ProcessStore;
@@ -229,23 +229,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
const target = pkg.nodes.find((n) => n.id === edge.to);
if (target === undefined) continue;
if (target.kind === 'gateway' && target.mode === 'join') {
const instances = await deps.store.listInstances(record.id);
const converged = new Set(instances.filter((i) => i.terminal === 'released').map((i) => i.nodeId));
const tolerated = new Set(instances.filter((i) => i.failureDigested === true).map((i) => i.nodeId));
// 未回齐就等着 —— 别的分支到了会再来一次
if (!joinReady(pkg, target.id, converged, tolerated)) continue;
}
const assembled = assemble(edge, { ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, deps.sfeel.expression);
/*
* 网关不是责任单元,**按到达的东西路由**:它没有入参契约要满足,
* 上游的输出口载荷要原样穿过去,判据才读得到(如 G4 读 outcome)。
* 若只给边映射装配出来的那份,无映射的边就把判据依据整个丢掉,
* 异或网关会一条分支都选不中,整条流悄无声息地停住。
* 活动与审批点仍只收**装配后的**载荷 —— 它们有契约,不该被上游多余字段污染。
* 多入边即异或汇合:上游互斥,只会来一条 —— **没有「等回齐」这回事**。
* (等待逻辑随网关一起摘除;AND 并行待「并行组」声明时再带自己的汇合语义。)
* 节点只收**装配后的**载荷:活动与审批点都有契约,不被上游多余字段污染;
* 判据要用的隐性上下文(审批 verdict、放行选口 outcome)由系统注入判据作用域,不走映射。
*/
const arriving = target.kind === 'gateway' ? { ...payload, ...assembled } : assembled;
const arriving = assemble(edge, { ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, deps.sfeel.expression);
try {
await enter(record, pkg, target, arriving);
} catch (error) {
@@ -450,8 +440,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
if (disposition.kind === 'tolerate') {
/*
* 容忍 = 这条支线的失败被吸收,**流继续往下走**。
* 只把实例标成「已消化」而不往下推,汇合就永远等不到它:
* `joinReady` 认 tolerated,可没人再去问一次它够不够齐。
* 光把实例标成「已消化」而不往下推,这条线就断在这儿 —— 下游谁也不会再被叫起。
* 载荷为空 —— 容忍分支本就没有产出,下游的必填映射不得依赖它(闭合校验已拦)。
*/
await advanceFrom(record, pkg, nodeId, {});