Archived
三面拆分⑫:网关去节点化 —— 分支即条件出边,汇合即多入边异或
模型(与 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:
@@ -25,10 +25,16 @@
|
||||
被依赖: trigger, app(治理台 · 工作流管理)
|
||||
|
||||
不变量(OS 03 / 3.2):
|
||||
- **不闭合不得保存**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、
|
||||
汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `closed = false`,`savePackage` 抛 `NotClosedError`
|
||||
- **严格检查 + 保存即生效,没有发布机制**(产品决策 2026-07-28):
|
||||
没有草稿态、没有发布门。保存 = 存为新版 + 即刻生效。
|
||||
- **画图不检查,激活才查完整**(产品决策 2026-07-28):`savePackage` 不跑闭合校验,
|
||||
随手画的半成品也存得进来(存出来即 `retired`,不生效);`setPackageState` 激活时
|
||||
跑 `checkClosure`,不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。
|
||||
违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、含网关、
|
||||
容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。
|
||||
- **无网关**(产品决策 2026-07-28):分支 = 源节点的多条条件出边(恰取一条,
|
||||
无条件边作默认兜底、至多一条),汇合 = 多入边异或(上游互斥,只会来一条)。
|
||||
schema 仍收 `kind: 'gateway'` 只为读历史版本 —— 在途工作流照走(运行时兼容),
|
||||
但含网关的定义激活即 `gateway-deprecated` 违规,**仅可读,不可激活**。
|
||||
AND 并行暂无声明机制,将来以「并行组」加回(对应 Mastra `.parallel`,见 debt.md)。
|
||||
- **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占
|
||||
(同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。
|
||||
起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。
|
||||
@@ -45,10 +51,12 @@
|
||||
|
||||
其它不变量:
|
||||
- **流转零 LLM**:选边与条件求值全部经 `@sfeel`;判定依据入事件记录
|
||||
- **XOR 恰取一条**:条件命中优先,无条件的边作默认分支兜底;
|
||||
- **多出边一律异或,不分节点种类**:条件命中优先(按声明序),无条件的边作默认兜底;
|
||||
一条都没中而该节点确有出边 = **死路**,发 `route-dead-end` 事件
|
||||
(哪个节点、各出边的条件、判据里当时有哪些键 —— 只记键名不记值)
|
||||
- **AND 汇合要回齐**:未回齐就不进汇合节点;容忍分支以「已消化」计,不阻汇合
|
||||
(哪个节点、各出边的条件、判据里当时有哪些键 —— 只记键名不记值)。
|
||||
审批点的裁决(`verdict`)由引擎注入判据,两个去向就是它自己的两条条件出边
|
||||
- **异或汇合不等**(`joinReady`):互斥分支结构上只会来一条,等齐 = 等一个不可能
|
||||
到来的分支,流会无声卡死;AND 回齐逻辑仅剩历史版本的并行网关在用,容忍分支以「已消化」计
|
||||
- **流程层不自动重试活动**(过程内重试归 Harness);
|
||||
流级重做一律人裁决后**重开新任务实例**
|
||||
- **驳回走向声明式**:审批点的驳回按 `reject` 声明回指定节点重开新实例,不由临场决定
|
||||
|
||||
@@ -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('容忍分支的输出不得被下游必填依赖', () => {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* 「**连线即映射,画图即校验,不闭合不得保存。**
|
||||
* 连一条边即须完成该边映射(必填未映射 = 边未完成),类型兼容依两端契约 schema 当场校验;
|
||||
* 存在未完成边、未满足必填或类型冲突的流程包**存不进来**。
|
||||
* AND 汇合后的同名字段必须**显式指定来源分支**,不做隐式合并。」
|
||||
* 多出边即异或分支(恰取一条,默认兜底至多一条);多入边即异或汇合。」
|
||||
*
|
||||
* 网关已废除(2026-07-28):schema 仍收 gateway 只为读历史 —— 见 checkGateway。
|
||||
*
|
||||
* 原文是「可存草稿、不得发布」——**已不成立**:没有草稿态也没有发布门,
|
||||
* 闸落在保存上(严格检查 + 保存即生效)。画布上的半成品留在浏览器里,存不进库。
|
||||
@@ -69,6 +71,24 @@ export function checkClosure(input: ClosureInput): ClosureReport {
|
||||
if (node.kind === 'gateway') {
|
||||
violations.push(...checkGateway(node, incoming.get(node.id) ?? [], outgoing.get(node.id) ?? []));
|
||||
}
|
||||
|
||||
/*
|
||||
* 多出边 = 异或分支(恰取一条):条件命中优先,无条件边作默认兜底。
|
||||
* 默认兜底**至多一条** —— 两条无条件出边就是「都想当默认」,运行时按声明序
|
||||
* 静默取先者,另一条永远走不到:图上画着、结构上死路,必须在定义期拦下。
|
||||
* 条件不必全覆盖(全不中走默认;连默认都没有则运行时报 route-dead-end)。
|
||||
*/
|
||||
const outs = outgoing.get(node.id) ?? [];
|
||||
if (outs.length > 1) {
|
||||
const defaults = outs.filter((e) => e.condition === undefined || e.condition.trim() === '');
|
||||
if (defaults.length > 1) {
|
||||
violations.push({
|
||||
kind: 'multi-default-edge',
|
||||
nodeId: node.id,
|
||||
message: `节点「${node.label}」有 ${defaults.length} 条无条件出边 —— 默认兜底至多一条,其余必须带条件`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
violations.push(...checkTolerateDependencies(pkg, contracts, byId));
|
||||
@@ -139,22 +159,12 @@ function checkActivityNode(
|
||||
}
|
||||
}
|
||||
|
||||
// AND 汇合后的同名字段必须显式指定来源分支,不做隐式合并。
|
||||
// 回边(reopen)是**重新入场**不是并发汇合 —— 不参与此判定(OS 03 失败走向 / 议价回环)。
|
||||
const concurrent = inbound.filter((e) => !e.reopen);
|
||||
const targets = concurrent.flatMap((e) => e.mapping.map((m) => m.target));
|
||||
const seen = new Set<string>();
|
||||
for (const target of targets) {
|
||||
if (seen.has(target) && concurrent.length > 1) {
|
||||
out.push({
|
||||
kind: 'join-ambiguous-source',
|
||||
nodeId: node.id,
|
||||
field: target,
|
||||
message: `汇合处字段 ${target} 有多个来源分支,须显式指定 —— 不做隐式合并`,
|
||||
});
|
||||
}
|
||||
seen.add(target);
|
||||
}
|
||||
/*
|
||||
* 多入边**不再判「隐式合并」**(产品决策 2026-07-28:网关去节点化)。
|
||||
* 多入边即异或汇合:上游是互斥的条件分支,结构上只会来一条,同名映射天然无歧义 ——
|
||||
* 从前为绕开这条禁令而生的显式汇合点(G2J 之类)正是被删掉的东西。
|
||||
* AND 并行没有声明机制,不存在「并发汇合」;将来加「并行组」时再回来立汇合规则。
|
||||
*/
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -164,22 +174,21 @@ function checkGateway(
|
||||
inbound: readonly Edge[],
|
||||
outbound: readonly Edge[],
|
||||
): ClosureViolation[] {
|
||||
const out: ClosureViolation[] = [];
|
||||
if (node.mode === 'fork' && outbound.length < 2) {
|
||||
out.push({ kind: 'gateway-unbalanced', nodeId: node.id, message: `分叉网关「${node.label}」出边少于两条` });
|
||||
}
|
||||
if (node.mode === 'join' && inbound.length < 2) {
|
||||
out.push({ kind: 'gateway-unbalanced', nodeId: node.id, message: `汇合网关「${node.label}」入边少于两条` });
|
||||
}
|
||||
// XOR 的每条出边必须有条件(声明式、静态可检,不得内嵌自由逻辑)
|
||||
if (node.gateway === 'exclusive' && node.mode === 'fork') {
|
||||
for (const edge of outbound) {
|
||||
if (edge.condition === undefined || edge.condition.trim() === '') {
|
||||
out.push({ kind: 'edge-unmapped', edgeId: edge.id, nodeId: node.id, message: `异或网关的出边 ${edge.id} 缺条件` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
void inbound;
|
||||
void outbound;
|
||||
/*
|
||||
* **网关已废除**(产品决策 2026-07-28):分支 = 源节点的条件出边,汇合 = 多入边异或。
|
||||
* schema 仍收 gateway 只为**读历史版本** —— 在途工作流照走(运行时兼容),
|
||||
* 但含网关的定义不再可激活:这里判违规,激活闸(不闭合不得激活)就会拦下。
|
||||
* 想复用旧图,在生效版上把网关的出边改画成源活动的条件出边即可。
|
||||
*/
|
||||
return [
|
||||
{
|
||||
kind: 'gateway-deprecated',
|
||||
nodeId: node.id,
|
||||
message: `网关「${node.label}」已废除 —— 把分支写成上游节点的条件出边(历史版本仅可读,不可激活)`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,16 +75,27 @@ export function selectEdges(
|
||||
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];
|
||||
/*
|
||||
* **多出边一律异或,不分节点种类**(产品决策 2026-07-28:网关去节点化)。
|
||||
*
|
||||
* 分支不再是网关节点的专利 —— 活动/审批点自己的多条条件出边就是分支,
|
||||
* 判据读的是它自己的出参(与 Mastra 的 `.branch` 同构:条件挂在步与步之间)。
|
||||
* 语义与从前的异或网关逐字相同:条件命中优先(按声明序),无条件边作默认兜底,
|
||||
* 恰取一条。并行(AND)没有节点了也没有声明机制 —— 要并行时以「并行组」声明加回
|
||||
* (对应 Mastra 的 `.parallel` 结构块),见债务簿。
|
||||
*
|
||||
* 遗留的网关节点(历史版本里还有)走同一条逻辑 —— 异或网关的语义本来就是这个;
|
||||
* 并行网关分叉的「全发」不再支持,历史包若有,激活时已被闭合校验拦下。
|
||||
*/
|
||||
if (outgoing.length <= 1) {
|
||||
// 并行网关的遗留直通不受损:单出边不判条件,原样走
|
||||
void node;
|
||||
return outgoing;
|
||||
}
|
||||
|
||||
// 并行网关分叉、活动节点、审批点:全部出边
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user