三面拆分⑫:网关去节点化 —— 分支即条件出边,汇合即多入边异或

模型(与 Mastra .branch 同构,条件挂在步与步之间):
- routing.selectEdges:多出边一律异或,不分节点种类 —— 条件命中优先(按声明序),
  无条件边作默认兜底,恰取一条;单出边直通保留(历史并行网关的遗留通道)
- closure:含网关即 gateway-deprecated(历史版本仅可读、不可激活);
  「隐式合并」禁令删除(多入边=异或汇合,上游互斥无歧义);
  新规:多出边节点的无条件边至多一条,超出报 multi-default-edge
- contracts:CLOSURE_VIOLATION_KINDS 去 gateway-unbalanced/join-ambiguous-source,
  进 multi-default-edge/gateway-deprecated(下游零引用,api-report 随行)

种子(按用户要求:只留一条流一个版本,图/节点/边全部对账):
- MVP_PACKAGE 重写:10 节点 14 边零网关 —— G1/G2 展开为 A1 的三条出边
  (选型/直接寻源/默认直接定价)+ A2 的两条,G2J/G3 整颗删除,
  A5 审批点的两个去向就是自己的两条 verdict 条件出边(驳回边与 reject 声明字字对齐)
- 手绘图:顶车道 y30 补画 A1→A3「直接寻源」—— 画布保存只认画出来的边,
  不画这条语义边会在首次存盘被静默丢掉(新增图边对账测试卡死这类漏画);
  「供应商底价表」标注钉进 y190 车道空档,不再被卡点压住
- 开发库外科手术:清运行数据 + 删 v1–v4,重种为唯一生效版 v1(闭合过闸)

UI 与文案同步:
- 元素盘/追加盘不再有网关件(画布仍认得历史图);图例删「判断/并行网关」「菱形=网关」
- 新建流程补激活一跳(保存不生效模型下,不激活「建出来就能起单」就是谎言)
- 总览四种编排要素、「画图不检查,激活才查完整」贯穿 blueprint/memory/process 文案

测试:closure 43 通过(gateway-deprecated + multi-default-edge 两用例),
seed-mvp-flow 8 通过(含图边对账),端到端八例真库全绿(网关从不建实例,断言原样存活);
五闸 typecheck/lint/depcruise/api:check/test 全过(退出码验)。

债:M5-4 AND 并行待「并行组」声明(Mastra .parallel);M5-5 网关运行时兼容三处,
含网关在途流清零后随 schema 一并摘除。

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 17:56:20 +08:00
co-authored by Claude Fable 5
parent 7a065a50e0
commit 6854524784
17 changed files with 349 additions and 387 deletions
@@ -126,7 +126,11 @@ describe('checkClosure —— 不闭合不得激活', () => {
expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0);
});
it('异或网关的出边必须有条件 —— 声明式、静态可检', () => {
/*
* 网关已废除(2026-07-28):含网关的定义一律违规 —— 历史版本仅可读,不可激活。
* 分支改由源节点的条件出边表达,见下一用例。
*/
it('网关节点即违规 —— gateway-deprecated,历史版本不可激活', () => {
const withGateway = pkg({
nodes: [
...pkg().nodes,
@@ -136,11 +140,32 @@ describe('checkClosure —— 不闭合不得激活', () => {
...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 },
{ id: 'g3', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: withGateway, contracts, positions }).violations;
expect(violations.some((v) => v.message.includes('缺条件'))).toBe(true);
expect(violations.filter((v) => v.kind === 'gateway-deprecated').map((v) => v.nodeId)).toEqual(['G2']);
});
it('多出边异或分支:两条无条件出边 = 都想当默认 —— multi-default-edge', () => {
const twoDefaults = pkg({
nodes: [...pkg().nodes, { id: 'end2' as never, kind: 'end', label: '另一终点' }],
edges: [
...pkg().edges,
// A4 已有一条无条件出边 A4→end;再加一条无条件出边即违规
{ id: 'e9', from: 'A4' as never, to: 'end2' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: twoDefaults, contracts, positions }).violations;
expect(violations.filter((v) => v.kind === 'multi-default-edge').map((v) => v.nodeId)).toEqual(['A4']);
// 其中一条补上条件即闭合 —— 条件命中优先,无条件边只作默认兜底
const oneDefault = pkg({
nodes: twoDefaults.nodes,
edges: twoDefaults.edges.map((e) => (e.id === 'e9' ? { ...e, condition: 'ok = true' } : e)),
});
expect(
checkClosure({ pkg: oneDefault, contracts, positions }).violations.filter((v) => v.kind === 'multi-default-edge'),
).toEqual([]);
});
it('容忍分支的输出不得被下游必填依赖', () => {