From 323ccc847540507a9e8be01176609f2b81e1ffa6 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 16:26:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AD=A5=E9=A9=9F5=20=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E2=91=A0=EF=BC=9A=E5=BC=95=E6=93=8E=E9=80=9A=E7=94=A8=E5=85=B7?= =?UTF-8?q?=E5=90=8D=E5=88=86=E6=94=AF=E9=82=8A=EF=BC=88ON=5FTRUE/ON=5FFAL?= =?UTF-8?q?SE/ON=5FBRANCH=EF=BC=89=EF=BC=9DArcrun#5=20=E6=A0=B9=E6=B2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題(「全變成 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 --- cypher-executor/src/actions/search-nodes.ts | 16 +- cypher-executor/src/actions/target-search.ts | 12 +- cypher-executor/src/graph-executor.ts | 55 ++++ cypher-executor/src/lib/branch-hints.ts | 83 +++++ cypher-executor/src/lib/constants.ts | 12 + cypher-executor/src/lib/schemas.ts | 3 +- cypher-executor/src/types.ts | 3 + .../tests/conditional-edges.test.ts | 304 ++++++++++++++++++ .../docs/3-specs/workflow-discovery/tasks.md | 48 +++ 9 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 cypher-executor/src/lib/branch-hints.ts create mode 100644 cypher-executor/tests/conditional-edges.test.ts diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index 1dc8133..68f48da 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -3,6 +3,8 @@ import { resolveNodeRole, isVirtualIoName } from './triplet-parser'; import { wasmWorkerUrl } from '../lib/component-loader'; import { resolveRecipe } from '../routes/recipes'; import type { RecipeDefinition } from '../routes/recipes'; +import { branchHintFor } from '../lib/branch-hints'; +import type { BranchHint } from '../lib/branch-hints'; /** * `not_found` 而非 `missing`:欄位契約以頂層機械考 @@ -63,6 +65,13 @@ export type NodeInfo = { similar_recipes?: string[]; /** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */ substitution?: NodeSubstitution; + /** + * 分支用法自我說明(3.11):只有「本身會分岔」的零件才有 + * (if_control/switch/try_catch)。 + * 存在的理由=走 n8n 式「逐顆查、自己組圖」的 AI,光看 input_schema 不知道 + * 「判斷完之後兩條路怎麼接」⇒ 會回頭寫 code。判準:只看這一顆的回應就知道怎麼接下一步。 + */ + branch_hint?: BranchHint; }; export type SearchResult = { @@ -216,6 +225,7 @@ export async function searchNodes( input_schema: hit.input_schema, success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined, stability: typeof hit.stability === 'string' ? hit.stability : undefined, + branch_hint: branchHintFor(componentId), }; continue; } @@ -355,6 +365,7 @@ async function legacyPerNodeLookup( info: { status: 'found', componentId, type: role, source: 'component', input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability, + branch_hint: branchHintFor(componentId), }, missing: false, }; @@ -407,7 +418,7 @@ async function legacyPerNodeLookup( type SubstitutionHit = Pick< NodeInfo, 'status' | 'componentId' | 'source' | 'substitution' | - 'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' + 'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' | 'branch_hint' >; function trySubstitution( @@ -472,6 +483,9 @@ function trySubstitution( input_schema: top.entry.input_schema, success_rate: typeof top.entry.success_rate === 'number' ? top.entry.success_rate : undefined, stability: typeof top.entry.stability === 'string' ? top.entry.stability : undefined, + // 替換成分岔零件時(例「判斷有沒有新資料」→ if_control)一併附分支用法, + // 否則 AI 換到零件卻不知道怎麼接兩條路,仍會退回寫 code。 + branch_hint: branchHintFor(top.entry.canonical_id), substitution: { from: nodeName, componentId: top.entry.canonical_id, diff --git a/cypher-executor/src/actions/target-search.ts b/cypher-executor/src/actions/target-search.ts index 431c353..2010e2b 100644 --- a/cypher-executor/src/actions/target-search.ts +++ b/cypher-executor/src/actions/target-search.ts @@ -17,6 +17,7 @@ import { wasmWorkerUrl } from '../lib/component-loader'; import { fetchTenantWorkflowSearch } from '../lib/workflow-search'; import { listAllRecipes, type SearchNodesEnv } from './search-nodes'; +import { branchHintFor } from '../lib/branch-hints'; export type TargetQueryEnv = SearchNodesEnv & { KBDB_BASE_URL?: string; @@ -44,12 +45,21 @@ export async function searchByTarget( ); if (!res.ok) return { ok: false, status: 502, error: `registry 搜尋失敗(HTTP ${res.status})` }; const body = (await res.json()) as { data?: { results?: unknown[]; count?: number } }; + // 3.11:逐顆查零件(n8n 式「自己一顆一顆填」)時,會分岔的零件要自我說明分支用法。 + // leo 08-01:「它可以一一查詢自己手工填寫每個零件,就像在 n8n 那樣」—— + // 這條路徑若只回 input_schema,AI 拿到 if_control/switch 仍不知道兩條路怎麼接 ⇒ 回頭寫 code。 + const results = (body.data?.results ?? []).map(r => { + if (!r || typeof r !== 'object') return r; + const rec = r as Record; + const hint = branchHintFor(typeof rec.canonical_id === 'string' ? rec.canonical_id : undefined); + return hint ? { ...rec, branch_hint: hint } : rec; + }); return { ok: true, body: { target, query, - results: body.data?.results ?? [], + results, count: body.data?.count ?? 0, }, }; diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index 672e96b..36e6808 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -478,6 +478,37 @@ export class GraphExecutor { break; } + // ── 條件邊(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5 缺口①)── + // 為什麼要有:`if_control` 回 {result, branch} 卻沒有邊讀得懂它, + // AI 照規矩用了零件仍得寫 code 判斷走哪條 ⇒「全變成 code」的根(Arcrun#5)。 + // 讀法對齊零件 output_schema:優先 data.branch(if_control/switch 的正式形狀), + // 相容 top-level branch / result 布林。讀不出分支=不走(誠實,不亂挑一條)。 + case 'ON_TRUE': { + if (readBranch(result) === 'true') { + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); + } + break; + } + + case 'ON_FALSE': { + if (readBranch(result) === 'false') { + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); + } + break; + } + + case 'ON_BRANCH': { + // switch 具名分支:邊上的 branch 要跟上游 output 的 branch 字面相等才走 + const actual = readBranch(result); + if (edge.branch !== undefined && actual !== undefined && actual === edge.branch) { + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); + } + break; + } + case 'FOREACH': { const iteratorKey = edge.iterator ?? 'item'; // 找 iterable 順序:先看上游 output (result),沒有再看完整 context (含上游 chain 累積的 fields) @@ -631,6 +662,30 @@ function getNestedValue(ctx: unknown, path: string): unknown { return cur; } +/** + * 從節點 output 讀出「走哪條分支」(SDD workflow-discovery 3.11) + * + * 讀取順序(對齊零件 contract 的 output_schema,由正式到相容): + * 1. `data.branch` —— if_control / switch 的正式輸出形狀 {success, data:{result, branch}} + * 2. `branch` —— 已被 propagateCtx spread 到 top-level 的情況 + * 3. `data.result` —— 只有布林沒有 branch 的零件 + * 4. `result` —— top-level 布林 + * 讀不出來回 undefined ⇒ 呼叫端一律不走該邊(誠實:寧可不走,不亂挑一條)。 + */ +function readBranch(result: unknown): string | undefined { + if (!result || typeof result !== 'object') return undefined; + const r = result as Record; + const data = (r.data && typeof r.data === 'object') ? r.data as Record : undefined; + + const named = data?.branch ?? r.branch; + if (typeof named === 'string') return named; + + const bool = data?.result ?? r.result; + if (typeof bool === 'boolean') return bool ? 'true' : 'false'; + + return undefined; +} + /** 判斷節點執行結果是否為失敗:success === false 或含有 error key */ function isFailure(result: unknown): boolean { if (!result || typeof result !== 'object') return false; diff --git a/cypher-executor/src/lib/branch-hints.ts b/cypher-executor/src/lib/branch-hints.ts new file mode 100644 index 0000000..6832557 --- /dev/null +++ b/cypher-executor/src/lib/branch-hints.ts @@ -0,0 +1,83 @@ +/** + * 分支用法自我說明(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5) + * + * 為什麼需要這一層(leo 08-01 逼出的洞,別刪): + * leo:「它也可以不要送整個意圖工作流去查詢,它可以**一一查詢自己手工填寫每個零件, + * 就像在 n8n 那樣**,這時它不會每個都寫 code?」 + * 取證:逐顆查 `if_control`,回應只有 {status, componentId, input_schema, success_rate…}, + * `input_schema` 只說得出 {condition, input}——**沒有任何欄位告訴 AI「判斷完之後兩條路怎麼分岔」** + * ⇒ 走 n8n 式逐顆查、自己組圖的 AI 拿到 if_control 後必然卡在「然後呢」,回頭寫 code。 + * + * 判準(leo 一貫要求:資訊出現在需要它的那一刻): + * **AI 只看這一顆的查詢回應,就知道怎麼接下一步**,不必回頭讀 skill。 + * + * 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string` + * ⇒ 引擎只有「依標籤選邊」一個機制(ON_BRANCH),ON_TRUE/ON_FALSE 是布林路的語法糖。 + */ + +export type BranchHint = { + /** 這顆零件會輸出哪個欄位當分支標籤 */ + branch_field: string; + /** 可能的分支標籤(switch 是動態的,故標明由 cases 決定) */ + branches: string[] | string; + /** 接下游要用哪些邊型 */ + edge_types: string[]; + /** 一行說明:這顆零件之後怎麼分岔 */ + usage: string; + /** 可直接照抄的最小範例(意圖語法+對應的邊) */ + example: string; +}; + +/** + * 零件 → 分支用法。key = canonical_id。 + * 只收「本身會分岔」的零件;不分岔的零件不該有 branch_hint(避免噪音)。 + */ +const BRANCH_HINTS: Record = { + if_control: { + branch_field: 'data.branch', + branches: ['true', 'false'], + edge_types: ['ON_TRUE', 'ON_FALSE'], + usage: + '這顆算完會輸出 data.branch("true"/"false")。下游接兩條邊:ON_TRUE 接條件成立要做的事,' + + 'ON_FALSE 接不成立要做的事。**不需要自己寫 code 判斷走哪條**——引擎依 branch 自動選路。', + example: + '判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram\n' + + '判斷有沒有新資料 >> ON_FALSE >> 結束\n' + + '(中文語意詞亦可:「成立時」=ON_TRUE、「否則」=ON_FALSE)', + }, + switch: { + branch_field: 'data.branch', + branches: '由 input_schema.cases[].branch 與 default_branch 決定(N 路,非固定清單)', + edge_types: ['ON_BRANCH'], + usage: + '這顆依 value 比對 cases,輸出 data.branch=命中那個 case 的 branch 名(都沒中則是 default_branch)。' + + '下游**每條路各接一條 ON_BRANCH 邊,並在邊上標 branch 等於你在 cases 裡取的名字**。' + + 'default_branch 不需要特別的邊型,照樣用 ON_BRANCH 標它的名字即可。', + example: + '{"cases":[{"match":"active","branch":"branch_active"}],"default_branch":"branch_default"}\n' + + 'edges: [\n' + + ' {"from":"my_switch","to":"處理啟用","type":"ON_BRANCH","branch":"branch_active"},\n' + + ' {"from":"my_switch","to":"處理其他","type":"ON_BRANCH","branch":"branch_default"}\n' + + ']', + }, + try_catch: { + branch_field: 'data.branch', + branches: ['try', 'catch'], + edge_types: ['ON_BRANCH'], + usage: + '這顆看上游 error 是否非空,輸出 data.branch("try"=沒錯/"catch"=有錯)。' + + '下游接兩條 ON_BRANCH 邊,branch 分別標 "try" 與 "catch"。' + + '**錯誤處理不需要寫 code**——把要補救的節點接在 catch 那條邊後面即可。', + example: + 'edges: [\n' + + ' {"from":"my_try_catch","to":"正常流程","type":"ON_BRANCH","branch":"try"},\n' + + ' {"from":"my_try_catch","to":"補救流程","type":"ON_BRANCH","branch":"catch"}\n' + + ']', + }, +}; + +/** 取某零件的分支用法說明;不分岔的零件回 undefined(回應不加噪音)。 */ +export function branchHintFor(componentId: string | undefined): BranchHint | undefined { + if (!componentId) return undefined; + return BRANCH_HINTS[componentId.toLowerCase()]; +} diff --git a/cypher-executor/src/lib/constants.ts b/cypher-executor/src/lib/constants.ts index 5d4f960..903d896 100644 --- a/cypher-executor/src/lib/constants.ts +++ b/cypher-executor/src/lib/constants.ts @@ -5,6 +5,8 @@ export const VALID_EDGE_TYPES = new Set([ 'PIPE', 'IF', 'FOREACH', 'CONTINUE', // 新增:執行語意 'IS_A', 'ON_SUCCESS', 'ON_FAIL', + // 新增:條件語意(SDD workflow-discovery 3.11)—— 讀上游 if_control/switch 的 branch + 'ON_TRUE', 'ON_FALSE', 'ON_BRANCH', // 新增:觸發語意 'ON_CLICK', 'CALLS_SUBFLOW', // 新增:結構語意(記錄圖結構,不執行) @@ -28,9 +30,19 @@ export const SEMANTIC_EDGE_MAP: Record = { '失敗時': 'ON_FAIL', '對每個': 'FOREACH', '條件滿足時': 'IF', + // 條件分支語意(SDD workflow-discovery 3.11):讓意圖工作流寫得出兩條路 + '成立時': 'ON_TRUE', + '為真時': 'ON_TRUE', + '不成立時': 'ON_FALSE', + '為假時': 'ON_FALSE', + '否則': 'ON_FALSE', // 英文別名 'SUCCESS': 'ON_SUCCESS', 'FAIL': 'ON_FAIL', + 'TRUE': 'ON_TRUE', + 'FALSE': 'ON_FALSE', + 'ELSE': 'ON_FALSE', + 'BRANCH': 'ON_BRANCH', 'CLICK': 'ON_CLICK', 'SUBFLOW': 'CALLS_SUBFLOW', }; diff --git a/cypher-executor/src/lib/schemas.ts b/cypher-executor/src/lib/schemas.ts index 9ebbd0a..6c134eb 100644 --- a/cypher-executor/src/lib/schemas.ts +++ b/cypher-executor/src/lib/schemas.ts @@ -14,9 +14,10 @@ export const graphSchema = z.object({ edges: z.array(z.object({ from: z.string(), to: z.string(), - type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']), + type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_TRUE', 'ON_FALSE', 'ON_BRANCH', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']), condition: z.string().optional(), iterator: z.string().optional(), + branch: z.string().optional(), // ON_BRANCH 的具名分支(SDD workflow-discovery 3.11) })), }); diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 94efb72..2d1e767 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -148,6 +148,7 @@ export type GraphNode = { export type EdgeType = | 'PIPE' | 'IF' | 'FOREACH' | 'CONTINUE' // 現有 | 'IS_A' | 'ON_SUCCESS' | 'ON_FAIL' // 執行語意 + | 'ON_TRUE' | 'ON_FALSE' | 'ON_BRANCH' // 條件語意(SDD workflow-discovery 3.11) | 'ON_CLICK' | 'CALLS_SUBFLOW' // 觸發語意 | 'CONTAINS' | 'HAS_STYLE' | 'HAS_BEHAVIOR'; // 結構語意(記錄圖結構,不執行) @@ -157,6 +158,8 @@ export type GraphEdge = { type: EdgeType; condition?: string; // IF 的條件表達式 iterator?: string; // FOREACH 的迭代變數名 + /** ON_BRANCH 的具名分支(對應 switch 零件 output 的 data.branch) */ + branch?: string; }; export type ExecutionGraph = { diff --git a/cypher-executor/tests/conditional-edges.test.ts b/cypher-executor/tests/conditional-edges.test.ts new file mode 100644 index 0000000..363be6c --- /dev/null +++ b/cypher-executor/tests/conditional-edges.test.ts @@ -0,0 +1,304 @@ +/** + * 條件邊 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 = {}) { + 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; + trace?: Array<{ nodeId: string }>; + error?: string; + }, + }; +} + +/** + * 用 Input 節點直接餵出 if_control 形狀的 output({data:{result,branch}}), + * 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。 + */ +function branchGraph(branch: 'true' | 'false', edges: Array>) { + 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>) { + 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); + }); +}); diff --git a/system-dev/docs/3-specs/workflow-discovery/tasks.md b/system-dev/docs/3-specs/workflow-discovery/tasks.md index 3dc6928..1648d6d 100644 --- a/system-dev/docs/3-specs/workflow-discovery/tasks.md +++ b/system-dev/docs/3-specs/workflow-discovery/tasks.md @@ -114,6 +114,54 @@ --- +## 3.y 追加(2026-08-01,leo confirm 兩缺口進 SDD;服務 CP `arcrun-usable` 步驟 5) + +> **D35 處置(鐵律④ 判斷結果:不開新 SDD、不換 active、不搬移 paused 任務)** +> +> leo 2026-08-01 confirm 頂層 `pending-changes.md` 07-30 兩段(缺口①引擎條件邊/ +> 缺口② recipe payload 與回應處理層)。處置理由逐條: +> +> 1. **不開新 SDD**——鐵律②「CC 在任何情況下不得主動建新 SDD」。confirm 的是 +> 「規格層 proposal」,鐵律④只規定「**開新 SDD 時**」的搬移程序,並未要求每個 +> confirm 都必開新 SDD。本案能落進現行 active 就不該增生第三本。 +> 2. **不把 active 交給 arcrun-core-mvp/recipe-system**——兩本 paused 的未完成任務 +> (core-mvp 14 筆=credential 注入/auth-worker/multi-tenant KV/analytics; +> recipe-system 10 筆=prompt_recipe 的 MCP tool 與 mira wiki 端到端) +> **與本次兩缺口零交集**。升任一本為 active 就得先收掉 workflow-discovery(16 筆 +> 未完成、正在服務 CP 步驟 3/4),等於為了掛兩筆新任務把進行中的鏈打斷。 +> 3. **落在 workflow-discovery=任務層變更(鐵律②第二類)**——CP `arcrun-usable` +> 步驟 3→4→5 是**同一條有序鏈**,步驟 3(誠實查詢)、步驟 4(節點替換)的任務 +> 本來就掛在本 SDD 的 3.x;步驟 5 是同鏈的下一步,且**改的是同一顆 worker** +> (cypher-executor)。掛同一本=與既有 3.6–3.10 同源,不是新方向。 +> 4. **兩本 paused 維持 paused、未完成任務原地不動**——沒有被取代、沒有被繼承, +> 故不填 `superseded_by`、不移入 archive/。pending-changes 原提案標的 +> (缺口①→arcrun-core-mvp、缺口②→recipe-system)僅為「議題歸屬」描述, +> 非活性歸屬;實作歸屬依鐵律①走現行 active。 +> +> **作廢任務清單:無**(本次不作廢任何既有任務)。 +> **搬移任務清單:無**(不換 active,故無跨 SDD 搬移;新增下列 3.11–3.13)。 + +- [ ] 3.11 **缺口①:引擎條件邊**(=Arcrun#5 根治;CP 步驟 5 交付物之一) + 現況:`if_control` 只回 `{result, branch}`,`cypher-executor/src` grep + `ON_TRUE|ON_FALSE`=0,圖的邊只有 `ON_SUCCESS`/`IF`/FOREACH ⇒ AI 照規矩用 + `if_control` 也還是得寫 code 判斷該走哪條路 ⇒「全變成 code」的根。 + 要做:`EdgeType` 加 `ON_TRUE`/`ON_FALSE`+`graph-executor` 走邊邏輯依上游 + `branch` 選路。**引擎核心、風險最高:先寫測試再改**;既有 4 支官方 workflow + 行為必須零變化(跑一次證明)。 +- [ ] 3.12 **缺口②:recipe payload 與回應處理層**(CP 步驟 5 交付物之二、之三) + 現況 schema 只有 `{canonical_id, endpoint, method, auth_service, headers, body}` + ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code;回應解析 + (`finalize` 2786 字元)綁死 Gemini 格式。 + 要補三層:`body_template`(payload 模板+變數插值)/`response_map`(回應正規化: + 取值路徑・思考型模型旗標・淨化規則)/`auth` 第四型 `binding`(免金鑰, + 一次打開 `env.AI`/`VECTORIZE`/`BROWSER`/`QUEUE`)。 + **相容硬要求**:既有 recipe(無新欄位)行為完全不變。 +- [ ] 3.13 **驗收=CP 步驟 5 考試(features/07)**:拿現行 `assemble` 節點 + (5509 字元、if×23)用新能力重寫 → code 大幅下降且仍 `verdict=success`; + 貼改寫前後字元數與實跑輸出。另附 haiku 場景:缺件時只寫 recipe 就補全、零 JS。 + +--- + ## 跨任務鐵律提醒 - 強制填 / 搜尋 / 回填全是**能力 → 落 API**;CLI/MCP 只暴露(rule 07)。