323ccc8475
問題(「全變成 code」的根):if_control 回 {result, branch} 卻沒有邊讀得懂它,
圖的邊只有 ON_SUCCESS/IF/FOREACH ⇒ 就算照規矩用零件,仍得寫 code 判斷走哪條。
leo 08-01 追問「你改了 if,有改 switch 嗎?switch 更嚴重」——確認 switch(N 路)
與 try_catch(try/catch) 同病,故一次做成通用機制,不留「為 switch 再改一次」的債。
設計:三顆流程控制零件的 output_schema 本來就都收斂到同一形狀 data.branch: string
(if_control→true/false;switch→case 名或 default_branch;try_catch→try/catch)
⇒ 引擎只需「依標籤選邊」一個機制 ON_BRANCH;ON_TRUE/ON_FALSE 是布林路的語法糖,
底層同一條路(測試已證等價)。讀不出分支=不走(誠實,不亂挑一條)。
- types/schemas/constants:新增三邊型(純新增,既有列舉不動)+ GraphEdge.branch
- graph-executor:readBranch() 依 data.branch → branch → data.result → result 四層相容
- 中文語意詞:成立時/為真時=ON_TRUE,不成立時/為假時/否則=ON_FALSE,BRANCH=ON_BRANCH
分支用法要「查得到」(leo 08-01:AI 可能像 n8n 那樣逐顆查、自己組圖):
新增 lib/branch-hints.ts,讓 if_control/switch/try_catch 的查詢回應自帶 branch_hint
(branch_field/branches/edge_types/usage/example)——只看這一顆的回應就知道怎麼接下一步,
不必回頭讀 skill。四條回應路徑全wire:catalog found/legacy 逐顆/步驟4 substitution/
target=component 名字搜尋(=n8n 式那條)。不分岔的零件不加此欄,避免噪音。
測試(先寫測試再改引擎,紅線要求):tests/conditional-edges.test.ts 16 項全綠
——if 兩路/switch 多路+default/try_catch 成功與失敗路/語法糖等價/
context 傳遞/同分支 fan-out/無匹配不走/混合邊時 PIPE 不受影響/schema 放行。
零變化保證:195 passed(前 179 +16 新),失敗數維持既有 9 筆未變(console/portal
HTML 資產未建置+executor 斷言字串漂移,皆與本次無關);tsc --noEmit 綠。
現存 workflow/example 用到新邊型=0 筆(grep 實查)⇒ 既有行為不可能被改動。
SDD: workflow-discovery task 3.11|CP: arcrun-usable 步驟 5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
/**
|
||
* 條件邊 ON_TRUE / ON_FALSE / ON_BRANCH —— CP `arcrun-usable` 步驟 5 缺口①
|
||
* SDD: workflow-discovery tasks 3.11
|
||
*
|
||
* 為什麼要有這組測試(別刪):
|
||
* `if_control` 零件回 `{success, data:{result, branch}}`,但引擎過去只有
|
||
* ON_SUCCESS / IF / FOREACH ⇒ 就算照規矩用 if_control,也只拿到布林值,
|
||
* 還是得寫 code 判斷該走哪條路 ⇒ 這正是「全變成 code」的根(Arcrun#5)。
|
||
*
|
||
* 本檔先寫測試再改引擎(引擎核心風險最高,紅線要求)。
|
||
* 既有邊行為的零變化迴歸另見 executor.test.ts(PIPE/IF/ON_SUCCESS 原樣通過)。
|
||
*/
|
||
import { SELF } from 'cloudflare:test';
|
||
import { describe, it, expect } from 'vitest';
|
||
|
||
/** 送一張圖進 /execute,回 parsed JSON */
|
||
async function run(graph: unknown, context: Record<string, unknown> = {}) {
|
||
const res = await SELF.fetch('http://localhost/execute', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ graph, context }),
|
||
});
|
||
return {
|
||
status: res.status,
|
||
body: (await res.json()) as {
|
||
success: boolean;
|
||
data: Record<string, unknown>;
|
||
trace?: Array<{ nodeId: string }>;
|
||
error?: string;
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 用 Input 節點直接餵出 if_control 形狀的 output({data:{result,branch}}),
|
||
* 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。
|
||
*/
|
||
function branchGraph(branch: 'true' | 'false', edges: Array<Record<string, unknown>>) {
|
||
return {
|
||
id: `g-branch-${branch}`,
|
||
name: '條件邊測試',
|
||
nodes: [
|
||
// 模擬 if_control 的輸出形狀
|
||
{ id: 'cond', type: 'Input', data: { success: true, data: { result: branch === 'true', branch } } },
|
||
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||
{ id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } },
|
||
],
|
||
edges,
|
||
};
|
||
}
|
||
|
||
describe('條件邊:ON_TRUE / ON_FALSE(缺口① Arcrun#5 根治)', () => {
|
||
it('branch=true → 只走 ON_TRUE 那條,ON_FALSE 那條不執行', async () => {
|
||
const { body } = await run(
|
||
branchGraph('true', [
|
||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||
]),
|
||
);
|
||
expect(body.success).toBe(true);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('yes');
|
||
expect(visited).not.toContain('no');
|
||
});
|
||
|
||
it('branch=false → 只走 ON_FALSE 那條,ON_TRUE 那條不執行', async () => {
|
||
const { body } = await run(
|
||
branchGraph('false', [
|
||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||
]),
|
||
);
|
||
expect(body.success).toBe(true);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('no');
|
||
expect(visited).not.toContain('yes');
|
||
});
|
||
|
||
it('result 是布林但沒有 branch 欄位 → 仍judged得出(相容 {result:true} 形狀)', async () => {
|
||
const graph = {
|
||
id: 'g-bool-only',
|
||
name: '只有 result',
|
||
nodes: [
|
||
{ id: 'cond', type: 'Input', data: { result: true } },
|
||
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||
{ id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } },
|
||
],
|
||
edges: [
|
||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||
],
|
||
};
|
||
const { body } = await run(graph);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('yes');
|
||
expect(visited).not.toContain('no');
|
||
});
|
||
|
||
it('條件邊的下游拿得到上游 context(propagateCtx 一致)', async () => {
|
||
const graph = {
|
||
id: 'g-ctx',
|
||
name: 'context 傳遞',
|
||
nodes: [
|
||
{ id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' }, carried: 'keep-me' } },
|
||
{ id: 'yes', type: 'Component', componentId: 'comp_passthrough' },
|
||
],
|
||
edges: [{ from: 'cond', to: 'yes', type: 'ON_TRUE' }],
|
||
};
|
||
const { body } = await run(graph);
|
||
expect(body.success).toBe(true);
|
||
expect(body.data.carried).toBe('keep-me');
|
||
});
|
||
|
||
it('兩條 ON_TRUE 並存 → 都走(同分支多下游是合法 fan-out)', async () => {
|
||
const graph = {
|
||
id: 'g-fanout',
|
||
name: '同分支多下游',
|
||
nodes: [
|
||
{ id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' } } },
|
||
{ id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } },
|
||
{ id: 'b', type: 'Component', componentId: 'comp_uppercase', data: { text: 'b' } },
|
||
],
|
||
edges: [
|
||
{ from: 'cond', to: 'a', type: 'ON_TRUE' },
|
||
{ from: 'cond', to: 'b', type: 'ON_TRUE' },
|
||
],
|
||
};
|
||
const { body } = await run(graph);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('a');
|
||
expect(visited).toContain('b');
|
||
});
|
||
});
|
||
|
||
describe('條件邊:ON_BRANCH(switch 具名分支)', () => {
|
||
/** switch 零件回 {success, data:{branch:"branch_a"}} */
|
||
function switchGraph(branch: string) {
|
||
return {
|
||
id: 'g-switch',
|
||
name: 'switch 具名分支',
|
||
nodes: [
|
||
{ id: 'sw', type: 'Input', data: { success: true, data: { branch } } },
|
||
{ id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } },
|
||
{ id: 'z', type: 'Component', componentId: 'comp_uppercase', data: { text: 'z' } },
|
||
],
|
||
edges: [
|
||
{ from: 'sw', to: 'a', type: 'ON_BRANCH', branch: 'branch_a' },
|
||
{ from: 'sw', to: 'z', type: 'ON_BRANCH', branch: 'fallback' },
|
||
],
|
||
};
|
||
}
|
||
|
||
it('branch=branch_a → 只走標 branch_a 的邊', async () => {
|
||
const { body } = await run(switchGraph('branch_a'));
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('a');
|
||
expect(visited).not.toContain('z');
|
||
});
|
||
|
||
it('branch=fallback → 只走標 fallback 的邊', async () => {
|
||
const { body } = await run(switchGraph('fallback'));
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('z');
|
||
expect(visited).not.toContain('a');
|
||
});
|
||
|
||
it('沒有任何邊匹配 → 誠實地不走(不亂挑一條,也不報錯)', async () => {
|
||
const { body } = await run(switchGraph('no_such_branch'));
|
||
expect(body.success).toBe(true);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).not.toContain('a');
|
||
expect(visited).not.toContain('z');
|
||
});
|
||
});
|
||
|
||
describe('通用具名分支涵蓋三型零件(leo 08-01:switch 比 if 更嚴重)', () => {
|
||
/**
|
||
* 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string`:
|
||
* if_control → "true" | "false"(布林兩路)
|
||
* switch → case 的 branch 名 | default_branch(N 路)
|
||
* try_catch → "try" | "catch"(成功/失敗兩路)
|
||
* ⇒ 引擎只需要「依標籤選邊」這一個機制,不是為每顆零件開特例。
|
||
* ON_TRUE / ON_FALSE 只是 if 布林路的語法糖,底層與 ON_BRANCH 同一條路。
|
||
*/
|
||
async function branchTo(branch: string, edges: Array<Record<string, unknown>>) {
|
||
return run({
|
||
id: `g-generic-${branch}`,
|
||
name: '通用具名分支',
|
||
nodes: [
|
||
{ id: 'ctrl', type: 'Input', data: { success: true, data: { branch } } },
|
||
{ id: 'p1', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p1' } },
|
||
{ id: 'p2', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p2' } },
|
||
{ id: 'p3', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p3' } },
|
||
],
|
||
edges,
|
||
});
|
||
}
|
||
|
||
const threeWay = [
|
||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'branch_active' },
|
||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'branch_inactive' },
|
||
{ from: 'ctrl', to: 'p3', type: 'ON_BRANCH', branch: 'branch_default' },
|
||
];
|
||
|
||
it('switch 多路:branch_active → 只走第一條,其餘兩條不走', async () => {
|
||
const { body } = await branchTo('branch_active', threeWay);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('p1');
|
||
expect(visited).not.toContain('p2');
|
||
expect(visited).not.toContain('p3');
|
||
});
|
||
|
||
it('switch 多路:branch_inactive → 只走第二條', async () => {
|
||
const { body } = await branchTo('branch_inactive', threeWay);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('p2');
|
||
expect(visited).not.toContain('p1');
|
||
expect(visited).not.toContain('p3');
|
||
});
|
||
|
||
it('switch default:無匹配 case 時零件回 default_branch → 走 default 那條', async () => {
|
||
// 注意:挑 default 是 switch 零件內部的事(它回 default_branch 名);
|
||
// 引擎這層看到的一律是「一個標籤」,故 default 不需要引擎特別處理。
|
||
const { body } = await branchTo('branch_default', threeWay);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('p3');
|
||
expect(visited).not.toContain('p1');
|
||
expect(visited).not.toContain('p2');
|
||
});
|
||
|
||
it('try_catch 成功路:branch=try → 走 try 邊,不走 catch 邊', async () => {
|
||
const { body } = await branchTo('try', [
|
||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' },
|
||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' },
|
||
]);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('p1');
|
||
expect(visited).not.toContain('p2');
|
||
});
|
||
|
||
it('try_catch 失敗路:branch=catch → 走 catch 邊,不走 try 邊', async () => {
|
||
const { body } = await branchTo('catch', [
|
||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' },
|
||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' },
|
||
]);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).toContain('p2');
|
||
expect(visited).not.toContain('p1');
|
||
});
|
||
|
||
it('ON_TRUE 與 ON_BRANCH branch="true" 等價(語法糖,底層同一條路)', async () => {
|
||
const sugar = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_TRUE' }]);
|
||
const raw = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'true' }]);
|
||
const v1 = (sugar.body.trace ?? []).map(t => t.nodeId);
|
||
const v2 = (raw.body.trace ?? []).map(t => t.nodeId);
|
||
expect(v1).toEqual(v2);
|
||
expect(v1).toContain('p1');
|
||
});
|
||
});
|
||
|
||
describe('零變化保證:新邊型不影響既有邊', () => {
|
||
it('ON_TRUE 邊存在時,同圖的 PIPE 邊照常走', async () => {
|
||
const graph = {
|
||
id: 'g-mixed',
|
||
name: '混合邊',
|
||
nodes: [
|
||
{ id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' }, count: 0 } },
|
||
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||
{ id: 'always', type: 'Component', componentId: 'comp_counter' },
|
||
],
|
||
edges: [
|
||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||
{ from: 'cond', to: 'always', type: 'PIPE' },
|
||
],
|
||
};
|
||
const { body } = await run(graph);
|
||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||
expect(visited).not.toContain('yes'); // 條件邊擋掉
|
||
expect(visited).toContain('always'); // PIPE 不受影響
|
||
});
|
||
|
||
it('/validate 接受 ON_TRUE / ON_FALSE / ON_BRANCH(schema 已放行)', async () => {
|
||
const res = await SELF.fetch('http://localhost/validate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: 'g-validate',
|
||
name: 'schema 驗證',
|
||
nodes: [
|
||
{ id: 'a', type: 'Input' },
|
||
{ id: 'b', type: 'Output' },
|
||
{ id: 'c', type: 'Output' },
|
||
],
|
||
edges: [
|
||
{ from: 'a', to: 'b', type: 'ON_TRUE' },
|
||
{ from: 'a', to: 'c', type: 'ON_FALSE' },
|
||
],
|
||
}),
|
||
});
|
||
const data = (await res.json()) as { valid: boolean };
|
||
expect(res.status).toBe(200);
|
||
expect(data.valid).toBe(true);
|
||
});
|
||
});
|