t158 迴歸修復「部署≠發現」:複製路徑回毫秒級純編圖,誠實化只留在 discover

leo 07-31 定調:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證,難怪這麼慢。
就算是我自己寫了錯的工作流,也可以跑跑看,如果錯誤就修改,
沒有說有錯誤還要一個個驗證這回事。」

定性=迴歸非新設計:部署路徑純複製是既有設計(arcrun-rag installer/src/index.js:13
「workflows.json 是既有 workflows/*.local.yaml 的搬運(打包期抽 flow/config)」+
arcrun-rag wiki「workflow 打包期預編成 workflows.json(worker 免帶 parser)」)。
5cadc60 起誠實化漏進 /cypher/search ⇒ 複製路徑也逐節點跑兩庫查詢+相似搜尋
(每 missing 節點 1+9 次 HTTP+recipe KV 掃)⇒ 冷實例 8 節點實測 25.7s、
安裝器 15s timeout 必炸(leo stage 實走 rag_takedown_direct aborted)。

改動:
- /cypher/search 加 mode:compile=純編圖零查詢(安裝器/acr push 複製路徑);
  discover=誠實查詢預設(AI 問「有沒有」的既有契約,not_found+分型指路全保留)
- /cypher/execute 一律 compile(存在性由 component-loader 執行時決定=原權威)
- compile 的節點 status 標 unchecked(誠實「沒查」,不回假 found)
- discover 批次化:registry 新增 GET /components/catalog(一次回全目錄含
  input_schema,補 CP2-B「沒有列表端點」缺口)+recipe 清單一次抓,
  存在判定與相似度全記憶體比對;舊 registry 無 catalog 端點 → 退回逐顆(相容);
  registry 整個查不通 → unknown 照舊(不誤判 not_found)
- cli push 帶 mode:compile+拔 missing 擋(push 不看 missing;要問有沒有走 validate)

驗(本地 wrangler dev 誠實環境,registry 種 20 合約):
- compile 編圖:graph_neighbors 39ms/rag_chat(11節點) 3ms/rag_ingest_card 2ms/
  rag_takedown_direct(8節點) 3ms——回迴歸前毫秒級
- 安裝器 pushWorkflowTo(mode:compile)4/4 ok(44/7/7/5ms)
- 故意引用不存在零件的 workflow:部署 ok=true,trigger 執行時誠實報
  「找不到零件…」+可用零件清單(部署≠發現實證)
- discover 契約:邏輯名 missing=2+not_found+suggestion(21ms);
  頂層 verify.sh 01 組 5/5+03 組 4/4 全綠
- cypher+registry tsc 全綠;vitest 9 failed/179 passed=5cadc60 基線完全相同

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-31 13:31:33 +08:00
parent 7e87a3336b
commit 7e631a890a
6 changed files with 236 additions and 33 deletions
+11 -3
View File
@@ -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<string, unknown>; 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);
//
// t15807-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);
+188 -21
View File
@@ -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[];
};
/**
* t158leo 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<string, Record<string, unknown>>,
env?: SearchNodesEnv,
mode: SearchMode = 'discover',
): Promise<SearchResult> {
const nodeResults: Record<string, NodeInfo> = {};
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 次 catalog1 次 recipe 清單。
const catalog = registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] };
const recipes = env?.RECIPES ? await listAllRecipes(env.RECIPES) : [];
const byId = new Map<string, CatalogFullRecord>();
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<CatalogFetch> {
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<RecipeDefinition[]> {
try {
const list = await kv.list({ prefix: 'recipe:' });
return (await Promise.all(
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
)).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<string, number>();
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<string>();
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——查詢端點要快、要可預測):