From 525faaf5d01e156a9b8f90808607bead92f40165 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 11 Aug 2026 21:51:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(cypher-executor):=20/cypher/search=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=AA=A4=E5=A0=B1=E5=9F=B7=E8=A1=8C=E6=9C=9F?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E9=9B=B6=E4=BB=B6=20not=5Ffound=EF=BC=88Arcr?= =?UTF-8?q?un#88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 病因:/cypher/search 只查 component registry(SUBMISSIONS_KV,經 submitComponent/ index-only 才有記錄);而 component-loader.ts 能直接解析、從不查 registry 的一整類 零件(trigger_workflow/BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS, 如 if_control/http_request/switch)從未被 submit 過。leo21c 實例實測: GET /components/catalog 回 404「零件 catalog 不存在」,search 因此對這些零件誠實地 回「兩庫都查過沒有」——但它們其實跑得動(leo 08-11 探測工作流已證)。 修法:從 component-loader.ts 匯出 RUNTIME_NATIVE_COMPONENT_IDS(既有三份執行期 解析白名單的聯集,非新清單),search-nodes.ts 在查 registry 之前先比對, 不受 registry 是否可達/是否已 backfill 影響。真正不存在的零件仍誠實回 not_found/unknown,not_found 的分型建議與相似候選機制不變。 刻意不做:不掃描 registry/components/* 目錄當清單來源——那含已標記待刪的死碼 (km_writer/kbdb_upsert_block),07-30 曾把這類死碼誤灌進 registry;也不投資 SUBMISSIONS_KV 的 backfill 腳本——decisions-summary.md D29 已定調 SUBMISSIONS_KV 併入「KV 退休戰」,不宜再加投資。 測試:cypher-executor/tests/search-nodes-runtime-native.test.ts 7 case 全綠, 複現 registry unreachable/registry 可達但目錄空(leo21c 實例的真實症狀)兩種情境。 全 suite 迴歸:357 pass(較修前 350 pass 多 7 個新測試),既有 14 個失敗與修前 數量、內容完全相同(pre-existing,與本次改動無關)。 --- cypher-executor/src/actions/search-nodes.ts | 29 ++++- cypher-executor/src/lib/component-loader.ts | 27 ++++ .../tests/search-nodes-runtime-native.test.ts | 116 ++++++++++++++++++ 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 cypher-executor/tests/search-nodes-runtime-native.test.ts diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index d1a192e..cabec18 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -1,6 +1,6 @@ import type { ParsedTriplets, NodeRole } from './triplet-parser'; import { resolveNodeRole, isVirtualIoName } from './triplet-parser'; -import { wasmWorkerUrl } from '../lib/component-loader'; +import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader'; import { resolveRecipe } from '../routes/recipes'; import type { RecipeDefinition } from '../routes/recipes'; import { branchHintFor } from '../lib/branch-hints'; @@ -44,8 +44,12 @@ export type NodeInfo = { status: NodeStatus; componentId?: string; type: NodeRole; - /** found 時標來源庫:零件 registry(component)或 recipe 庫(recipe)。 */ - source?: 'component' | 'recipe'; + /** + * found 時標來源庫:零件 registry(component)、recipe 庫(recipe), + * 或 cypher-executor 自帶、無須查 registry 即保證解析得動的執行期原生零件(builtin, + * Arcrun#88——component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS)。 + */ + source?: 'component' | 'recipe' | 'builtin'; /** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */ input_schema?: unknown; /** 成功率(found 時附上,讓「被測過幾次」看得見)。 */ @@ -212,6 +216,25 @@ export async function searchNodes( continue; } + // ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ────────────────── + // component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS=trigger_workflow/ + // BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS 的聯集—— + // 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。 + // 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control/ + // http_request/switch…)會被下面「兩庫都查過沒有」誤判成 not_found—— + // 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。 + // target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。 + if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) { + nodeResults[nodeName] = { + status: 'found', + componentId, + type: role, + source: 'builtin', + branch_hint: branchHintFor(componentId), + }; + continue; + } + // registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。 // **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。 // 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。 diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index 46315a1..2a0f8aa 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -88,6 +88,33 @@ const LOGIC_BINDING_MAP: Record = { // Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。 }; +/** + * 「查得到 vs 真的有」的單一真相源(Arcrun#88,2026-08-11)。 + * + * 病因:`/cypher/search`(`search-nodes.ts`)只查 component registry(`SUBMISSIONS_KV`, + * 經 `submitComponent`/`index-only` 才會有記錄);而本檔 0/1/5/7 四步驟能直接解析、 + * **完全不查 registry** 的一整類零件(trigger_workflow、BUILTIN_COMPONENTS、 + * LOGIC_BINDING_MAP、WASM_HTTP_RUNNER_IDS)從未被 submit 過(也不需要——它們是 + * cypher-executor 自帶的,不是投稿存量)。實測 leo21c 實例:`/components/catalog` + * 404(registry 是舊版沒這端點/索引空),search 因此對 `if_control`/`http_request` + * 誠實地回「兩庫都查過沒有」——但這兩顆其實跑得動(leo 08-11 探測工作流已證)。 + * + * 修法:把「執行期真的解析得動」的這份清單匯出給 search-nodes.ts,在查 registry + * **之前**先比對——讓「查得到」不受 registry 是否可達/是否已 backfill 影響。 + * + * 刻意不做的事:不去掃 `registry/components/*` 目錄當清單來源——那是零件原始碼 + * 存放處,含已標記待刪的死碼(`km_writer`/`kbdb_upsert_block`,見 + * `system-dev/docs/3-specs/arcrun-usable/cleanup-dead-code.md`);07-30 曾把這類死碼 + * 誤灌進 registry(leo 點名的錯)。這裡改用**執行期真正拿去 resolve 的白名單本身** + * (本檔 1/5/7 步驟既有的三份清單)——精確等於「解析得動」,不會多一顆、不會少一顆。 + */ +export const RUNTIME_NATIVE_COMPONENT_IDS: ReadonlySet = new Set([ + 'trigger_workflow', + ...BUILTIN_COMPONENTS.keys(), + ...Object.keys(LOGIC_BINDING_MAP), + ...WASM_HTTP_RUNNER_IDS, +]); + export function createComponentLoader(env: Bindings) { return async (componentId: string): Promise => { diff --git a/cypher-executor/tests/search-nodes-runtime-native.test.ts b/cypher-executor/tests/search-nodes-runtime-native.test.ts new file mode 100644 index 0000000..ef92fdb --- /dev/null +++ b/cypher-executor/tests/search-nodes-runtime-native.test.ts @@ -0,0 +1,116 @@ +/** + * Arcrun#88:「零件目錄會說『這顆零件不存在』,但同一台實例上那顆零件跑得動」 + * + * 病史(leo21c 實例實測,2026-08-11): + * `/cypher/search` 對 `if_control`/`http_request` 回 `not_found`, + * 但兩者其實由 component-loader.ts 直接解析(LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS), + * 從不查 registry;registry catalog 端點在該實例回 404(`GET /components/catalog` → + * `{"success":false,"error":"零件 catalog 不存在"}`),search 因此誤判成「兩庫都查過沒有」。 + * + * 本測試複現病史的環境條件(wrangler.test.toml 未設 WORKER_SUBDOMAIN → registryBase + * undefined → catalog.status='unreachable',等價於「registry 完全連不到」), + * 驗證修法:RUNTIME_NATIVE_COMPONENT_IDS 的成員必須在 registry 查詢**之前**就短路成 found, + * 不受 registry 健康狀態影響——因為它們的存在性從不依賴 registry。 + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { parseTriplets } from '../src/actions/triplet-parser'; +import { searchNodes } from '../src/actions/search-nodes'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const IF_CONTROL_TRIPLETS = [ + 'input >> ON_SUCCESS >> if_control', +]; + +const HTTP_REQUEST_TRIPLETS = [ + 'input >> ON_SUCCESS >> http_request', +]; + +const MULTI_BUILTIN_TRIPLETS = [ + 'input >> ON_SUCCESS >> switch', + 'input >> ON_SUCCESS >> filter', + 'input >> ON_SUCCESS >> code', +]; + +const FAKE_COMPONENT_TRIPLETS = [ + 'input >> ON_SUCCESS >> totally_made_up_component_xyz', +]; + +describe('Arcrun#88:執行期原生零件不受 registry 健康狀態影響', () => { + it('if_control(LOGIC_BINDING_MAP 成員)在 registry 不可達時仍回 found', async () => { + const parsed = parseTriplets(IF_CONTROL_TRIPLETS); + expect(parsed).not.toBeNull(); + const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, { + // 無 WORKER_SUBDOMAIN/REGISTRY_BASE_URL → registryBase undefined → catalog unreachable + }); + expect(nodeResults.if_control.status).toBe('found'); + expect(nodeResults.if_control.source).toBe('builtin'); + // if_control 會分岔,branch_hint 應隨 found 一併附上(不必逐顆再查一次) + expect(nodeResults.if_control.branch_hint?.edge_types).toEqual(['ON_TRUE', 'ON_FALSE']); + expect(missingNodes).not.toContain('if_control'); + }); + + it('http_request(WASM_HTTP_RUNNER_IDS 成員)在 registry 不可達時仍回 found', async () => { + const parsed = parseTriplets(HTTP_REQUEST_TRIPLETS); + const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {}); + expect(nodeResults.http_request.status).toBe('found'); + expect(nodeResults.http_request.source).toBe('builtin'); + expect(missingNodes).not.toContain('http_request'); + }); + + it('switch/filter/code(同一批白名單的其他成員)也回 found,不逐一漏網', async () => { + const parsed = parseTriplets(MULTI_BUILTIN_TRIPLETS); + const { nodeResults } = await searchNodes(parsed!, undefined, {}); + expect(nodeResults.switch.status).toBe('found'); + expect(nodeResults.filter.status).toBe('found'); + expect(nodeResults.code.status).toBe('found'); + }); + + it('registry 完全連不到時,真正不存在的名字誠實回 unknown(不敢空口說沒有——既有行為,修法沒有動它)', async () => { + const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS); + const { nodeResults } = await searchNodes(parsed!, undefined, {}); + expect(nodeResults.totally_made_up_component_xyz.status).toBe('unknown'); + }); + + it('registry 查得到但目錄是空的(複現 leo21c 實例 catalog 404 的真實症狀):真正不存在的名字回 not_found', async () => { + // 複現生產實測:GET /components/catalog → HTTP 200 空陣列(本測試模擬「registry 活著但沒東西」, + // 與 leo21c 實例的「404 零件 catalog 不存在」殊途同歸——都會落到「查得到、目錄無此零件」)。 + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + )); + const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS); + const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, { + WORKER_SUBDOMAIN: 'test-sub', + }); + expect(nodeResults.totally_made_up_component_xyz.status).toBe('not_found'); + expect(missingNodes).toContain('totally_made_up_component_xyz'); + }); + + it('registry 目錄是空的(catalog 通但無資料)時,執行期原生零件依然 found——這才是 Arcrun#88 的核心場景', async () => { + // 這就是 leo21c 實例的真實狀態:registry 活著、目錄卻沒有任何一顆執行期原生零件的記錄 + // (SUBMISSIONS_KV 從未收到 if_control/http_request 的 submit)。若沒有本次修法, + // 這裡會落到「兩庫都查過沒有」→ not_found,正是 Arcrun#88 回報的病徵。 + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + )); + const parsed = parseTriplets(IF_CONTROL_TRIPLETS); + const { nodeResults } = await searchNodes(parsed!, undefined, { WORKER_SUBDOMAIN: 'test-sub' }); + expect(nodeResults.if_control.status).toBe('found'); + expect(nodeResults.if_control.source).toBe('builtin'); + }); + + it('target=recipe 明確只查 recipe 庫時,執行期原生零件不搶答 found(尊重使用者明確限庫)', async () => { + const parsed = parseTriplets(IF_CONTROL_TRIPLETS); + const { nodeResults } = await searchNodes(parsed!, undefined, {}, 'discover', 'recipe'); + // if_control 從來不是 recipe,target=recipe 下不該被 builtin 短路成 found + expect(nodeResults.if_control.status).not.toBe('found'); + }); +});