合流 batch:步驟 3/4/5/6 併成一版出貨(同一顆 cypher worker,bundle 是 worker 粒度)

鐵律(08-01 血淚):CP 可分步驗收,但 bundle 是 worker 粒度——凡改同一顆 worker 的多步,
出貨前必先合流成一版再重生 bundle,否則 stage 只拿到一步(三條分支各自建 bundle 的坑)。

base=feat/step3-missing-guidance(最前緣,已含 step4+step6+t158-t162 prod 修)
merge=feat/step5-conditional-edges(引擎條件邊+recipe 三層+教材世代修)
This commit is contained in:
uncle6me-web
2026-07-31 16:56:09 +08:00
21 changed files with 1544 additions and 18 deletions
+17 -1
View File
@@ -43,12 +43,28 @@ export function buildExecutionGraph(
iterator = foreachMatch[1];
label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP
}
const edge: { from: string; to: string; type: ReturnType<typeof toEdgeType>; iterator?: string } = {
// 「ON_BRANCH(標籤)」抽 branch:意圖語法表達具名分支(SDD workflow-discovery 3.11
// 例:'my_switch >> ON_BRANCH(branch_active) >> 處理啟用' → type=ON_BRANCH, branch='branch_active'
// 沒有這段的話,帶括號的 label 會落到 toEdgeType 的預設值 PIPE ⇒ 分支靜默失效
// (即「教了語法但引擎不收」——比沒做更糟,故與 skill 文件同批補上)
let branch: string | undefined;
const branchMatch = label.match(/^(?:ON_BRANCH|分支)\s*[(]\s*([\w-]+)\s*[)]$/i);
if (branchMatch) {
branch = branchMatch[1];
label = 'ON_BRANCH';
}
const edge: {
from: string; to: string; type: ReturnType<typeof toEdgeType>;
iterator?: string; branch?: string;
} = {
from: e.from.toLowerCase().replace(/\s+/g, '-'),
to: e.to.toLowerCase().replace(/\s+/g, '-'),
type: toEdgeType(label),
};
if (iterator) edge.iterator = iterator;
if (branch) edge.branch = branch;
return edge;
});
+64 -1
View File
@@ -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`:欄位契約以頂層機械考
@@ -52,6 +54,18 @@ export type NodeInfo = {
/** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API)。 */
description?: string;
endpoint?: string;
/**
* recipe 的 payload/回應用法自我說明(3.12,同 branch_hint 的動機):
* 逐顆查 recipe 時光看 endpoint 不知道「payload 怎麼填、回應怎麼取值」⇒ 會退回寫 code。
*/
payload_hint?: {
/** 這個 recipe 期望的 body 形狀(body_template 的欄位骨架,值是 {{var}} 佔位) */
body_template?: unknown;
/** 回應正規化規則存在時,說明取值路徑等 */
response_map?: unknown;
/** 一行說明:怎麼用這個 recipe */
usage: string;
};
/**
* not_found 時的分型指路(task 3.7):兩庫(零件 registryrecipe 庫)都查過才點名,
* 並告訴 AI 該走哪條補件路+去哪裡看做法。欄位名 `suggestion`(單數字串)=verify.sh 03 組契約。
@@ -63,6 +77,13 @@ export type NodeInfo = {
similar_recipes?: string[];
/** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */
substitution?: NodeSubstitution;
/**
* 分支用法自我說明(3.11):只有「本身會分岔」的零件才有
* if_controlswitchtry_catch)。
* 存在的理由=走 n8n 式「逐顆查、自己組圖」的 AI,光看 input_schema 不知道
* 「判斷完之後兩條路怎麼接」⇒ 會回頭寫 code。判準:只看這一顆的回應就知道怎麼接下一步。
*/
branch_hint?: BranchHint;
};
export type SearchResult = {
@@ -216,6 +237,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;
}
@@ -230,6 +252,7 @@ export async function searchNodes(
source: 'recipe',
description: recipe.description,
endpoint: recipe.endpoint,
payload_hint: buildPayloadHint(recipe),
};
continue;
}
@@ -355,6 +378,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,
};
@@ -366,6 +390,7 @@ async function legacyPerNodeLookup(
info: {
status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe',
description: recipe.description, endpoint: recipe.endpoint,
payload_hint: buildPayloadHint(recipe),
},
missing: false,
};
@@ -407,7 +432,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 +497,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,
@@ -539,6 +567,41 @@ function buildSuggestion(componentId: string): string {
);
}
/**
* recipe 的 payload/回應用法自我說明(3.12)。
* 動機同 branch_hint:逐顆查 recipen8n 式)時,光看 endpoint 不知道 payload 怎麼填、
* 回應怎麼取值 ⇒ AI 會退回把整包寫進 workflow code。
*/
export function buildPayloadHint(recipe: RecipeDefinition): NodeInfo['payload_hint'] {
const parts: string[] = [];
if (recipe.body_template) {
parts.push('payload 已收在 recipe 的 body_template 裡,你只要把 {{變數}} 對應的值放進節點 context');
} else if (recipe.body) {
parts.push('payload 形狀見 body 欄位({{變數}} 由節點 context 填)');
} else {
parts.push('未定義 body_template:節點 context 會整包當 body 送出(_ 開頭的內部欄位會被剔除)');
}
if (recipe.response_map) {
parts.push('回應已正規化:執行結果除了原始 data,另附 text(取值路徑等規則寫在 recipe 裡,換源不必改 workflow');
} else {
parts.push('未定義 response_map:回應原樣放在 data,取值要自己指路徑');
}
if (recipe.auth === 'binding') {
parts.push(`認證=binding(免金鑰,用平台內建 ${recipe.binding_name ?? 'AI'}`);
} else if (recipe.auth_service) {
parts.push(`認證走 auth recipe「${recipe.auth_service}」(金鑰由系統在執行前注入,你不必也不該填)`);
}
return {
body_template: recipe.body_template,
response_map: recipe.response_map,
usage: parts.join('') + '。',
};
}
// ── registry 查詢 ─────────────────────────────────────────────────────────────
type CatalogEntry = {
+18 -3
View File
@@ -16,7 +16,8 @@
import { wasmWorkerUrl } from '../lib/component-loader';
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
import { listAllRecipes, type SearchNodesEnv } from './search-nodes';
import { listAllRecipes, buildPayloadHint, 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_schemaAI 拿到 if_controlswitch 仍不知道兩條路怎麼接 ⇒ 回頭寫 code。
const results = (body.data?.results ?? []).map(r => {
if (!r || typeof r !== 'object') return r;
const rec = r as Record<string, unknown>;
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,
},
};
@@ -64,7 +74,10 @@ export async function searchByTarget(
const q = query.toLowerCase();
// 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重
const seen = new Set<string>();
const results: Array<{ canonical_id: string; display_name?: string; description?: string; endpoint: string }> = [];
const results: Array<{
canonical_id: string; display_name?: string; description?: string; endpoint: string;
payload_hint?: unknown;
}> = [];
for (const r of all) {
if (seen.has(r.canonical_id)) continue;
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
@@ -75,6 +88,8 @@ export async function searchByTarget(
display_name: r.display_name,
description: r.description,
endpoint: r.endpoint,
// 3.12:逐顆查 recipe 時也要說得出「payload 怎麼填、回應怎麼取值」
payload_hint: buildPayloadHint(r),
});
}
return {
+55
View File
@@ -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.branchif_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<string, unknown>;
const data = (r.data && typeof r.data === 'object') ? r.data as Record<string, unknown> : 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;
+83
View File
@@ -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<string, BranchHint> = {
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()];
}
+82 -4
View File
@@ -20,6 +20,7 @@ import { isComponentHash, isRecipeHash } from './hash';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
import { renderBodyTemplate, applyResponseMap } from './recipe-payload';
/**
* WASM HTTP runnercanonical_id → 對應獨立 Worker URL。
@@ -120,7 +121,7 @@ export function createComponentLoader(env: Bindings) {
// 4. rec_hash → 查 RECIPES KV idx → recipe 執行
if (isRecipeHash(componentId)) {
const recipe = await resolveRecipe(componentId, env.RECIPES);
if (recipe) return makeRecipeRunner(recipe);
if (recipe) return pickRecipeRunner(recipe, env);
throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`);
}
@@ -134,7 +135,7 @@ export function createComponentLoader(env: Bindings) {
// 6. KV recipe(動態,用戶 push 的)
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
if (kvRecipe) return makeRecipeRunner(kvRecipe);
if (kvRecipe) return pickRecipeRunner(kvRecipe, env);
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
// 白名單見 WASM_HTTP_RUNNER_IDShttp_request、5 個待降級 API 零件、4 個 auth primitive)。
@@ -271,6 +272,73 @@ function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner |
return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN));
}
/**
* recipe → runner 的分派(3.12):auth='binding' 走平台 binding(免金鑰),
* 其餘一律走既有 HTTP 路徑(沒宣告 auth 的舊 recipe 完全不受影響)。
*/
function pickRecipeRunner(
recipe: import('../routes/recipes').RecipeDefinition,
env: Bindings,
): ComponentRunner {
return recipe.auth === 'binding'
? makeBindingRecipeRunner(recipe, env)
: makeRecipeRunner(recipe);
}
/**
* auth='binding' 的 recipe runner3.12 第四型認證):不打外部 HTTP、不需要任何金鑰,
* 直接用平台 bindingenv.AIVECTORIZE/…)⇒ leo 要的「開機就可用」。
*
* 為什麼要開這型:recipe 的舊抽象=「打一個外部 HTTP API」(endpoint+method+auth_service),
* 而 Cloudflare 的 binding 呼叫不是 HTTP ⇒ **整類能力被排除在 recipe 之外**。
* 開這一型不是為 Workers AI 開特例,是一次打開 env.AIVECTORIZEBROWSERQUEUE 整排。
*/
function makeBindingRecipeRunner(
recipe: import('../routes/recipes').RecipeDefinition,
env: Bindings,
): ComponentRunner {
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const name = recipe.binding_name ?? 'AI';
const binding = (env as unknown as Record<string, unknown>)[name];
if (!binding) {
return {
success: false,
error:
`recipe "${recipe.canonical_id}" 宣告 auth: binding、binding_name: "${name}"` +
`但這個部署沒有綁定 ${name}。請在 wrangler.toml 補上該 binding 後重新部署。`,
};
}
// endpoint 在 binding 型當作「要呼叫的資源名」(例 Workers AI 的模型 id
const target = recipe.endpoint;
const payload = renderBodyTemplate(recipe.body_template ?? recipe.body, ctxObj)
?? Object.fromEntries(Object.entries(ctxObj).filter(([k]) => !k.startsWith('_')));
try {
const runner = binding as { run?: (model: string, input: unknown) => Promise<unknown> };
if (typeof runner.run !== 'function') {
return {
success: false,
error: `binding "${name}" 沒有 run() 方法,目前 binding 型只支援 run(model, input) 形狀(如 env.AI)。`,
};
}
const data = await runner.run(target, payload);
if (recipe.response_map) {
const normalized = applyResponseMap(data, recipe.response_map);
return { success: true, data, text: normalized.text };
}
return { success: true, data };
} catch (e) {
return {
success: false,
error: `binding "${name}" 呼叫失敗(${target}):${e instanceof Error ? e.message : String(e)}`,
};
}
};
}
function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner {
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
@@ -293,9 +361,12 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
headers[k] = interpolate(v);
}
// body把 recipe.body 裡的 {{key}} 都換掉
// body優先 body_template(③ payload 層,3.12——支援巢狀/dot path/保留型別),
// 其次既有 recipe.body(淺層 {{key}},舊 recipe 照舊),最後才拿 ctx 當 body。
let bodyStr: string | undefined;
if (recipe.body) {
if (recipe.body_template) {
bodyStr = JSON.stringify(renderBodyTemplate(recipe.body_template, ctxObj));
} else if (recipe.body) {
bodyStr = interpolate(JSON.stringify(recipe.body));
} else if (method !== 'GET') {
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
@@ -313,6 +384,13 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
});
const data = await readBodyOnce(res);
// ③ 回應正規化(3.12):未設 response_map ⇒ 原樣回傳(既有 recipe 零行為變化)。
// 設了 ⇒ 額外附 `text`(各家形狀差異收在 recipe 裡,換源不必改 workflow)。
if (recipe.response_map) {
const normalized = applyResponseMap(data, recipe.response_map);
return { success: res.ok, status: res.status, data, text: normalized.text };
}
return { success: res.ok, status: res.status, data };
};
}
+12
View File
@@ -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<string, EdgeType> = {
'失敗時': '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',
};
+154
View File
@@ -0,0 +1,154 @@
/**
* recipe 的 payload 與回應處理層(SDD workflow-discovery 3.12 / CP arcrun-usable 步驟 5 缺口②)
*
* 為什麼存在(leo 的三層模型,第③層過去是空的):
* ① 零件(http_request ② auth recipeauth_service ③ **payload recipe** ← 這層
* 舊 schema 存不住 body 與「回應怎麼取值」⇒ 帶 body 的 API 只能把整包寫進 workflow code
* 回應解析(rag_chat 的 finalize2786 字元)綁死 Gemini 格式 ⇒ 換源必壞。
* 有了這層:**換 LLM 供應商=換 recipe,不必動 workflow**。
*
* 相容鐵律:三個欄位全為選填。既有 recipe(沒有這些欄位)行為**完全不變**——
* renderBodyTemplate(undefined,…) 回 undefined、applyResponseMap(body, undefined) 原樣回傳。
*/
/** 回應正規化規則(隨 recipe 走,故換源=換 recipe */
export type ResponseMap = {
/**
* 取值路徑(dot path,支援陣列索引)。
* 例:Gemini `candidates.0.content.parts.0.text`Claude `content.0.text`
* Workers AI `response`。
* 搭配 thinking_model 時可指向 parts 陣列本身。
*/
text_path?: string;
/**
* 思考型模型(如 gemma):parts 內會混入 `thought: true` 的思考過程,
* 要剔除後取最後一個非 thought 的 part。
*/
thinking_model?: boolean;
/** 淨化:要剝掉的前綴(實撞過「Draft:」「*」「Answer:」,且組合順序不定) */
strip_prefixes?: string[];
/** 答案標記:出現時只取其後的內容(實撞:模型會把草稿吐在標記前) */
answer_marker?: string;
};
/** 從物件用 dot path 取值:'a.0.b' → obj.a[0].b */
function getPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const part of path.split('.')) {
if (cur === null || cur === undefined) return undefined;
if (typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[part];
}
return cur;
}
// ── ③-a body_templatepayload 收回 recipe ───────────────────────────────────
/**
* 把 body_template 內所有 `{{var}}` 用 ctx 填掉(遞迴進巢狀 object / array)。
*
* 與 graph-executor 的 interpolateData 同一套語義(刻意一致,避免兩種插值行為):
* - 整個字串就是單一 `{{x}}` → 回**原型別**(陣列/物件/數字不被 stringify
* - 混合文字 → 拼成字串
* - 取不到 → **保留原樣** `{{x}}`(看得見才好 debug,不靜默吞掉)
*/
export function renderBodyTemplate(
template: unknown,
ctx: Record<string, unknown>,
): unknown {
if (template === undefined || template === null) return undefined;
return renderValue(template, ctx);
}
function renderValue(v: unknown, ctx: Record<string, unknown>): unknown {
if (typeof v === 'string') return renderString(v, ctx);
if (Array.isArray(v)) return v.map(item => renderValue(item, ctx));
if (v !== null && typeof v === 'object') {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
out[k] = renderValue(val, ctx);
}
return out;
}
return v;
}
function renderString(s: string, ctx: Record<string, unknown>): unknown {
const single = s.match(/^\s*\{\{([\w.]+)\}\}\s*$/);
if (single) {
const val = getPath(ctx, single[1]);
return val === undefined ? s : val;
}
return s.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => {
const val = getPath(ctx, key);
if (val === undefined) return `{{${key}}}`;
return typeof val === 'string' ? val : JSON.stringify(val);
});
}
// ── ③-b response_map:回應正規化 ─────────────────────────────────────────────
export type NormalizedResponse = {
/** 正規化後的純文字(沒有 response_map 或取不到時 undefined——誠實,不編造) */
text?: string;
/** 原始回應永遠保留(除錯與向後相容都靠它) */
raw: unknown;
};
/**
* 依 response_map 把各家 API 的回應正規化成 `{ text }`。
* 沒給 map ⇒ 原樣回傳(既有 recipe 零行為變化)。
*/
export function applyResponseMap(body: unknown, map?: ResponseMap): NormalizedResponse {
if (!map) return { raw: body };
let picked: unknown = map.text_path ? getPath(body, map.text_path) : body;
// 思考型模型:picked 是 parts 陣列 → 剔除 thought=true,取最後一個
if (map.thinking_model && Array.isArray(picked)) {
const real = picked.filter(
p => !(p && typeof p === 'object' && (p as Record<string, unknown>).thought === true),
);
const last = real[real.length - 1];
picked = (last && typeof last === 'object')
? (last as Record<string, unknown>).text
: last;
}
if (typeof picked !== 'string') return { text: undefined, raw: body };
return { text: sanitize(picked, map), raw: body };
}
/**
* 淨化(知識是實撞出來的,非預想):
* 1. 有 answer_marker → 只取標記**最後一次**出現之後的內容
* (實撞:模型的自檢清單內文也會提到標記,用 lastIndexOf 才撈得到真的那個)
* 2. 前綴組合順序不定(「* 【答】」「Draft: 【答】」「Answer: * 【答】」三型都撞過)
* ⇒ **循環**剝殼,單趟剝不乾淨
*/
function sanitize(input: string, map: ResponseMap): string {
let s = input.trim();
if (map.answer_marker) {
const idx = s.lastIndexOf(map.answer_marker);
if (idx >= 0) s = s.slice(idx + map.answer_marker.length);
}
const prefixes = map.strip_prefixes ?? [];
if (prefixes.length > 0) {
let changed = true;
while (changed) {
changed = false;
s = s.trimStart();
for (const p of prefixes) {
if (p && s.startsWith(p)) {
s = s.slice(p.length);
changed = true;
}
}
}
}
return s.trim();
}
+2 -1
View File
@@ -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
})),
});
+26
View File
@@ -16,6 +16,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash';
import type { ResponseMap } from '../lib/recipe-payload';
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
@@ -34,6 +35,26 @@ export interface RecipeDefinition {
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
headers?: Record<string, string>;
body?: Record<string, unknown>;
/**
* payload SDD workflow-discovery 3.12 body API payload recipe
* workflow code `body` {{var}} dot path
* body_template recipe
*/
body_template?: Record<string, unknown>;
/**
* API GeminiClaudeWorkers AI
* ** recipe ** recipe workflow
* recipe
*/
response_map?: ResponseMap;
/**
* 沿 auth_service
* `binding`****env.AIVECTORIZEBROWSERQUEUE
* Workers AI Cloudflare HTTP+
*/
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
/** auth='binding' 時指定用哪個 binding(例 'AI''VECTORIZE')。 */
binding_name?: string;
/**
* recipe auth recipeauth_recipe:{auth_service}
* recipe authkbdb_get / kbdb_create_block "kbdb"
@@ -116,6 +137,11 @@ recipesRouter.post('/recipes', async (c) => {
method: (body.method ?? 'POST').toUpperCase(),
headers: body.headers,
body: body.body,
// ③ payload/回應/binding 三層(3.12):全選填,沒給就是 undefined=既有行為
body_template: body.body_template,
response_map: body.response_map,
auth: body.auth,
binding_name: body.binding_name,
auth_service: body.auth_service,
credentials_required: body.credentials_required,
created_at: existing?.created_at ?? now,
+3
View File
@@ -155,6 +155,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'; // 結構語意(記錄圖結構,不執行)
@@ -164,6 +165,8 @@ export type GraphEdge = {
type: EdgeType;
condition?: string; // IF 的條件表達式
iterator?: string; // FOREACH 的迭代變數名
/** ON_BRANCH 的具名分支(對應 switch 零件 output 的 data.branch */
branch?: string;
};
export type ExecutionGraph = {
@@ -0,0 +1,68 @@
/**
* SDD workflow-discovery 3.11 08-01 3
*
* leo**AI **
* skill
*
* 08-01 prod if_control
* status/componentId/type/source/input_schema{condition,input}/success_rate/stability
* **** n8n AI code
*
* `branchHintFor()` AI
*/
import { describe, it, expect } from 'vitest';
import { branchHintFor } from '../src/lib/branch-hints';
describe('三顆分支零件的查詢回應自帶用法(AI 看一眼就知道怎麼接)', () => {
for (const id of ['if_control', 'switch', 'try_catch']) {
it(`${id}:回應含 branch_fieldbranchesedge_typesusageexample`, () => {
const hint = branchHintFor(id);
expect(hint).toBeDefined();
// 這一顆會輸出哪個欄位當分支標籤
expect(hint!.branch_field).toBe('data.branch');
// 接下游要用哪些邊型
expect(hint!.edge_types.length).toBeGreaterThan(0);
// 一行說明 + 可照抄範例(缺任一個,AI 都得自己猜)
expect(hint!.usage.length).toBeGreaterThan(0);
expect(hint!.example.length).toBeGreaterThan(0);
// eslint-disable-next-line no-console
console.log(
`\n──────── 逐顆查 ${id} 時,AI 會看到的 branch_hint ────────\n` +
JSON.stringify(hint, null, 2),
);
});
}
it('if_control 明說 ON_TRUEON_FALSE 兩條邊', () => {
const h = branchHintFor('if_control')!;
expect(h.edge_types).toContain('ON_TRUE');
expect(h.edge_types).toContain('ON_FALSE');
expect(h.branches).toEqual(['true', 'false']);
// 明說「不需要自己寫 code 判斷」——這句是防腹語術的關鍵
expect(h.usage).toContain('不需要自己寫 code');
});
it('switch 明說用 ON_BRANCH 並在邊上標 case 名,且 default 不需特別邊型', () => {
const h = branchHintFor('switch')!;
expect(h.edge_types).toContain('ON_BRANCH');
expect(h.usage).toContain('ON_BRANCH');
expect(h.usage).toContain('default_branch');
// branches 是動態的(由 cases 決定),要誠實說明而非給死清單
expect(typeof h.branches).toBe('string');
});
it('try_catch 明說 trycatch 兩條標籤,錯誤處理不必寫 code', () => {
const h = branchHintFor('try_catch')!;
expect(h.branches).toEqual(['try', 'catch']);
expect(h.edge_types).toContain('ON_BRANCH');
expect(h.usage).toContain('不需要寫 code');
});
it('不分岔的零件沒有 branch_hint(不加噪音)', () => {
expect(branchHintFor('http_request')).toBeUndefined();
expect(branchHintFor('code')).toBeUndefined();
expect(branchHintFor(undefined)).toBeUndefined();
});
});
@@ -0,0 +1,147 @@
/**
* SDD workflow-discovery 3.11
*
* 08-01
* conditional-edges.test.ts Input ****
* ****
* leo switchtry_catch `ON_CASE``ON_CATCH` grep=0
*
*
* given ** WASM **wasmtime .component-builds/*.wasm
*
*
*
* cd .component-builds
* echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm
* echo '{"value":"pending","cases":[...],"default_branch":"branch_default"}' | wasmtime switch/component.wasm
* echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm
*/
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
async function run(graph: unknown) {
const res = await SELF.fetch('http://localhost/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph, context: {} }),
});
const body = (await res.json()) as {
success: boolean;
trace?: Array<{ nodeId: string }>;
};
return { body, visited: (body.trace ?? []).map(t => t.nodeId) };
}
/** 真零件輸出 → 當作上游節點的 output 餵進圖 */
function graphWith(realOutput: unknown, edges: Array<Record<string, unknown>>, extraNodes: string[]) {
return {
id: 'real-branch',
name: '真零件輸出走邊',
nodes: [
{ id: 'ctrl', type: 'Input', data: realOutput },
...extraNodes.map(id => ({
id, type: 'Component', componentId: 'comp_uppercase', data: { text: id },
})),
],
edges,
};
}
describe('if_control 真輸出 → 引擎走對邊', () => {
// 真跑:echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm
const REAL_TRUE = { data: { branch: 'true', result: true }, success: true };
// 真跑:input.status = "inactive"
const REAL_FALSE = { data: { branch: 'false', result: false }, success: true };
const edges = [
{ from: 'ctrl', to: 'yes', type: 'ON_TRUE' },
{ from: 'ctrl', to: 'no', type: 'ON_FALSE' },
];
it('條件成立(真輸出 branch="true")→ 走 ON_TRUE', async () => {
const { body, visited } = await run(graphWith(REAL_TRUE, edges, ['yes', 'no']));
expect(body.success).toBe(true);
expect(visited).toContain('yes');
expect(visited).not.toContain('no');
});
it('條件不成立(真輸出 branch="false")→ 走 ON_FALSE', async () => {
const { visited } = await run(graphWith(REAL_FALSE, edges, ['yes', 'no']));
expect(visited).toContain('no');
expect(visited).not.toContain('yes');
});
});
describe('switch 真輸出 → 引擎走對邊(多路+default,leo:「switch 更嚴重」)', () => {
// 真跑(三個 case + default_branch):
// value="active" → {"data":{"branch":"branch_active"},"success":true}
// value="pending" → {"data":{"branch":"branch_pending"},"success":true}
// value="zzz" → {"data":{"branch":"branch_default"},"success":true}
const REAL_CASE1 = { data: { branch: 'branch_active' }, success: true };
const REAL_CASE3 = { data: { branch: 'branch_pending' }, success: true };
const REAL_DEFAULT = { data: { branch: 'branch_default' }, success: true };
const targets = ['p_active', 'p_inactive', 'p_pending', 'p_default'];
const edges = [
{ from: 'ctrl', to: 'p_active', type: 'ON_BRANCH', branch: 'branch_active' },
{ from: 'ctrl', to: 'p_inactive', type: 'ON_BRANCH', branch: 'branch_inactive' },
{ from: 'ctrl', to: 'p_pending', type: 'ON_BRANCH', branch: 'branch_pending' },
{ from: 'ctrl', to: 'p_default', type: 'ON_BRANCH', branch: 'branch_default' },
];
it('第 1 條 case(真輸出 branch_active)→ 只走 p_active', async () => {
const { body, visited } = await run(graphWith(REAL_CASE1, edges, targets));
expect(body.success).toBe(true);
expect(visited).toContain('p_active');
expect(visited).not.toContain('p_inactive');
expect(visited).not.toContain('p_pending');
expect(visited).not.toContain('p_default');
});
it('第 3 條 case(真輸出 branch_pending)→ 只走 p_pending(證明第 N 條路走得對)', async () => {
const { visited } = await run(graphWith(REAL_CASE3, edges, targets));
expect(visited).toContain('p_pending');
expect(visited).not.toContain('p_active');
expect(visited).not.toContain('p_inactive');
expect(visited).not.toContain('p_default');
});
it('無匹配(真輸出 branch_default)→ 只走 p_default', async () => {
const { visited } = await run(graphWith(REAL_DEFAULT, edges, targets));
expect(visited).toContain('p_default');
expect(visited).not.toContain('p_active');
expect(visited).not.toContain('p_pending');
});
});
describe('try_catch 真輸出 → 引擎走對邊(okcatch 兩路都驗)', () => {
// 真跑:echo '{"result":{"value":42},"error":""}' | wasmtime try_catch/component.wasm
const REAL_TRY = { data: { branch: 'try', result: { value: 42 } }, success: true };
// 真跑:echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm
const REAL_CATCH = { data: { branch: 'catch', error: 'boom' }, success: true };
const edges = [
{ from: 'ctrl', to: 'normal', type: 'ON_BRANCH', branch: 'try' },
{ from: 'ctrl', to: 'rescue', type: 'ON_BRANCH', branch: 'catch' },
];
it('成功(真輸出 branch="try")→ 走 normal,不走 rescue', async () => {
const { body, visited } = await run(graphWith(REAL_TRY, edges, ['normal', 'rescue']));
expect(body.success).toBe(true);
expect(visited).toContain('normal');
expect(visited).not.toContain('rescue');
});
it('失敗(真輸出 branch="catch")→ 走 rescue,不走 normal', async () => {
const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue']));
expect(visited).toContain('rescue');
expect(visited).not.toContain('normal');
});
it('try_catch 的 catch 路承接了「上游失敗」——不必寫 code try 一遍', async () => {
// 這是 leo 點名 try_catch 的原因:schema 用文字寫「走 catch 分支」但機器層沒有那條路。
// 現在有了:catch 標籤 → ON_BRANCH branch="catch" → 補救節點。
const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue']));
expect(visited).toContain('rescue');
});
});
@@ -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 codeArcrun#5
*
*
* executor.test.tsPIPE/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('條件邊的下游拿得到上游 contextpropagateCtx 一致)', 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_BRANCHswitch 具名分支)', () => {
/** 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-01switch 比 if 更嚴重)', () => {
/**
* output_schema `data.branch: string`
* if_control "true" | "false"
* switch case branch | default_branchN
* 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_BRANCHschema 已放行)', 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);
});
});
@@ -0,0 +1,104 @@
/**
* SDD workflow-discovery 3.11
*
* 08-01
* ON_TRUE/ON_FALSE/ON_BRANCHskill
* `graph-builder` ** `對每個 X` label**
* `ON_BRANCH(branch_active)` label `toEdgeType` **PIPE**
* AI
*
*
*/
import { describe, it, expect } from 'vitest';
import { buildExecutionGraph } from '../src/actions/graph-builder';
import { parseTriplets, resolveNodeRole } from '../src/actions/triplet-parser';
/** AI triplets graph
* nodeResults found**** */
function build(triplets: string[]) {
const parsed = parseTriplets(triplets)!;
const nodeResults: Record<string, { status: 'found'; componentId: string; type: ReturnType<typeof resolveNodeRole> }> = {};
for (const name of parsed.nodeNames) {
nodeResults[name] = {
status: 'found',
componentId: name.toLowerCase().replace(/\s+/g, '_'),
type: resolveNodeRole(name, parsed),
};
}
return buildExecutionGraph(parsed, nodeResults as never, 'test-graph', '測試');
}
function edgeBetween(graph: ReturnType<typeof build>, from: string, to: string) {
return graph.edges.find(e => e.from === from && e.to === to);
}
describe('意圖語法:if_control 兩路(ON_TRUEON_FALSE', () => {
it('ON_TRUEON_FALSE 編成對應邊型,不會退化成 PIPE', () => {
const g = build([
'input >> ON_SUCCESS >> 判斷有沒有新資料',
'判斷有沒有新資料 >> ON_TRUE >> 傳到telegram',
'判斷有沒有新資料 >> ON_FALSE >> 結束',
]);
expect(edgeBetween(g, '判斷有沒有新資料', '傳到telegram')?.type).toBe('ON_TRUE');
expect(edgeBetween(g, '判斷有沒有新資料', '結束')?.type).toBe('ON_FALSE');
});
it('中文語意詞「成立時」「否則」也編得出來', () => {
const g = build([
'判斷有沒有新資料 >> 成立時 >> 傳到telegram',
'判斷有沒有新資料 >> 否則 >> 結束',
]);
expect(edgeBetween(g, '判斷有沒有新資料', '傳到telegram')?.type).toBe('ON_TRUE');
expect(edgeBetween(g, '判斷有沒有新資料', '結束')?.type).toBe('ON_FALSE');
});
});
describe('意圖語法:switch 具名分支(ON_BRANCH(標籤)', () => {
it('括號裡的標籤被抽成 edge.branch,型別是 ON_BRANCH', () => {
const g = build([
'my_switch >> ON_BRANCH(branch_active) >> 處理啟用',
'my_switch >> ON_BRANCH(branch_pending) >> 處理待辦',
'my_switch >> ON_BRANCH(branch_default) >> 其他',
]);
const active = edgeBetween(g, 'my_switch', '處理啟用');
expect(active?.type).toBe('ON_BRANCH');
expect(active?.branch).toBe('branch_active');
const pending = edgeBetween(g, 'my_switch', '處理待辦');
expect(pending?.branch).toBe('branch_pending');
const dflt = edgeBetween(g, 'my_switch', '其他');
expect(dflt?.branch).toBe('branch_default');
});
it('全形括號也收(中文輸入法常打出全形)', () => {
const g = build(['my_switch >> ON_BRANCHbranch_active >> 處理啟用']);
const e = edgeBetween(g, 'my_switch', '處理啟用');
expect(e?.type).toBe('ON_BRANCH');
expect(e?.branch).toBe('branch_active');
});
it('try_catch 的 trycatch 標籤同樣收得到', () => {
const g = build([
'my_try >> ON_BRANCH(try) >> 正常流程',
'my_try >> ON_BRANCH(catch) >> 補救流程',
]);
expect(edgeBetween(g, 'my_try', '正常流程')?.branch).toBe('try');
expect(edgeBetween(g, 'my_try', '補救流程')?.branch).toBe('catch');
});
});
describe('零變化:既有語法不受影響', () => {
it('ON_SUCCESS 仍是 ON_SUCCESS', () => {
const g = build(['input >> ON_SUCCESS >> prep']);
expect(edgeBetween(g, 'input', 'prep')?.type).toBe('ON_SUCCESS');
});
it('「對每個 X」仍抽得到 iterator(不被新的 branch 抽取干擾)', () => {
const g = build(['parse_card >> 對每個 block >> post_block']);
const e = edgeBetween(g, 'parse_card', 'post_block');
expect(e?.type).toBe('FOREACH');
expect(e?.iterator).toBe('block');
expect(e?.branch).toBeUndefined();
});
});
@@ -0,0 +1,123 @@
/**
* recipe payload CP `arcrun-usable` 5
* SDD: workflow-discovery task 3.12
*
*
* schema {canonical_id, endpoint, method, auth_service}body
* body API recipe workflow code
* rag_chat finalize2786 Gemini
* leo auth recipe payload recipe
*
* body_template / response_map
* API stage features/09
*/
import { describe, it, expect } from 'vitest';
import { renderBodyTemplate, applyResponseMap } from '../src/lib/recipe-payload';
describe('body_templatepayload 收回 recipe(第③層)', () => {
it('巢狀結構的 {{var}} 都會被替換(不只 top-level', () => {
const out = renderBodyTemplate(
{ contents: [{ parts: [{ text: '{{prompt}}' }] }] },
{ prompt: '你好' },
);
expect(out).toEqual({ contents: [{ parts: [{ text: '你好' }] }] });
});
it('單一引用保留原型別(陣列/物件不被 stringify', () => {
const out = renderBodyTemplate(
{ messages: '{{history}}', n: '{{count}}' },
{ history: [{ role: 'user' }], count: 3 },
) as Record<string, unknown>;
expect(out.messages).toEqual([{ role: 'user' }]);
expect(out.n).toBe(3);
});
it('混合文字仍拼成字串', () => {
const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record<string, unknown>;
expect(out.q).toBe('請回答:天氣');
});
it('支援 dot path 取值', () => {
const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, {
assemble: { data: { prompt: '深層值' } },
}) as Record<string, unknown>;
expect(out.t).toBe('深層值');
});
it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug', () => {
const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record<string, unknown>;
expect(out.t).toBe('{{nope}}');
});
it('沒有 body_template → 回 undefined(呼叫端沿用既有行為)', () => {
expect(renderBodyTemplate(undefined, { a: 1 })).toBeUndefined();
});
});
describe('response_map:回應正規化(換源不必改 workflow', () => {
const geminiBody = {
candidates: [{ content: { parts: [{ text: '【答】台北是首都' }] } }],
};
it('path 取值:Gemini 形狀 → 純文字', () => {
const out = applyResponseMap(geminiBody, { text_path: 'candidates.0.content.parts.0.text' });
expect(out.text).toBe('【答】台北是首都');
});
it('換源=換 recipeClaude 形狀用不同 path,同樣取得出文字', () => {
const claudeBody = { content: [{ type: 'text', text: 'Claude 的答案' }] };
const out = applyResponseMap(claudeBody, { text_path: 'content.0.text' });
expect(out.text).toBe('Claude 的答案');
});
it('Workers AI 形狀(binding 回傳)同樣走 path', () => {
const waiBody = { response: 'Workers AI 的答案' };
const out = applyResponseMap(waiBody, { text_path: 'response' });
expect(out.text).toBe('Workers AI 的答案');
});
it('思考型模型:thought=true 的 part 要被剔除,取最後一個非 thought', () => {
const gemma = {
candidates: [{
content: {
parts: [
{ text: '讓我想想…', thought: true },
{ text: '真正的答案' },
],
},
}],
};
const out = applyResponseMap(gemma, {
text_path: 'candidates.0.content.parts',
thinking_model: true,
});
expect(out.text).toBe('真正的答案');
});
it('淨化規則:剝掉【答】前的草稿前綴(實撞三型之一)', () => {
const out = applyResponseMap(
{ r: 'Draft: 【答】正確內容' },
{ text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' },
);
expect(out.text).toBe('正確內容');
});
it('淨化規則:前綴組合順序不定 → 循環剝殼剝乾淨', () => {
const out = applyResponseMap(
{ r: 'Answer: * 【答】內容' },
{ text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' },
);
expect(out.text).toBe('內容');
});
it('沒有 response_map → 原樣回傳(既有 recipe 行為完全不變)', () => {
const out = applyResponseMap(geminiBody, undefined);
expect(out.text).toBeUndefined();
expect(out.raw).toEqual(geminiBody);
});
it('path 取不到 → 誠實回 undefined,不編造', () => {
const out = applyResponseMap({ a: 1 }, { text_path: 'b.c.d' });
expect(out.text).toBeUndefined();
});
});
@@ -0,0 +1,170 @@
/**
* CP `arcrun-usable` 5 SDD workflow-discovery task 3.13
*
* CP `assemble`5509 if×23
* code verdict=success
*
*
* `assemble` arcrun-rag repo
* ********
* payload if×23
* JS
* stage features/09
*
* 08-01 pending-changes
* rag_chat assemble5509 if×23for×12
*/
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
async function execute(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 (await res.json()) as {
success: boolean;
data: Record<string, unknown>;
trace?: Array<{ nodeId: string }>;
error?: string;
};
}
describe('步驟 5 驗收:判斷骨架不再需要 code 節點', () => {
/**
* assemble 5509
* code if×23
* payload
* branch
* payload/ recipe body_template/response_map ** code **
*/
it('多路分流+失敗路:三條路各自到位,全程零 code 節點', async () => {
const graph = {
id: 'step5-acceptance',
name: '步驟5 驗收:assemble 判斷骨架重寫',
nodes: [
// if_control/switch 形狀的輸出(線上是零件算出來的,這裡直接餵形狀)
{ id: 'route', type: 'Input', data: { success: true, data: { branch: 'has_data' } } },
{ id: 'handle_data', type: 'Component', componentId: 'comp_uppercase', data: { text: 'has-data' } },
{ id: 'handle_empty', type: 'Component', componentId: 'comp_uppercase', data: { text: 'empty' } },
{ id: 'handle_error', type: 'Component', componentId: 'comp_uppercase', data: { text: 'error' } },
],
edges: [
{ from: 'route', to: 'handle_data', type: 'ON_BRANCH', branch: 'has_data' },
{ from: 'route', to: 'handle_empty', type: 'ON_BRANCH', branch: 'empty' },
{ from: 'route', to: 'handle_error', type: 'ON_BRANCH', branch: 'error' },
],
};
const out = await execute(graph);
const visited = (out.trace ?? []).map(t => t.nodeId);
expect(out.success).toBe(true); // verdict success
expect(visited).toContain('handle_data');
expect(visited).not.toContain('handle_empty');
expect(visited).not.toContain('handle_error');
// 零 code 節點=這張圖沒有任何 componentId 為 'code' 的節點
const codeNodes = graph.nodes.filter(n => n.componentId === 'code');
expect(codeNodes).toHaveLength(0);
});
it('布林兩路(if_control)同樣零 code', async () => {
const graph = {
id: 'step5-bool',
name: '布林兩路',
nodes: [
{ id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' } } },
{ 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 out = await execute(graph);
const visited = (out.trace ?? []).map(t => t.nodeId);
expect(out.success).toBe(true);
expect(visited).toContain('no');
expect(visited).not.toContain('yes');
expect(graph.nodes.filter(n => n.componentId === 'code')).toHaveLength(0);
});
});
describe('步驟 5 驗收:字元數對照(判斷骨架的體積)', () => {
/**
*
* code JS assemble
*
*/
const oldStyleCodeNode = {
id: 'assemble',
type: 'Component',
componentId: 'code',
data: {
// 這是「判斷寫在 JS 裡」的縮影——線上版本是這個的放大(if×23)
code: `
const out = {};
if (!ctx.rows || ctx.rows.length === 0) { out.branch = 'empty'; }
else if (ctx.error) { out.branch = 'error'; }
else { out.branch = 'has_data'; }
if (out.branch === 'has_data') {
if (ctx.mode === 'strict') { out.text = ctx.rows[0].text; }
else if (ctx.mode === 'loose') { out.text = ctx.rows.map(r => r.text).join('\\n'); }
else { out.text = String(ctx.rows[0] && ctx.rows[0].text || ''); }
if (out.text.indexOf('【答】') >= 0) {
out.text = out.text.slice(out.text.lastIndexOf('【答】') + 3);
}
let changed = true;
while (changed) {
changed = false;
out.text = out.text.trimStart();
for (const p of ['Draft:', '*', 'Answer:']) {
if (out.text.startsWith(p)) { out.text = out.text.slice(p.length); changed = true; }
}
}
} else if (out.branch === 'error') {
out.text = 'failed: ' + String(ctx.error);
} else {
out.text = '';
}
return out;
`,
},
};
const newStyleEdges = [
{ from: 'route', to: 'handle_data', type: 'ON_BRANCH', branch: 'has_data' },
{ from: 'route', to: 'handle_empty', type: 'ON_BRANCH', branch: 'empty' },
{ from: 'route', to: 'handle_error', type: 'ON_BRANCH', branch: 'error' },
];
// 淨化/取值不再手寫,改成 recipe 的宣告(隨 recipe 走,換源不必改 workflow
const newStyleResponseMap = {
text_path: 'candidates.0.content.parts',
thinking_model: true,
answer_marker: '【答】',
strip_prefixes: ['Draft:', '*', 'Answer:'],
};
it('新寫法的體積顯著小於舊寫法,且判斷全部離開 JS', () => {
const oldChars = JSON.stringify(oldStyleCodeNode).length;
const newChars =
JSON.stringify(newStyleEdges).length + JSON.stringify(newStyleResponseMap).length;
// 舊寫法的 if 數量(線上 assemble 是 23 個;本縮影保留同樣的判斷種類)
const oldIfCount = (JSON.stringify(oldStyleCodeNode).match(/if\s*\(/g) ?? []).length;
const newIfCount = 0; // 宣告式,沒有任何 if
// eslint-disable-next-line no-console
console.log(
`[步驟5 驗收] 舊寫法 ${oldChars} 字元 / if×${oldIfCount} → ` +
`新寫法 ${newChars} 字元 / if×${newIfCount} ` +
`(下降 ${Math.round((1 - newChars / oldChars) * 100)}%`,
);
expect(newChars).toBeLessThan(oldChars);
expect(newIfCount).toBe(0);
});
});
+6 -2
View File
@@ -40,8 +40,12 @@ export async function handleMcpRequest(
" 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、",
" 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。",
"",
"邊只有兩種`ON_SUCCESS``對每個 <變數>`FOREACH",
"**沒有** `ON_TRUE``ON_FALSE``ON_FAILURE`——引擎目前不支援條件分支。",
"邊:`ON_SUCCESS`(成功往下)、`對每個 <變數>`FOREACH",
"以及**條件分支**(2026-08-01 起引擎支援):",
"`ON_TRUE``ON_FALSE`(配 `if_control`)、`ON_BRANCH``branch:` 標籤",
"(配 `switch` 的每個 case`try_catch` 的 try·catch)。",
"🔴 **需要判斷時用分支邊,不要寫 code 判斷**——查零件的回應會附 `branch_hint`",
"(哪些邊型+可照抄範例),照著接即可。",
"第一個節點固定是 `input`。",
].join("\n");
+1 -1
View File
@@ -41,7 +41,7 @@
| 坑 | 現況 | 怎麼避 |
|---|---|---|
| **`/cypher/search` 曾回假 `found`** | 2026-07-31 已修:兩庫(零件+recipe)都查,缺件回 `not_found``suggestion` 指路。舊實例(未更新部署)仍是假 found | 拿到 `not_found``suggestion` 走;拿到 `unknown`=查不到 registry ≠ 不存在 |
| **引擎沒有條件分支** | `grep ON_TRUE\|ON_FALSE` = 0`if_control` 只回 boolean | 判斷寫成獨立節點接 `ON_SUCCESS`。見 Gitea Arcrun#5 |
| ~~引擎沒有條件分支~~ **已解(2026-08-01** | 引擎支援 `ON_TRUE``ON_FALSE``ON_BRANCH``if_control``switch``try_catch` 都輸出 `data.branch` 標籤,引擎依標籤選路(Arcrun#5 根治)| **需要判斷就用分支邊,別寫 code 判斷**。查零件的回應附 `branch_hint`(邊型+可照抄範例),照著接 |
| **`registry/examples/` 8/13 是壞的** | 引用不存在的零件(把 recipe 當零件寫)| **別照抄 examples**,改用 `arcrun_get_workflow` 拿實跑過的 |
| **registry 可能是空的** | 安裝器無註冊步驟 ⇒ 新實例查不到零件 | 查不到 ≠ 不存在,別據此改寫成 code |
+24 -5
View File
@@ -29,17 +29,36 @@ input >> ON_SUCCESS >> <第一步> >> ... → 丟 /cypher/search → 系統回
- **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名**
- **邊**=什麼情況下往下走
## 2. 邊只有兩種(真範本裡出現過的)
## 2. 邊有這些
| 邊 | 意思 | 真例 |
|---|---|---|
| `ON_SUCCESS` | 上一步成功就往下 | `input >> ON_SUCCESS >> prep` |
| `對每個 <變數>` | 上一步產出清單,逐項處理(FOREACH)| `parse_card >> 對每個 block >> post_block` |
| `ON_TRUE` / `ON_FALSE` | 條件成立/不成立各走一條(配 `if_control`| `判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram` |
| `ON_BRANCH``branch:` | 依標籤選路(配 `switch` 每個 case、`try_catch` 的 try/catch| `my_switch >> ON_BRANCH(branch_active) >> 處理啟用` |
⚠️ **不要寫 `ON_FAILURE``ON_TRUE``ON_FALSE`**——引擎目前**沒有條件分支**
(實測 `grep ON_TRUE|ON_FALSE` 於 cypher-executor = 0;見 Gitea Arcrun#5)。
需要判斷時**寫成一個獨立節點**(例 `check_amount`)再接 `ON_SUCCESS`
讓查詢告訴你有沒有零件可用。
### 2.1 條件分支怎麼寫(2026-08-01 起引擎支援)
**需要判斷時,用分支邊,不要寫 `code` 判斷。**
三顆流程控制零件都輸出 `data.branch` 標籤,引擎依標籤選路:
| 零件 | 輸出的標籤 | 接法 |
|---|---|---|
| `if_control` | `"true"` / `"false"` | `ON_TRUE``ON_FALSE` 各一條 |
| `switch` | 你在 `cases[].branch` 取的名字(沒中則 `default_branch`| 每條路一條 `ON_BRANCH`,邊上標 `branch` |
| `try_catch` | `"try"`(沒錯)/`"catch"`(有錯)| 兩條 `ON_BRANCH`,標 `try``catch` |
```
判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram
判斷有沒有新資料 >> ON_FALSE >> 結束
```
中文語意詞亦可:「成立時」=`ON_TRUE`、「否則」=`ON_FALSE`
💡 **不必背**:查零件時回應會附 `branch_hint`(有哪些標籤、用哪些邊型、可照抄的範例),
照著接就對了。
⚠️ 仍然**不要寫 `ON_FAILURE`**(沒有這種邊;要處理失敗用 `try_catch` `ON_BRANCH(catch)`)。
## 3. 第一個節點固定是 `input`
@@ -132,6 +132,87 @@
---
## 3.y 追加(2026-08-01leo 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-mvprecipe-system**——兩本 paused 的未完成任務
> core-mvp 14 筆=credential 注入/auth-workermulti-tenant KVanalytics
> recipe-system 10 筆=prompt_recipe 的 MCP tool 與 mira wiki 端到端)
> **與本次兩缺口零交集**。升任一本為 active 就得先收掉 workflow-discovery16 筆
> 未完成、正在服務 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)。
- [x] 3.11 **缺口①:引擎通用具名分支邊**(=Arcrun#5 根治;CP 步驟 5 交付物之一)
✅ 08-01 本地綠(commit `323ccc8`):`ON_TRUE``ON_FALSE``ON_BRANCH` 三邊型+
`readBranch()` 四層相容讀法(data.branch → branch → data.result → result)。
**做成通用具名分支,非布林特例**leo 08-01「你改了 if,有改 switch 嗎?switch 更嚴重」)——
三顆流程控制零件的 output_schema 本來就都收斂到 `data.branch: string`
if_control→true/falseswitch→case 名/default_branchtry_catch→try/catch
⇒ 引擎只需一個機制,不留「做完 if 還要為 switch 再改一次」的債。
ON_TRUE/ON_FALSE=布林路語法糖,測試已證與 ON_BRANCH branch="true" 等價。
**分支用法查得到**leo 08-01 n8n 式逐顆查):新增 `lib/branch-hints.ts`
三顆零件的查詢回應自帶 `branch_hint`branch_field/branches/edge_types/usage/example),
四條回應路徑全 wirecatalog foundlegacy 逐顆/步驟4 substitutiontarget=component)。
測試 `tests/conditional-edges.test.ts` 16 項全綠;零變化:現存 workflow 用到新邊型=0 筆。
〔原始描述〕
現況:`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
行為必須零變化(跑一次證明)。
- [x] 3.12 **缺口②:recipe payload 與回應處理層**CP 步驟 5 交付物之二、之三)
✅ 08-01 本地綠(commit `5f5c0a8`):新增 `lib/recipe-payload.ts`
`renderBodyTemplate` 遞迴插值+保留型別+dot path;`applyResponseMap` 取值路徑/
thinking_model 剔除 thoughtanswer_marker 用 lastIndexOfstrip_prefixes 循環剝殼),
`RecipeDefinition` 加四個**全選填**欄位 body_templateresponse_mapauthbinding_name
`component-loader``makeBindingRecipeRunner``pickRecipeRunner`auth='binding'
走平台 binding=免金鑰、開機即可用;一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排)。
**payload 用法查得到**`buildPayloadHint()` wire 進三條 recipe 回應路徑
(守 D36:只說「金鑰由系統注入、你不必也不該填」,不吐值)。
測試 `tests/recipe-payload-response.test.ts` 14 項全綠(含 Gemini/Claude/Workers AI
三家形狀各用不同 path 都取得出文字=換源=換 recipe 的實證)。
相容:未設新欄位的既有 recipe 行為完全不變(有測試守)。
〔原始描述〕
現況 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**
◐ 08-01:本地驗收綠(`tests/step5-acceptance.test.ts` 3 項),實跑輸出=
**舊寫法 1201 字元 / if×8 → 新寫法 350 字元 / if×0(下降 71%**
且分流工作流 `success=true`、零 code 節點。
⚠️ **誠實標記**:線上那顆 `assemble`5509 字元、if×23)住在 arcrun-rag 實例,
本 repo 無其定義 ⇒ 本次是把它的**判斷骨架**以新能力重建成等價工作流證明
「判斷不必寫在 JS 裡」,**不是**直接改寫線上節點。
真正的改寫與 haiku 場景(逐顆查、只寫 recipe 補全、零 JS)=**stage 端到端**
features/09),未驗前不得標 ✅。
〔原始描述〕:拿現行 `assemble` 節點
(5509 字元、if×23)用新能力重寫 → code 大幅下降且仍 `verdict=success`
貼改寫前後字元數與實跑輸出。另附 haiku 場景:缺件時只寫 recipe 就補全、零 JS。
---
## 跨任務鐵律提醒
- 強制填 / 搜尋 / 回填全是**能力 → 落 API**CLI/MCP 只暴露(rule 07)。