diff --git a/cli/src/commands/push.ts b/cli/src/commands/push.ts index 9fa6e77..7d36079 100644 --- a/cli/src/commands/push.ts +++ b/cli/src/commands/push.ts @@ -55,10 +55,12 @@ export async function cmdPush(filePath: string): Promise { const searchSpinner = ora('取得執行圖').start(); let graph: unknown; try { + // t158「部署≠發現」(leo:「這裡只是複製工作流的 data 過去,沒有要在這裡驗證」): + // push=複製路徑,帶 mode:compile 純編圖——寫錯的 workflow 照樣部署,錯在執行時現形。 const res = await fetch(`${executorUrl}/cypher/search`, { method: 'POST', headers, - body: JSON.stringify({ triplets: workflow.flow }), + body: JSON.stringify({ triplets: workflow.flow, mode: 'compile' }), }); if (!res.ok) { @@ -68,10 +70,8 @@ export async function cmdPush(filePath: string): Promise { } const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] }; - if (data.missing?.length > 0) { - searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`)); - process.exit(1); - } + // t158:push 不看 missing(compile 模式亦恆空)——存在性由執行時 component-loader 決定; + // 要「先問有沒有」用 acr validate/MCP 查詢(discover 路徑)。 // 附上 id / name,並將 workflow.config 套入節點(componentId + data) const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record }>; edges: unknown[] }; diff --git a/cypher-executor/src/actions/cypher-handlers.ts b/cypher-executor/src/actions/cypher-handlers.ts index cc5648f..05de7f6 100644 --- a/cypher-executor/src/actions/cypher-handlers.ts +++ b/cypher-executor/src/actions/cypher-handlers.ts @@ -5,12 +5,13 @@ import { graphSchema } from '../lib/schemas'; import { createComponentLoader } from '../lib/component-loader'; import { writeEvaluation, updateComponentStats } from './execution-evaluator'; import { parseTriplets } from './triplet-parser'; -import { searchNodes } from './search-nodes'; +import { searchNodes, type SearchMode } from './search-nodes'; import { buildExecutionGraph } from './graph-builder'; export async function handleCypherSearch( triplets: unknown[], env: Bindings, + mode: SearchMode = 'discover', ): Promise<{ nodes: Record; cypher: unknown; missing: string[] }> { const parsed = parseTriplets(triplets); if (!parsed) { @@ -19,7 +20,12 @@ export async function handleCypherSearch( // 2026-07-30:查 registry 判真實存在(workflow-discovery)。 // `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。 - const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env); + // + // t158(07-31 迴歸修復,leo:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證」): + // 誠實化只屬於 **discover**(AI 問「有沒有」);**compile**(部署/推送的複製路徑) + // 純編圖零查詢——那本來就是既有設計(workflows.json=打包期預編的搬運), + // 5cadc60 起誠實化漏進複製路徑=迴歸(冷實例 8 節點 25.7s、安裝器 timeout 炸)。 + const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env, mode); const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result'); return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes }; @@ -52,7 +58,9 @@ export async function handleCypherExecute( throw new Error('無法解析任何節點'); } - const { nodeResults } = await searchNodes(parsed, config, env); + // t158:執行路徑=compile(零 discovery round-trip)——存在性由 component-loader + // 在載入該節點時決定(原本的權威),查詢層不重複驗。 + const { nodeResults } = await searchNodes(parsed, config, env, 'compile'); const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config); const parseResult = graphSchema.safeParse(graph); diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index e10f0bf..a818b65 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -8,7 +8,8 @@ import type { RecipeDefinition } from '../routes/recipes'; * `not_found` 而非 `missing`:欄位契約以頂層機械考 * `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。 */ -export type NodeStatus = 'found' | 'not_found' | 'unknown'; +/** `unchecked`=compile 模式的誠實標記:沒查、不知道有沒有(≠found 的假信號)。 */ +export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked'; export type NodeInfo = { status: NodeStatus; @@ -40,6 +41,18 @@ export type SearchResult = { missingNodes: string[]; }; +/** + * t158(leo 07-31 定調「部署≠發現」): + * 「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證,難怪這麼慢。 + * 就算是我自己寫了錯的工作流,也可以跑跑看,如果錯誤就修改, + * 沒有說有錯誤還要一個個驗證這回事。」 + * - `compile`=純編圖:**零外部查詢**(不打 registry、不掃 recipe、不算相似度、不擋 missing)。 + * 部署/推送/執行路徑用——寫錯的 workflow 照樣部署,錯在執行時現形。 + * - `discover`=誠實查詢(預設,`/cypher/search` 的既有契約):AI 問「有沒有」時用, + * not_found+分型指路+相似候選全保留。 + */ +export type SearchMode = 'discover' | 'compile'; + /** searchNodes 需要的環境子集(cypher-handlers 傳整份 Bindings 進來也相容)。 */ export type SearchNodesEnv = { WORKER_SUBDOMAIN?: string; @@ -79,13 +92,46 @@ export async function searchNodes( parsed: ParsedTriplets, config?: Record>, env?: SearchNodesEnv, + mode: SearchMode = 'discover', ): Promise { const nodeResults: Record = {}; const missingNodes: string[] = []; + // ── compile:純編圖,零外部查詢(t158,部署≠發現)───────────────────────── + if (mode === 'compile') { + for (const nodeName of parsed.nodeNames) { + const role = resolveNodeRole(nodeName, parsed); + if ((role === 'Input' || role === 'Output') && isVirtualIoName(nodeName)) { + nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role }; + continue; + } + const configComponent = config?.[nodeName]?.component as string | undefined; + // unchecked=誠實「沒查」;存在性由 component-loader 在執行時決定 + nodeResults[nodeName] = { + status: configComponent ? 'found' : 'unchecked', + componentId: configComponent ?? nodeName, + type: role, + }; + } + return { nodeResults, missingNodes }; + } + const sub = env?.WORKER_SUBDOMAIN; const registryBase = env?.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined); + // ── discover 批次化(t158):兩庫各抓**一次**,之後全在記憶體內比對。──────── + // 病史(07-31 stage 實測):舊版對每個 missing 節點各打「1 次逐顆查+最多 9 次 + // 相似搜尋+一輪 recipe KV 掃描」⇒ 冷實例 8 節點 /cypher/search 25.7s, + // 安裝器 15s timeout 必炸。批次化後每 request 固定 1 次 catalog+1 次 recipe 清單。 + const catalog = registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] }; + const recipes = env?.RECIPES ? await listAllRecipes(env.RECIPES) : []; + const byId = new Map(); + for (const e of catalog.entries) { + const prev = byId.get(e.canonical_id); + if (!prev || (e.score ?? 0) > (prev.score ?? 0)) byId.set(e.canonical_id, e); + for (const a of e.aliases ?? []) if (!byId.has(a)) byId.set(a, e); + } + for (const nodeName of parsed.nodeNames) { const role = resolveNodeRole(nodeName, parsed); @@ -107,34 +153,37 @@ export async function searchNodes( continue; } - if (!registryBase) { + // registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。 + // **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。 + // 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。 + if (catalog.status === 'unreachable') { nodeResults[nodeName] = { status: 'unknown', componentId, type: role }; continue; } + if (catalog.status === 'no_endpoint') { + const legacy = await legacyPerNodeLookup(registryBase!, componentId, nodeName, role, env, recipes); + nodeResults[nodeName] = legacy.info; + if (legacy.missing) missingNodes.push(nodeName); + continue; + } - // ── 第一庫:零件 registry ──────────────────────────────────────────────── - const q = await fetchComponent(registryBase, componentId); - if (!q.ok) { - // registry 查不通(未部署/網路失敗)⇒ 誠實回 unknown。 - // **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。 - nodeResults[nodeName] = { status: 'unknown', componentId, type: role }; - continue; - } - if (q.entry) { + // ── 第一庫:零件 catalog(記憶體)──────────────────────────────────────── + const hit = byId.get(componentId); + if (hit) { nodeResults[nodeName] = { status: 'found', componentId, type: role, source: 'component', - input_schema: q.entry.input_schema, - success_rate: q.entry.success_rate, - stability: q.entry.stability, + input_schema: hit.input_schema, + success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined, + stability: typeof hit.stability === 'string' ? hit.stability : undefined, }; continue; } - // ── 第二庫:recipe 庫(task 3.6——「說不出某 recipe 沒有」的病灶就在漏了這步)── - const recipe = env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null; + // ── 第二庫:recipe 清單(記憶體;canonical_id 精確比對)────────────────── + const recipe = recipes.find(r => r.canonical_id === componentId); if (recipe) { nodeResults[nodeName] = { status: 'found', @@ -147,11 +196,9 @@ export async function searchNodes( continue; } - // ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選 ──────────── - const [similarComponents, similarRecipes] = await Promise.all([ - searchSimilarComponents(registryBase, nodeName), - env?.RECIPES ? searchSimilarRecipes(env.RECIPES, nodeName) : Promise.resolve([]), - ]); + // ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選(全記憶體)── + const similarComponents = similarFromCatalog(catalog.entries, nodeName); + const similarRecipes = similarFromRecipes(recipes, nodeName); nodeResults[nodeName] = { status: 'not_found', @@ -167,6 +214,126 @@ export async function searchNodes( return { nodeResults, missingNodes }; } +// ── t158 批次化 helpers ──────────────────────────────────────────────────────── + +type CatalogFullRecord = { + canonical_id: string; + display_name?: string; + description?: string; + aliases?: string[]; + tags?: string[]; + score?: number; + input_schema?: unknown; + success_rate?: number; + stability?: string; +}; + +type CatalogFetch = { status: 'ok' | 'no_endpoint' | 'unreachable'; entries: CatalogFullRecord[] }; + +/** 一次抓 registry 全目錄。404=舊版 registry 沒這端點 → 呼叫端退回逐顆查。 */ +async function fetchCatalog(registryBase: string): Promise { + try { + const res = await fetch(`${registryBase}/components/catalog`, { signal: AbortSignal.timeout(10000) }); + if (res.status === 404) return { status: 'no_endpoint', entries: [] }; + if (!res.ok) return { status: 'unreachable', entries: [] }; + const body = (await res.json()) as { data?: { components?: CatalogFullRecord[] } }; + return { status: 'ok', entries: body.data?.components ?? [] }; + } catch { + return { status: 'unreachable', entries: [] }; + } +} + +/** 一次抓 recipe 全清單(本部署 recipe 數量小;exact 與相似度共用同一份)。 */ +async function listAllRecipes(kv: KVNamespace): Promise { + try { + const list = await kv.list({ prefix: 'recipe:' }); + return (await Promise.all( + list.keys.map(k => kv.get(k.name, 'json') as Promise), + )).filter(Boolean) as RecipeDefinition[]; + } catch { + return []; + } +} + +/** 相似零件(記憶體版):全名 substring 優先,否則斷詞計數 top3——判準與舊 HTTP 版一致。 */ +function similarFromCatalog(entries: CatalogFullRecord[], nodeName: string): string[] { + const searchableOf = (e: CatalogFullRecord) => + [e.canonical_id, e.display_name ?? '', e.description ?? '', ...(e.aliases ?? []), ...(e.tags ?? [])] + .join(' ').toLowerCase(); + const full = nodeName.toLowerCase(); + const direct = entries.filter(e => searchableOf(e).includes(full)).map(e => e.canonical_id); + if (direct.length > 0) return [...new Set(direct)].slice(0, 3); + + const tokens = extractTokens(nodeName); + if (tokens.length === 0) return []; + const count = new Map(); + for (const e of entries) { + const hay = searchableOf(e); + const hits = tokens.filter(t => hay.includes(t)).length; + if (hits > 0) count.set(e.canonical_id, Math.max(count.get(e.canonical_id) ?? 0, hits)); + } + return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id); +} + +/** 相似 recipe(記憶體版;判準沿用 searchSimilarRecipes)。 */ +function similarFromRecipes(recipes: RecipeDefinition[], nodeName: string): string[] { + const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)]; + const seen = new Set(); + const matched: string[] = []; + for (const r of recipes) { + if (seen.has(r.canonical_id)) continue; + const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase(); + if (tokens.some(t => hay.includes(t))) { + seen.add(r.canonical_id); + matched.push(r.canonical_id); + } + } + return matched.slice(0, 3); +} + +/** 舊 registry(無 /catalog 端點)的相容路徑:維持逐顆查語義。 */ +async function legacyPerNodeLookup( + registryBase: string, + componentId: string, + nodeName: string, + role: NodeRole, + env: SearchNodesEnv | undefined, + recipes: RecipeDefinition[], +): Promise<{ info: NodeInfo; missing: boolean }> { + const q = await fetchComponent(registryBase, componentId); + if (!q.ok) return { info: { status: 'unknown', componentId, type: role }, missing: false }; + if (q.entry) { + return { + info: { + status: 'found', componentId, type: role, source: 'component', + input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability, + }, + missing: false, + }; + } + const recipe = recipes.find(r => r.canonical_id === componentId) + ?? (env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null); + if (recipe) { + return { + info: { + status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe', + description: recipe.description, endpoint: recipe.endpoint, + }, + missing: false, + }; + } + const similarComponents = await searchSimilarComponents(registryBase, nodeName); + const similarRecipes = similarFromRecipes(recipes, nodeName); + return { + info: { + status: 'not_found', componentId, type: role, suggestion: buildSuggestion(componentId), + ...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}), + ...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}), + }, + missing: true, + }; +} + // ── 缺件分型(task 3.7)──────────────────────────────────────────────────────── // // 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測): diff --git a/cypher-executor/src/routes/cypher.ts b/cypher-executor/src/routes/cypher.ts index 0b65203..3730c8a 100644 --- a/cypher-executor/src/routes/cypher.ts +++ b/cypher-executor/src/routes/cypher.ts @@ -6,19 +6,23 @@ export const cypherRouter = new Hono<{ Bindings: Bindings }>(); // POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式) cypherRouter.post('/cypher/search', async (c) => { - const body = await c.req.json() as { triplets?: unknown }; + const body = await c.req.json() as { triplets?: unknown; mode?: unknown }; const rawTriplets = body?.triplets; if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) { return c.json({ error: 'triplets 必須為非空字串陣列' }, 400); } + // t158「部署≠發現」:mode=compile=純編圖(安裝器/acr push 的複製路徑,零存在性查詢); + // 預設 discover=誠實查詢(AI 問「有沒有」的既有契約,not_found+指路照舊)。 + const mode = body?.mode === 'compile' ? 'compile' : 'discover'; + try { const now = new Date(); const timestamp = now.toISOString(); const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; - const result = await handleCypherSearch(rawTriplets, c.env); + const result = await handleCypherSearch(rawTriplets, c.env, mode); const response = { version: versionId, diff --git a/registry/src/actions/queryComponents.ts b/registry/src/actions/queryComponents.ts index b6dbdfb..b0f4834 100644 --- a/registry/src/actions/queryComponents.ts +++ b/registry/src/actions/queryComponents.ts @@ -145,7 +145,7 @@ function computeScore(v: Record): number { return successRate * speedScore * Math.log(callCount + 2); } -function toComponentRecord(v: Record): ComponentRecord { +export function toComponentRecord(v: Record): ComponentRecord { return { component_hash_id: String(v.component_hash_id ?? ''), canonical_id: String(v.canonical_id ?? ''), diff --git a/registry/src/routes/query.ts b/registry/src/routes/query.ts index 7cb628b..81103fd 100644 --- a/registry/src/routes/query.ts +++ b/registry/src/routes/query.ts @@ -5,10 +5,34 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; -import { getComponent, getComponentVersions, searchComponents } from '../actions/queryComponents'; +import { getComponent, getComponentVersions, searchComponents, toComponentRecord } from '../actions/queryComponents'; +import type { ComponentRecord } from '../actions/queryComponents'; const app = new Hono<{ Bindings: Bindings }>(); +// 全清單(t158 批次化):/cypher/search discover 一次抓走整份目錄, +// 節點存在判定+相似度全在 cypher 記憶體內比對——取代「每個 missing 節點 +// 各打 1+8 次查詢」的疊爆模式(冷實例 8 節點實測 25.7s 的病根)。 +// 也補上 CP2-B 記載的「registry 沒有列表端點」缺口。 +// 必須在 /:id 之前,避免 "catalog" 被當作 id。 +app.get('/catalog', async c => { + const list = await c.env.SUBMISSIONS_KV.list({ prefix: 'comp:' }); + const seen = new Set(); + const components: ComponentRecord[] = []; + for (const key of list.keys) { + const raw = await c.env.SUBMISSIONS_KV.get(key.name); + if (!raw) continue; + let v: Record; + try { v = JSON.parse(raw) as Record; } catch { continue; } + if (v.status === 'tombstone' || v.visibility !== 'public') continue; + const dedup = `${String(v.component_hash_id ?? '')}:${String(v.version ?? '')}`; + if (seen.has(dedup)) continue; + seen.add(dedup); + components.push(toComponentRecord(v)); + } + return c.json({ success: true, data: { components, count: components.length } }); +}); + // 語意搜尋(必須在 /:id 之前,避免 "search" 被當作 id) app.get('/search', async c => { const q = c.req.query('q');