merge main:把 t176(拔雲端 LLM 下發)與 workers_ai_chat 種子併進 CIS 分支

出貨前發現兩條分支各有一半:
  main                  → t176 拔掉雲端下發 extractor/刪 admin/extractor/workers_ai_chat 種子
  fix/cis-round3-portal → t181 新萃取端點 /portal/daemon/extract、CIS 視覺
**要合起來才是完整的出貨內容**(實測:合併前 bundle 仍含 admin/extractor ×2)。

原始碼三檔全自動合併;只有 portal-admin.test.ts 衝突——
HEAD 側是**過時的 t131/t122 測試**(測 main 已刪的端點,留著必紅),
main 側是刪除。解法:接受刪除、保留我這側的 t181 守衛。

測試 31 passed,唯一 failed 是基準線既有的 GET /portal 靜態資源案。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-04 15:33:43 +08:00
95 changed files with 4812 additions and 8671 deletions
+21 -29
View File
@@ -3,24 +3,33 @@ import { ExecutionError, WorkflowPaused } from '../types';
import { GraphExecutor } from '../graph-executor';
import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
import { recordComponentStats } from './execution-evaluator';
import { parseTriplets } from './triplet-parser';
import { searchNodes } from './search-nodes';
import { searchNodes, type SearchMode, type SearchTarget } from './search-nodes';
import { buildExecutionGraph } from './graph-builder';
export async function handleCypherSearch(
triplets: unknown[],
env: Bindings,
mode: SearchMode = 'discover',
target?: SearchTarget,
): Promise<{ nodes: Record<string, unknown>; cypher: unknown; missing: string[] }> {
const parsed = parseTriplets(triplets);
if (!parsed) {
throw new Error('無法解析任何節點');
}
const { nodeResults } = searchNodes(parsed);
// 2026-07-30:查 registry 判真實存在(workflow-discovery)。
// `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。
//
// t15807-31 迴歸修復,leo:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證」):
// 誠實化只屬於 **discover**AI 問「有沒有」);**compile**(部署/推送的複製路徑)
// 純編圖零查詢——那本來就是既有設計(workflows.json=打包期預編的搬運),
// 5cadc60 起誠實化漏進複製路徑=迴歸(冷實例 8 節點 25.7s、安裝器 timeout 炸)。
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env, mode, target);
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes };
}
export async function handleCypherExecute(
@@ -50,7 +59,9 @@ export async function handleCypherExecute(
throw new Error('無法解析任何節點');
}
const { nodeResults } = searchNodes(parsed, config);
// 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);
@@ -66,18 +77,8 @@ export async function handleCypherExecute(
const result = await executor.execute(parseResult.data as ExecutionGraph, context ?? {}, env.EXEC_CONTEXT);
const duration_ms = Date.now() - start;
// 非同步記錄統計(Phase 7 補充 analytics,目前為 no-op
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
const runId = `${graphId}-${Date.now()}`;
waitUntil(writeEvaluation(env, {
run_id: runId,
workflow_id: graphId,
component_id: componentId,
verdict: 'success',
duration_ms,
evaluated_at: Date.now(),
}));
waitUntil(updateComponentStats(env, componentId, 'success', duration_ms));
// 非同步回寫每顆零件的執行統計(design.md「執行統計設計」;fire-and-forget 不阻擋回應
waitUntil(recordComponentStats(env, graph.nodes, result.trace));
return { success: true, data: result.data, trace: result.trace, duration_ms, graph };
} catch (err) {
@@ -99,19 +100,10 @@ export async function handleCypherExecute(
}
const errMsg = err instanceof Error ? err.message : String(err);
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
const runId = `${graphId}-${Date.now()}`;
waitUntil(writeEvaluation(env, {
run_id: runId,
workflow_id: graphId,
component_id: componentId,
verdict: 'failed',
duration_ms,
error_message: errMsg.slice(0, 200),
evaluated_at: Date.now(),
}));
waitUntil(updateComponentStats(env, componentId, 'failed', duration_ms));
// 失敗路徑同樣回寫每顆零件統計:ExecutionError 帶完整 trace(失敗節點有 error、
// 之前成功的節點照記成功);非 ExecutionError 無 trace 可歸因 → 不記(誠實:不瞎猜)。
if (err instanceof ExecutionError) {
waitUntil(recordComponentStats(env, graph.nodes, err.trace));
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
status: s.error ? 'failed' : 'success',
@@ -1,36 +1,96 @@
/**
* Execution Analytics — 零件執行後的統計記錄
* Execution Analytics — 零件執行後的統計回寫
*
* Phase 1 MVPstub(不寫入任何外部服務)
* Phase 7 補充:fire-and-forget POST 至 registry.arcrun.dev/analytics/record
* SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
* 執行完成處(cypher-handlers / webhook-handlers 收尾)對本次用到的**每顆零件**
* fire-and-forget POST registry `/analytics/record`——統計失敗不影響執行、不增加同步延遲
* (呼叫端一律用 waitUntil 包,仿 recordRecipeStats / recordTelemetry 既有慣例)。
*
* 每顆零件的成敗判定來源=執行 trace(per-node):
* - trace step 有 `error` → 失敗(runner throw
* - output 是物件且 `success === false` → 失敗(makeHttpRunner 對非 2xx 不 throw,回這種)
* - 其餘 → 成功
* FOREACH 重複執行同一節點 → trace 有幾筆就記幾次(每次真實執行都算一次樣本)。
*/
import type { Bindings } from '../types';
import type { GraphNode, TraceStep } from '../types';
import { wasmWorkerUrl } from '../lib/component-loader';
export interface EvaluationRecord {
run_id: string;
workflow_id: string;
/** 本模組需要的環境子集(傳整份 Bindings 也相容,仿 SearchNodesEnv 慣例)。 */
export type AnalyticsEnv = {
WORKER_SUBDOMAIN?: string;
/** registry 位置覆蓋(可選;本地 wrangler dev / self-hosted 用)。未設 → wasmWorkerUrl('registry', WORKER_SUBDOMAIN)。 */
REGISTRY_BASE_URL?: string;
};
export interface ComponentVerdict {
component_id: string;
verdict: 'success' | 'failed' | 'timeout';
success: boolean;
duration_ms: number;
error_message?: string;
evaluated_at: number;
}
/** 記錄執行結果(MVPno-opPhase 7 補充 analytics*/
export async function writeEvaluation(
_env: Bindings,
_record: EvaluationRecord,
): Promise<void> {
// Phase 7: POST to registry.arcrun.dev/analytics/record
/** 從執行 trace 導出每顆零件的成敗(只算 type=Component 且有 componentId 的節點)。 */
export function componentVerdictsFromTrace(
nodes: GraphNode[],
trace: TraceStep[],
): ComponentVerdict[] {
const componentByNodeId = new Map<string, string>();
for (const n of nodes) {
if (n.type === 'Component' && n.componentId) componentByNodeId.set(n.id, n.componentId);
}
const verdicts: ComponentVerdict[] = [];
for (const step of trace) {
const componentId = componentByNodeId.get(step.nodeId);
if (!componentId) continue;
const out = step.output;
const outputSaysFailed =
typeof out === 'object' && out !== null && !Array.isArray(out) &&
(out as Record<string, unknown>).success === false;
verdicts.push({
component_id: componentId,
success: !step.error && !outputSaysFailed,
duration_ms: Math.max(0, Number(step.duration_ms) || 0),
});
}
return verdicts;
}
/** 更新零件統計(MVPno-opPhase 7 補充)*/
export async function updateComponentStats(
_env: Bindings,
_componentId: string,
_verdict: 'success' | 'failed' | 'timeout',
_durationMs: number,
/**
* 對本次執行用到的每顆零件回寫統計到 registrydesign.md「Analytics Record」)。
* 永不 throw;呼叫端用 waitUntil 包,不阻擋主流程。
*/
export async function recordComponentStats(
env: AnalyticsEnv,
nodes: GraphNode[],
trace: TraceStep[],
): Promise<void> {
// Phase 7: update ANALYTICS_KV via registry worker
try {
const base = (
env.REGISTRY_BASE_URL ??
(env.WORKER_SUBDOMAIN ? wasmWorkerUrl('registry', env.WORKER_SUBDOMAIN) : undefined)
)?.replace(/\/$/, '');
if (!base) return;
const verdicts = componentVerdictsFromTrace(nodes, trace);
if (verdicts.length === 0) return;
await Promise.all(
verdicts.map(v =>
fetch(`${base}/analytics/record`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
canonical_id: v.component_id,
success: v.success,
duration_ms: v.duration_ms,
}),
}).catch(() => undefined), // 統計失敗不影響執行
),
);
} catch {
// fire-and-forget:不拋錯,不影響主流程
}
}
+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;
});
+698 -17
View File
@@ -1,40 +1,721 @@
import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole } from './triplet-parser';
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`:欄位契約以頂層機械考
* `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。
*/
/** `unchecked`compile 模式的誠實標記:沒查、不知道有沒有(≠found 的假信號)。 */
/** `resolved`=意圖節點被媒合替換成真實零件/recipe(步驟 4;≠字面 exact 的 found)。 */
export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked' | 'resolved';
/**
* 意圖節點 → 真實零件/recipe 的替換結果(CP 步驟 4workflow-discovery 3.x 搜尋端延伸)。
* 目的(CP 原文):AI 只要填 payload——系統把「傳到 telegram」翻成
* `http_request`recipe `telegram_send`,並明說缺什麼。
*/
export type NodeSubstitution = {
/** 原始意圖節點名(替換前)。 */
from: string;
/**
* 執行底層零件:component 替換=該零件本身;
* recipe 替換=`http_request`recipe 是 http_request+參數模板的具名封裝)。
*/
componentId: string;
/** recipe 替換時的 canonical_id——workflow config 寫 `component: <此值>` 即可直接用。 */
recipe?: string;
/** 為什麼這樣換(簡單可解釋規則的命中說明,不接 LLM)。 */
reason: string;
};
/**
* 指定搜尋對象(leo 07-31:「難道我不能指定要搜尋工作流或節點或 recipe 嗎?」)。
* 不給=現行混搜兩庫+意圖替換;`component`=只查零件 registry`recipe`=只查 recipe 庫。
* `workflow` 不進本函式——workflow 搜尋是名字搜尋(route 層走既有 /workflows/search 機制)。
*/
export type SearchTarget = 'component' | 'recipe';
export type NodeInfo = {
status: NodeStatus;
componentId?: string;
type: NodeRole;
/** found 時標來源庫:零件 registrycomponent)或 recipe 庫(recipe)。 */
source?: 'component' | 'recipe';
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
input_schema?: unknown;
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
success_rate?: number;
stability?: string;
/** 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 組契約。
*/
suggestion?: string;
/** not_found 時的相近零件候選(自然語言節點名 → 既有零件的媒合)。 */
similar_components?: string[];
/** not_found 時的相近 recipe 候選。 */
similar_recipes?: string[];
/** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */
substitution?: NodeSubstitution;
/**
* 分支用法自我說明(3.11):只有「本身會分岔」的零件才有
* if_controlswitchtry_catch)。
* 存在的理由=走 n8n 式「逐顆查、自己組圖」的 AI,光看 input_schema 不知道
* 「判斷完之後兩條路怎麼接」⇒ 會回頭寫 code。判準:只看這一顆的回應就知道怎麼接下一步。
*/
branch_hint?: BranchHint;
};
export type SearchResult = {
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
nodeResults: Record<string, NodeInfo>;
missingNodes: string[];
};
/**
* 對所有節點進行解析,確認每個節點對應的零件 ID。
*
* 注意:此步驟只做靜態解析,不做遠端查找。
* 零件是否真的存在由 component-loader 在執行時決定(Service Binding / KV / URL)。
*
* 優先序:
* 1. Input/Output 角色:自動標記,componentId = 小寫節點名稱
* 2. config[nodeName].component 已指定:使用 config 提供的 componentId
* 3. 其他:componentId = 節點名稱(交給 component-loader 在執行時解析)
* t158leo 07-31 定調「部署≠發現」):
* 「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證,難怪這麼慢。
* 就算是我自己寫了錯的工作流,也可以跑跑看,如果錯誤就修改,
* 沒有說有錯誤還要一個個驗證這回事。」
* - `compile`=純編圖:**零外部查詢**(不打 registry、不掃 recipe、不算相似度、不擋 missing)。
* 部署/推送/執行路徑用——寫錯的 workflow 照樣部署,錯在執行時現形。
* - `discover`=誠實查詢(預設,`/cypher/search` 的既有契約):AI 問「有沒有」時用,
* not_found+分型指路+相似候選全保留。
*/
export function searchNodes(
export type SearchMode = 'discover' | 'compile';
/** searchNodes 需要的環境子集(cypher-handlers 傳整份 Bindings 進來也相容)。 */
export type SearchNodesEnv = {
WORKER_SUBDOMAIN?: string;
/**
* registry 位置覆蓋(可選,非機密)。未設 → 用 wasmWorkerUrl('registry', WORKER_SUBDOMAIN)
* 現算(比照 KBDB_GRAPH_URL 慣例)。本地 wrangler dev / self-hosted 把 registry 掛別處時用。
*/
REGISTRY_BASE_URL?: string;
/** recipe 庫(本 worker 自己的 KV;task 3.6 兩庫都查的第二庫)。 */
RECIPES?: KVNamespace;
};
/**
* 對所有節點進行解析,確認每個節點對應的零件/recipe 是否**真的存在**。
*
* ⚠️ 2026-07-30 改為會查 registryworkflow-discovery task 3.x);
* 2026-07-31 再加 recipe 庫查詢+缺件分型指路(task 3.6/3.7)。
*
* 改之前的行為(病灶):無條件回 `status: 'found'`、`missingNodes` 永遠是空陣列——
* 實測「完全不存在的東西xyz」也回 found。
*
* 為什麼這是嚴重問題(leo 2026-07-30 定性「腹語術」):
* AI 寫意圖 → 查詢回「都 found」(假信號)→ 實際零件不存在
* → 部署/執行才發現 → 最快的修法是改寫成 `code` 節點自己寫 JS
* → 於是正式 workflow 只用 2 個零件、8 個 code 節點含 if×61
* ⇒ 「零件被測過 1000 次所以 AI 只要填 payload」的價值完全落空。
*
* 設計基調(leo 2026-07-31 二次定調):**回覆的重點是「缺哪些」不是「有哪些」**——
* 有的照常編圖不必報告;缺的要兩庫(零件 registry+recipe 庫)都搜過後點名+給正確指示:
* 缺外部 API → 自己寫 recipeskill `write_recipe`);
* 缺計算原語 → 投稿零件 PRskill `add_new_wasm_component`)。
*
* 誠實限制:查不到 registry(未部署/網路失敗)時回 `'unknown'` 而不是 `'not_found'`——
* 不能因為查詢失敗就宣告零件不存在(那會讓 AI 誤判而重寫 code,正是要避免的事)。
*/
export async function searchNodes(
parsed: ParsedTriplets,
config?: Record<string, Record<string, unknown>>,
): SearchResult {
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
env?: SearchNodesEnv,
mode: SearchMode = 'discover',
target?: SearchTarget,
): 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);
// target 限庫(leo 07-31):component=只查零件 registryrecipe=只查 recipe 庫。
// 不給=混搜兩庫(既有行為)。
const wantComponents = target !== 'recipe';
const wantRecipes = target !== 'component';
// ── discover 批次化(t158):兩庫各抓**一次**,之後全在記憶體內比對。────────
// 病史(07-31 stage 實測):舊版對每個 missing 節點各打「1 次逐顆查+最多 9 次
// 相似搜尋+一輪 recipe KV 掃描」⇒ 冷實例 8 節點 /cypher/search 25.7s
// 安裝器 15s timeout 必炸。批次化後每 request 固定 1 次 catalog1 次 recipe 清單。
// 步驟 4 的意圖替換也在**同一份清單**上做——不加任何新 round-trip。
const catalog = !wantComponents
? { status: 'ok' as const, entries: [] } // target=reciperegistry 不參與,不因此回 unknown
: registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] };
const recipes = wantRecipes && 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);
if (role === 'Input' || role === 'Output') {
// 只有**字面上的虛擬 IO 名**input/trigger/…/output/done)才免查——
// 位置上是頭節點但名字是真零件(`aes_encrypt >> … >> code` 的頭,role 也是 Input
// 仍要照常查兩庫,否則缺件被角色掩蓋、又回到「假 found」。
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;
const componentId = configComponent ?? nodeName;
nodeResults[nodeName] = { status: 'found', componentId, type: role };
// config 明確給了 component(多半是安裝器代入的 worker URL 或既有 workflow
// → 不判 not_found。這條路徑的存在性由 component-loader 在執行時決定(原行為)。
if (configComponent) {
nodeResults[nodeName] = { status: 'found', componentId, type: role };
continue;
}
// 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;
}
// ── 第一庫:零件 catalog(記憶體)────────────────────────────────────────
const hit = byId.get(componentId);
if (hit) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
source: 'component',
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;
}
// ── 第二庫:recipe 清單(記憶體;canonical_id 精確比對)──────────────────
const recipe = recipes.find(r => r.canonical_id === componentId);
if (recipe) {
nodeResults[nodeName] = {
status: 'found',
componentId: recipe.canonical_id,
type: role,
source: 'recipe',
description: recipe.description,
endpoint: recipe.endpoint,
payload_hint: buildPayloadHint(recipe),
};
continue;
}
// ── 步驟 4:意圖節點 → 真實零件/recipe 替換(同一份清單、全記憶體)────────
// 字面 exact 兩庫都落空的自然語言節點(例「傳到 telegram」「判斷有沒有新資料」),
// 先試保守的替換規則;換得到=resolved(回應直接可組 workflow),換不到才 not_found。
const substituted = trySubstitution(nodeName, catalog.entries, recipes);
if (substituted) {
nodeResults[nodeName] = { ...substituted, type: role };
continue;
}
// ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選(全記憶體)──
const similarComponents = similarFromCatalog(catalog.entries, nodeName);
const similarRecipes = similarFromRecipes(recipes, nodeName);
nodeResults[nodeName] = {
status: 'not_found',
componentId,
type: role,
suggestion: buildSuggestion(componentId),
...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}),
...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}),
};
missingNodes.push(nodeName);
}
return { nodeResults, missingNodes: [] };
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 與相似度共用同一份)。
* export 給 target=recipe 的名字搜尋(actions/target-search.ts)共用同一份讀法。 */
export 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,
branch_hint: branchHintFor(componentId),
},
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,
payload_hint: buildPayloadHint(recipe),
},
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,
};
}
// ── 步驟 4:意圖節點 → 真實零件/recipe 替換 ────────────────────────────────────
//
// 目的(CP arcrun-usable 步驟 4):AI 只要填 payload——系統把「傳到 telegram」翻成
// `http_request`recipe `telegram_send`。媒合在「一次抓好的兩庫清單」記憶體內做,
// 零新增 round-trip;規則沿用 task 3.7 的服務詞判型+既有斷詞媒合(extractTokens),
// 刻意簡單可解釋、不接 LLM。
//
// 兩條規則(保守——換錯比不換更糟,寧可 not_found+候選讓 AI 自己選):
// A) 服務詞規則(recipe 路):節點名含 SERVICE_HINTS 服務詞 → 名字裡**全部**服務詞
// 都命中同一個 recipe、且該 recipe **唯一**才替換。
// 例「傳到 telegram」:服務詞 [telegram] → 唯一命中 telegram_send ⇒ 換。
// 反例「google_slides_create」:服務詞 [google, slides] → google_sheets_* 只中
// google 不中 slides ⇒ 不換(照 3.7 指去寫 recipe)。
// 有服務詞的節點**不落入規則 B**——外部服務就該是 recipe,不硬配零件
// (否則「google_slides」會被 display_name 含 Google 的零件誤吃)。
// B) 強欄位規則(零件路):斷詞後只算**強欄位**canonical_iddisplay_namealiases
// 命中為主:分數=強命中×10+弱命中(descriptiontags)×1
// 需「至少一個強命中」且「分數唯一最高」才替換。
// 例「判斷有沒有新資料」:2-gram「判斷」命中 if_control display_name「條件判斷」
// (強 10 分),try_catch 只在 description 中「判斷」(弱 1 分)⇒ 唯一最高 ⇒ 換。
// 反例「aes_encrypt」:無任何強命中 ⇒ 不換(照 3.7 指去投零件 PR)。
type SubstitutionHit = Pick<
NodeInfo,
'status' | 'componentId' | 'source' | 'substitution' |
'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' | 'branch_hint'
>;
function trySubstitution(
nodeName: string,
catalogEntries: CatalogFullRecord[],
recipes: RecipeDefinition[],
): SubstitutionHit | null {
const lower = nodeName.toLowerCase();
const serviceHits = SERVICE_HINTS.filter(w => lower.includes(w));
// 規則 A:服務詞 → recipe(全部服務詞命中+唯一)
if (serviceHits.length > 0) {
const matched = new Map<string, RecipeDefinition>();
for (const r of recipes) {
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
if (serviceHits.every(h => hay.includes(h))) matched.set(r.canonical_id, r);
}
if (matched.size !== 1) return null; // 0=真缺件走 not_found;≥2=歧義,候選留給 similar_recipes
const recipe = [...matched.values()][0];
return {
status: 'resolved',
componentId: recipe.canonical_id,
source: 'recipe',
description: recipe.description,
endpoint: recipe.endpoint,
substitution: {
from: nodeName,
componentId: 'http_request', // recipehttp_request+參數模板的具名封裝
recipe: recipe.canonical_id,
reason:
`服務詞「${serviceHits.join('、')}」唯一命中 recipe「${recipe.canonical_id}」;` +
`workflow config 寫 component: ${recipe.canonical_id}(底層零件=http_request),只需填 payload`,
},
};
}
// 規則 B:強欄位斷詞媒合 → 零件(至少一強命中+分數唯一最高)
const tokens = extractTokens(nodeName);
if (tokens.length === 0) return null;
type Scored = { entry: CatalogFullRecord; score: number; strongHits: string[] };
const byCanonical = new Map<string, Scored>();
for (const e of catalogEntries) {
const strongHay = [e.canonical_id, e.display_name ?? '', ...(e.aliases ?? [])].join(' ').toLowerCase();
const weakHay = [e.description ?? '', ...(e.tags ?? [])].join(' ').toLowerCase();
const strongHits = tokens.filter(t => strongHay.includes(t));
const weakCount = tokens.filter(t => weakHay.includes(t)).length;
const score = strongHits.length * 10 + weakCount;
if (score === 0) continue;
const prev = byCanonical.get(e.canonical_id);
if (!prev || score > prev.score) byCanonical.set(e.canonical_id, { entry: e, score, strongHits });
}
const ranked = [...byCanonical.values()].sort((a, b) => b.score - a.score);
const top = ranked[0];
if (!top || top.strongHits.length === 0) return null; // 沒有強命中=證據不足
if (ranked[1] && ranked[1].score >= top.score) return null; // 同分歧義=不硬猜
return {
status: 'resolved',
componentId: top.entry.canonical_id,
source: 'component',
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,
reason:
`斷詞「${top.strongHits.join('、')}」命中零件「${top.entry.canonical_id}` +
`${top.entry.display_name ?? ''})強欄位且分數唯一最高;只需照 input_schema 填 payload`,
},
};
}
// ── 缺件分型(task 3.7)────────────────────────────────────────────────────────
//
// 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測):
// 1) 名字含**外部服務詞**googletelegramslack…)→「外部 API 樣貌」
// → recipe 路:recipe 是 http_request+參數模板的具名封裝,用戶自己就能寫,不用改平台。
// 2) 否則名字含**計算原語詞**encrypthashencode…)→「計算原語樣貌」
// → 零件路:純計算得進 WASM 沙箱跑,要走 GitHub PR 投稿(人 merge=人類閘門,mindset §4)。
// 3) 都不含 → 判不出型,誠實說判不出,兩條路都給(不硬猜——猜錯會把人指去錯的路)。
// 判斷順序:服務詞優先於計算詞——「google_sheets_parse」雖含 parse,本質仍是打外部 API。
const SERVICE_HINTS = [
'google', 'gmail', 'sheets', 'slides', 'gdocs', 'drive', 'calendar', 'youtube',
'slack', 'telegram', 'discord', 'line', 'whatsapp', 'twilio',
'notion', 'airtable', 'trello', 'jira', 'asana', 'linear',
'github', 'gitea', 'gitlab', 'bitbucket',
'stripe', 'paypal', 'shopify', 'hubspot', 'salesforce',
'openai', 'anthropic', 'claude', 'gemini', 'groq',
'twitter', 'facebook', 'instagram', 'linkedin', 'dropbox', 'zoom',
'sendgrid', 'mailgun', 'kbdb',
];
const COMPUTE_HINTS = [
'encrypt', 'decrypt', 'cipher', 'aes', 'rsa', 'sha', 'md5', 'hmac', 'hash',
'sign', 'verify', 'encode', 'decode', 'base64', 'hex',
'compress', 'decompress', 'zip', 'gzip',
'uuid', 'random', 'regex', 'math', 'calc',
'sort', 'dedup', 'diff', 'template', 'render', 'convert', 'transform',
'parse', 'format', 'csv', 'xml',
];
function buildSuggestion(componentId: string): string {
const lower = componentId.toLowerCase();
const serviceHit = SERVICE_HINTS.find(w => lower.includes(w));
const computeHit = COMPUTE_HINTS.find(w => lower.includes(w));
if (serviceHit) {
return (
`兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」。` +
`名字含服務詞「${serviceHit}」=外部 API 樣貌 → 沒有此 recipe,可自己寫:` +
`寫法看 skill「write_recipe」(arcrun_get_skill('write_recipe')),` +
`寫好用 acr recipe push 或 POST /recipes 裝上即可用,不用改平台。`
);
}
if (computeHit) {
return (
`兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」。` +
`名字含計算詞「${computeHit}」=計算原語樣貌 → 沒有此零件,可投稿 PR 新增 WASM component` +
`做法看 skill「add_new_wasm_component」(arcrun_get_skill('add_new_wasm_component'))。`
);
}
return (
`兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」,且名字判不出型。` +
`缺外部 API → 自己寫 recipeskill「write_recipe」);` +
`缺計算能力 → 投稿零件 PRskill「add_new_wasm_component」,component 進 WASM 沙箱)。`
);
}
/**
* 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 = {
input_schema?: unknown;
success_rate?: number;
stability?: string;
};
/**
* 查單一零件是否存在於 registry。
*
* ⚠️ 為什麼逐個查而非抓整份目錄:registry **沒有列表端點**
* (實測 `GET /components` → 404,只有 `GET /components/<id>`)。
* 這是 CP2-B 記載的缺口(「修 /components 404」)——補了列表端點後可改為抓一次。
* 現階段逐個查:節點數通常 <10,且有 5s timeout,可接受。
*
* 回傳 `ok:false` 代表「查不到 registry」,由呼叫端區分:
* 整體查不通 → `unknown`;查得通但這顆沒有 → 繼續查 recipe 庫。
*/
async function fetchComponent(
registryBase: string,
id: string,
): Promise<{ ok: boolean; entry?: CatalogEntry }> {
try {
const res = await fetch(`${registryBase}/components/${encodeURIComponent(id)}`, {
signal: AbortSignal.timeout(5000),
});
if (res.status === 404) return { ok: true }; // registry 活著,但沒這顆
if (!res.ok) return { ok: false };
const body = (await res.json()) as { success?: boolean; data?: Record<string, unknown> };
if (body.success === false) return { ok: true }; // 同上:回「零件不存在」
const d = body.data ?? (body as unknown as Record<string, unknown>);
return {
ok: true,
entry: {
input_schema: d.input_schema,
success_rate: typeof d.success_rate === 'number' ? d.success_rate : undefined,
stability: typeof d.stability === 'string' ? d.stability : undefined,
},
};
} catch {
return { ok: false };
}
}
// ── 相近候選(自然語言節點名 → 既有零件/recipe 的媒合)──────────────────────────
//
// 節點名常是自然語言(例「判斷有沒有新資料」)。leo:「AI 不用知道零件存在」——
// 所以 not_found 時要主動給相近候選,讓 AI 看回覆就知道「其實有 if_control 可用」。
// 做法:先拿全名打 registry `/components/search`;沒中再斷詞重試——
// ASCII 取 3 字以上的詞、中日韓取 2-gramregistry search 是子字串比對,整句中文必落空,
// 2-gram 才撈得到「判斷」→ if_controldisplay_name「條件判斷」)這種命中)。
function extractTokens(name: string): string[] {
const tokens: string[] = [];
const ascii = name.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
tokens.push(...ascii);
const cjkRuns = name.match(/[一-鿿]+/g) ?? [];
for (const run of cjkRuns) {
for (let i = 0; i + 2 <= run.length; i++) tokens.push(run.slice(i, i + 2));
}
return [...new Set(tokens)].slice(0, 8); // 上限 8 個 token,避免對 registry 掃太多輪
}
async function searchRegistryIds(registryBase: string, q: string): Promise<string[]> {
try {
const res = await fetch(`${registryBase}/components/search?q=${encodeURIComponent(q)}`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const body = (await res.json()) as { data?: { results?: Array<{ canonical_id?: string }> } };
return (body.data?.results ?? []).map(r => r.canonical_id).filter((s): s is string => !!s);
} catch {
return [];
}
}
async function searchSimilarComponents(registryBase: string, nodeName: string): Promise<string[]> {
// 1) 全名直接搜
const direct = await searchRegistryIds(registryBase, nodeName);
if (direct.length > 0) return direct.slice(0, 3);
// 2) 斷詞搜,依命中次數排序
const tokens = extractTokens(nodeName);
if (tokens.length === 0) return [];
const hits = await Promise.all(tokens.map(t => searchRegistryIds(registryBase, t)));
const count = new Map<string, number>();
for (const ids of hits) {
for (const id of ids) count.set(id, (count.get(id) ?? 0) + 1);
}
return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id);
}
/** recipe 庫的相近候選:KV 全列(本部署 recipe 數量小)後子字串比對。 */
async function searchSimilarRecipes(kv: KVNamespace, nodeName: string): Promise<string[]> {
try {
const list = await kv.list({ prefix: 'recipe:' });
const all = (await Promise.all(
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
)).filter(Boolean) as RecipeDefinition[];
const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)];
const seen = new Set<string>();
const matched: string[] = [];
for (const r of all) {
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);
} catch {
return [];
}
}
@@ -0,0 +1,113 @@
/**
* target-search — POST /cypher/search 的「指定搜尋對象」名字搜尋(t159)
*
* leo 07-31:「search 節點名稱和 search 工作流名稱是同一個?一個死了另一個不能動?
* 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」
*
* ⇒ discover 入口加 `target`componentrecipeworkflow)+`query`
* - target=component → 轉發 registry GET /components/searchMCP arcrun_search_components 同一條路)
* - target=recipe → 掃私庫 RECIPES KV(與 discover 混搜的第二庫**同一份讀法** listAllRecipes);
* 公庫(多作者市場)另有 /public-recipesMCP arcrun_recipe_search,回應註明
* - target=workflow → lib/workflow-search.tsGET /workflows/searchMCP arcrun_search_workflows 同一條路)
*
* 「外部 API 只有一條一致的路」:三個 target 各自對應**既有**搜尋機制,本檔只做轉接,
* 不新造第二套搜尋。flag 安全:主動 pull,無輪詢。
*/
import { wasmWorkerUrl } from '../lib/component-loader';
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
import { listAllRecipes, buildPayloadHint, type SearchNodesEnv } from './search-nodes';
import { branchHintFor } from '../lib/branch-hints';
export type TargetQueryEnv = SearchNodesEnv & {
KBDB_BASE_URL?: string;
KBDB_INTERNAL_TOKEN?: string;
};
export type TargetQueryResult =
| { ok: true; body: Record<string, unknown> }
| { ok: false; status: 400 | 401 | 502; error: string };
export async function searchByTarget(
target: 'component' | 'recipe' | 'workflow',
query: string,
env: TargetQueryEnv,
apiKey?: string,
): Promise<TargetQueryResult> {
if (target === 'component') {
const sub = env.WORKER_SUBDOMAIN;
const registryBase = env.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);
if (!registryBase) return { ok: false, status: 502, error: 'registry 位置未設定(WORKER_SUBDOMAINREGISTRY_BASE_URL 皆缺)' };
try {
const res = await fetch(
`${registryBase}/components/search?q=${encodeURIComponent(query)}`,
{ signal: AbortSignal.timeout(10000) },
);
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,
count: body.data?.count ?? 0,
},
};
} catch (e) {
return { ok: false, status: 502, error: `registry 查不通:${e instanceof Error ? e.message : String(e)}` };
}
}
if (target === 'recipe') {
if (!env.RECIPES) return { ok: false, status: 502, error: 'RECIPES KV 未綁定' };
const all = await listAllRecipes(env.RECIPES);
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;
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();
if (!hay.includes(q)) continue;
seen.add(r.canonical_id);
results.push({
canonical_id: r.canonical_id,
display_name: r.display_name,
description: r.description,
endpoint: r.endpoint,
// 3.12:逐顆查 recipe 時也要說得出「payload 怎麼填、回應怎麼取值」
payload_hint: buildPayloadHint(r),
});
}
return {
ok: true,
body: {
target,
query,
results,
count: results.length,
note: '搜的是本部署私庫(workflow 可直接 component: <canonical_id> 引用)。公庫(多作者市場)走 MCP arcrun_recipe_searchGET /public-recipes。',
},
};
}
// target === 'workflow':租戶隔離,必帶 API key(同 GET /workflows/search 的既有契約)
if (!apiKey) return { ok: false, status: 401, error: 'target=workflow 需要 X-Arcrun-API-Key headerworkflow 搜尋限本租戶)' };
const res = await fetchTenantWorkflowSearch(env, apiKey, query);
if (!res.ok) return { ok: false, status: 502, error: `workflow 搜尋失敗(KBDB HTTP ${res.status}` };
const body = (await res.json()) as Record<string, unknown>;
return { ok: true, body: { target, query, ...body } };
}
@@ -105,6 +105,17 @@ export function parseTriplets(rawTriplets: unknown[]): ParsedTriplets | null {
const INPUT_NAMES = new Set(['input', 'trigger', 'webhook', 'start']);
const OUTPUT_NAMES = new Set(['output', 'result', 'end', 'done']);
/**
* 是否為「虛擬 IO 節點名」(input/output 這類非零件的佔位節點)。
* searchNodes 用它決定存在性查詢的短路:**只有字面上是虛擬 IO 名**才免查——
* 位置上是頭節點但名字是真零件(例 `aes_encrypt >> ON_SUCCESS >> code` 的頭)
* 仍要查兩庫,否則缺件被角色掩蓋、又回到「假 found」(task 3.7 實測踩到)。
*/
export function isVirtualIoName(name: string): boolean {
const lower = name.toLowerCase();
return INPUT_NAMES.has(lower) || OUTPUT_NAMES.has(lower);
}
/** 根據節點在圖中的位置決定其 type
*
* 規則:
@@ -14,7 +14,7 @@ export async function resolveWebhookGraph(
const parsed = parseTriplets(body.triplets as unknown[]);
if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' };
const { nodeResults } = searchNodes(parsed);
const { nodeResults } = await searchNodes(parsed);
const graphId = `webhook-${Date.now()}`;
const graphName = description || `Webhook ${new Date().toISOString()}`;
@@ -4,6 +4,8 @@ import { GraphExecutor } from '../graph-executor';
import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
import { recordTelemetry } from '../lib/telemetry';
import { recordComponentStats } from './execution-evaluator';
import type { GraphNode, TraceStep } from '../types';
/**
* kbdb-base §7.1+§7.5.h:一條工作流執行結束後,把這次用到的 recipe 各記一次成功/失敗到 KBDB 市場星數。
@@ -96,6 +98,17 @@ export async function executeWebhookGraph(
// kbdb-base §7.1:整體成功 → 用到的 recipe 各記成功一次。
recordRecipeStats(env, executor.usedRecipeKeys, true, Date.now(), ctx);
// arcrun-core-mvp「執行統計設計」:對用到的每顆零件回寫執行結果(fire-and-forget)。
{
const statsPromise = recordComponentStats(
env,
(parsed.data as ExecutionGraph).nodes as GraphNode[],
result.trace as TraceStep[],
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else void statsPromise;
}
return { success: true, data: result.data, duration_ms };
} catch (err) {
const duration_ms = Date.now() - start;
@@ -117,6 +130,18 @@ export async function executeWebhookGraph(
recordRecipeStats(env, executor.usedRecipeKeys, false, Date.now(), ctx);
}
// 零件統計失敗路徑:ExecutionError 帶完整 trace(失敗節點有 error、先前成功節點照記成功);
// paused 非失敗不記;非 ExecutionError 無 trace 可歸因 → 不記。
if (!isPaused && err instanceof ExecutionError) {
const statsPromise = recordComponentStats(
env,
(parsed.data as ExecutionGraph).nodes as GraphNode[],
err.trace,
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else void statsPromise;
}
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
+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)
@@ -651,6 +682,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;
+9 -4
View File
@@ -39,10 +39,15 @@ const STATIC_ORIGINS = ['https://arcrun.dev', 'https://www.arcrun.dev'];
app.use('*', cors({
origin: (origin, c) => {
const extra = (c.env.UI_ORIGINS || '')
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
// ⚠️ 非瀏覽器請求(CLIcurlMCP)沒有 Origin 標頭 → origin 是空字串/undefined。
// 此時必須原樣放行,不能回 null——回 null 會讓 Hono cors 中介層在後續處理拋錯,
// 表現為所有 CLI 部署一律 5002026-07-21 實撞:acr push 全掛,對照組亦然)。
if (!origin) return origin;
let extra: string[] = [];
try {
extra = String((c.env as Record<string, unknown>).UI_ORIGINS || '')
.split(',').map((s: string) => s.trim()).filter(Boolean);
} catch { /* UI_ORIGINS 未設定=只用靜態白名單 */ }
return [...STATIC_ORIGINS, ...extra].includes(origin) ? origin : null;
},
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
@@ -22,13 +22,21 @@
* KBDB 改網址後同步更新此處。seed 先照現況進。
*/
import type { ResponseMap } from './recipe-payload';
export interface ApiRecipeSeed {
canonical_id: string;
display_name: string;
description?: string;
/** HTTP recipe=要打的網址;`auth: 'binding'` 型=要呼叫的資源名(如 Workers AI 的模型 id)。 */
endpoint: string;
method: string;
auth_service?: string;
// ── payload/回應/binding 三層(3.12):全選填,既有種子不帶=行為完全不變 ──
body_template?: Record<string, unknown>;
response_map?: ResponseMap;
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
binding_name?: string;
}
export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
@@ -120,4 +128,47 @@ export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
method: 'POST',
auth_service: 'line_notify',
},
// ── LLM 對話(binding=免金鑰,3.12 第四型認證的第一個真實案例)──
//
// 為什麼進種子(而非寫在某個產品的安裝器裡):「裝好之後預設有哪些 recipe」是平台能力,
// 與本檔其餘種子同理由(見檔頭)。裝完 /init/seed 就有 ⇒ **用戶不填任何金鑰就能問答**。
//
// 換模型/換供應商=**改這一筆 recipe**endpoint + body_template + response_map),
// workflow 的 ask_llm 節點不動——這正是「換源=換 recipe 不是換引擎」。
//
// 選型實測(2026-08-03,在 1.4.4 實例上跑真實長度的 RAG prompt,每個模型連跑 2 次):
// @cf/meta/llama-4-scout-17b-16e-instruct 23732173 ms ✅ 答案最完整、引用正確
// @cf/meta/llama-3.3-70b-instruct-fp8-fast 32612147 ms ✅ 可用但波動較大
// @cf/mistralai/mistral-small-3.1-24b-instruct 35603631 ms
// @cf/qwen/qwen2.5-coder-32b-instruct 35723353 ms
// @cf/openai/gpt-oss-120b 19712295 ms ❌ 回應形狀不同,response 取不到文字
// @cf/google/gemma-3-12b-it ❌ 5018 This account is not allowed to access this model
// 對照舊路徑(Gemini `gemma-4-31b-it`):同型提問 **16.87 s**,且吐整段英文思考草稿
// ⇒ 選 llama-4-scout:**快 7 倍以上,且不需要淨化思考草稿**。
{
canonical_id: 'workers_ai_chat',
display_name: 'Workers AI 對話(免金鑰)',
description:
'Cloudflare Workers AI 文字生成,走 env.AI binding ⇒ 不需要任何 API 金鑰。'
+ 'ctx 帶 prompt,回應正規化成 text(含【答】標記與前綴淨化)。'
+ '換模型=改本 recipe 的 endpointworkflow 不動。',
endpoint: '@cf/meta/llama-4-scout-17b-16e-instruct',
method: 'POST',
auth: 'binding',
binding_name: 'AI',
body_template: {
messages: [{ role: 'user', content: '{{prompt}}' }],
max_tokens: 1024,
temperature: 0.2,
},
response_map: {
// Workers AI chat 回應:{ response: "…" }(另有 OpenAI 相容的 choices,取 response 最穩)
text_path: 'response',
// 提示詞要求答案以【答】開頭;模型偶爾會在前面多帶一行 ⇒ 取最後一個標記之後
answer_marker: '【答】',
// 前綴組合順序不定,循環剝殼(規則見 recipe-payload.ts sanitize
strip_prefixes: ['*', '-', '•', '>', '#', '"', '「', '【答】', 'Answer:', 'Draft:'],
},
},
];
+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
})),
});
@@ -0,0 +1,49 @@
/**
* workflow-search — 本租戶 workflow 名字搜尋的**唯一一條路**
*
* 既有機制(workflow-discovery 3.1):轉發 KBDB /entries/search
* entry_type=workflow + owner_id=apiKey 租戶隔離;優先 semanticKBDB 未開
* Vectorize 自動降級 keyword + capability_hint)。
*
* 為什麼抽成共用(leo 07-31:「search 節點名稱和 search 工作流名稱是同一個?
* 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」+「外部 API 只有一條一致的路」鐵律):
* - GET /workflows/searchMCP arcrun_search_workflows 走的路)
* - POST /cypher/search { target: "workflow", query }discover 入口指定搜尋對象)
* 兩個入口共用本函式 ⇒ 行為必然一致,改一處兩邊同步。
*
* 已知缺口(如實透傳,不掩蓋):workflow_metadata 無 description slot——
* 無 description 的 workflow 沒有 search entry、搜不到;補救走
* POST /workflows/backfill-search-entries(有 description 的補 entry、沒有的誠實列出)。
*
* flag 安全:主動 pull,無輪詢/排程。
*/
export type WorkflowSearchEnv = {
KBDB_BASE_URL?: string;
KBDB_INTERNAL_TOKEN?: string;
};
export type WorkflowSearchMode = 'semantic' | 'keyword';
/**
* 打 KBDB /entries/search(本租戶、entry_type=workflow)。
* 回原始 Response——GET /workflows/search 直接 stream 透傳(既有行為,一字不改);
* target=workflow 的呼叫端自行 json() 解析。
*/
export async function fetchTenantWorkflowSearch(
env: WorkflowSearchEnv,
apiKey: string,
q: string,
mode: WorkflowSearchMode = 'semantic',
): Promise<Response> {
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
const params = new URLSearchParams({
q,
owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow)
entry_type: 'workflow', // base 通用 filterQ4),只回 workflow entry
mode,
});
return fetch(`${base}/entries/search?${params.toString()}`, { headers });
}
+42 -3
View File
@@ -1,16 +1,55 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers';
import { searchByTarget } from '../actions/target-search';
export const cypherRouter = new Hono<{ Bindings: Bindings }>();
const VALID_TARGETS = new Set(['component', 'recipe', 'workflow']);
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
//
// t159leo 07-31):加 `target` 指定搜尋對象(componentrecipeworkflow)+`query` 名字搜尋。
// - triplets(不給 target)=混搜兩庫+意圖節點替換(步驟 4)
// - triplets + target=component|recipe=只查該庫
// - query + target=名字搜尋,各自走**既有**機制(registry search/私庫 RECIPESworkflows/search
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; target?: unknown; query?: unknown };
const rawTriplets = body?.triplets;
// ── target 驗證(component / recipe / workflow)─────────────────────────────
const target = typeof body?.target === 'string' ? body.target : undefined;
if (target !== undefined && !VALID_TARGETS.has(target)) {
return c.json({ error: `target 只接受 componentrecipeworkflow,收到「${target}` }, 400);
}
// ── query 名字搜尋分支(需 target)──────────────────────────────────────────
const query = typeof body?.query === 'string' ? body.query.trim() : '';
if (query) {
if (!target) {
return c.json({ error: '給 query 必須同時給 targetcomponentrecipeworkflow),指明要搜哪個庫' }, 400);
}
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
const r = await searchByTarget(target as 'component' | 'recipe' | 'workflow', query, c.env, apiKey);
if (!r.ok) return c.json({ error: r.error }, r.status);
return c.json(r.body);
}
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
return c.json({ error: 'triplets 必須為非空字串陣列(或給 query + target 做名字搜尋)' }, 400);
}
// t158「部署≠發現」:mode=compile=純編圖(安裝器/acr push 的複製路徑,零存在性查詢);
// 預設 discover=誠實查詢(AI 問「有沒有」的既有契約,not_found+指路照舊)。
const mode = body?.mode === 'compile' ? 'compile' : 'discover';
// target 限庫只屬於 discovercompile=純複製,不查任何庫,target 無意義)
if (target && mode === 'compile') {
return c.json({ error: 'mode=compile(複製路徑)不查庫,不接受 target;要指定搜尋對象請用 discover(預設)' }, 400);
}
// workflow 是名字搜尋,不參與三元組編圖——請帶 query
if (target === 'workflow') {
return c.json({ error: 'target=workflow 是名字搜尋,請改帶 { target: "workflow", query: "..." }(不吃 triplets' }, 400);
}
try {
@@ -18,7 +57,7 @@ cypherRouter.post('/cypher/search', async (c) => {
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, target as 'component' | 'recipe' | undefined);
const response = {
version: versionId,
+13 -3
View File
@@ -3,9 +3,19 @@ import type { Bindings } from '../types';
export const healthRouter = new Hono<{ Bindings: Bindings }>();
healthRouter.get('/health', (c) =>
c.json({ ok: true, bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? '' })
);
// t162leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
// 但本端點過去只回 {ok:true}**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
// ⇒ 恆判 stale ⇒ 假警報永遠不消失(安裝器其實一直有注入 ARCRUN_BUNDLE_VERSION var
// 只是沒有人把它吐出來)。修=誠實回報本實例的 bundle 版本。
// 未注入(本地 dev/很舊的實例)就省略該欄——daemon 對空字串仍判 stale
// 那是**正確的**(真的是老實例,該更新)。
healthRouter.get('/health', (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
return c.json(
bundleVersion ? { ok: true, bundle_version: bundleVersion } : { ok: true },
);
});
healthRouter.get('/', (c) =>
c.json({
+7
View File
@@ -50,6 +50,13 @@ initSeedRouter.post('/init/seed', async (c) => {
endpoint: seed.endpoint,
method: (seed.method ?? 'POST').toUpperCase(),
auth_service: seed.auth_service,
// ③ payload/回應/binding 三層(3.12):不列進來的欄位會被**靜默吃掉**——
// 種子帶了 body_template/response_map/auth 卻沒進 KV,症狀是 recipe 存在但跑起來
// 「像沒設定過」,且哪裡都不會紅(08-02 manifest.daemon 欄被列舉式重建吃掉的同型)。
body_template: seed.body_template,
response_map: seed.response_map,
auth: seed.auth,
binding_name: seed.binding_name,
created_at: existing?.created_at ?? now,
updated_at: now,
};
+158 -231
View File
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential } from './credentials';
export const portalRouter = new Hono<{ Bindings: Bindings }>();
@@ -840,26 +843,9 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
}),
);
// ── t122 萃取引擎設定(daemon 萃取用;與 chat-key AI 問答金鑰獨立管理)──────────────
// KV key = {tenant}:portal:extractor_config,存在 WEBHOOKS KV(同 chat-key 手法)
// 金鑰不落 logGET 只回 has_key:bool,不回明文
// daemon 未設定時預設 gemma(封測者不會有 claude,以 gemma 為友善預設)。
interface ExtractorConfig {
engine: 'gemma' | 'claude';
gemini_api_key?: string;
llm_model?: string;
}
function extractorConfigKey(env: Bindings): string {
return `${portalTenant(env)}:portal:extractor_config`;
}
async function getExtractorConfig(env: Bindings): Promise<ExtractorConfig | null> {
const raw = await env.WEBHOOKS.get(extractorConfigKey(env), 'text');
if (!raw) return null;
try { return JSON.parse(raw) as ExtractorConfig; } catch { return null; }
}
// t176t122 的 extractor_config(雲端指定地端萃取引擎)整組移除——
// interface/KV key/讀取函式都不再需要,因為雲端已不下發、也不再有設定入口
// ⚠️ 舊實例的 KV 殘值無害:daemon 端 t176 起也不吃這個欄位了
// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)——
@@ -888,164 +874,42 @@ portalRouter.post('/portal/daemon/config', (c) =>
}
await clearLoginFail(c.env, email);
const tenant = portalTenant(c.env);
const extractorCfg = await getExtractorConfig(c.env);
const engine = extractorCfg?.engine ?? 'gemma';
// t176leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
//
// 為什麼拔掉:extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`
// 而 portalTenant 是 **worker 層級**環境變數(見本檔 43 行)=**全租戶共用一把**。
// 任一處設了 claude,所有人的 daemon 都會收到 claude;沒裝 Claude Code 的機器
// 會萃取全滅,而 portal 的 Claude 勾選框又恆為 disableddaemon 從未回報 has_claude
// ⇒ 用戶自己解不開(08-03 封測實證:雲端同步成功、金鑰有效,卻零張卡)。
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。
//
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_urlnamespacelibrary)與本 route 本身照舊,
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
const daemonCfg: Record<string, string> = {
cypher_url: new URL(c.req.url).origin,
namespace: tenant,
library: 'kb',
extractor: engine,
email,
instance_name: String(rec.values.display_name ?? ''),
};
if (engine === 'gemma' && extractorCfg?.gemini_api_key) {
daemonCfg.gemini_api_key = extractorCfg.gemini_api_key;
}
if (extractorCfg?.llm_model) daemonCfg.llm_model = extractorCfg.llm_model;
return c.json({ success: true, config: daemonCfg });
}),
);
// ── t131 合併 AI 設定(Gemini API Key 同時設 chatextractorhas_claude 由 daemon 回報)─────
// KV key = {tenant}:portal:ai_config,存在 WEBHOOKS KV
// KV key = {tenant}:portal:daemon_caps,存 daemon 回報的能力(TTL 7 天)
// t176t131 這一版 `/portal/admin/ai`POST/GET**整組移除**。兩個原因:
// ① 它是**重複註冊**——本檔後段(arcrun-rag#10 那版)另有一組同路徑 route
// Hono 先到先比 ⇒ 舊的這組一直贏,後段那組修好的「金鑰真的寫進 credentials」形同死碼
// 這正是「key 從來沒存進去」的 bug 在 merge 後仍可能復發的原因。
// ② 它把 use_claude_for_extract 同步進 extractor_config 下發給 daemon
// syncExtractorFromAiConfig),而那把 KV 是**全租戶共用**——正是 08-03 事故根因。
// 保留的是後段那組(只管 Gemini 金鑰,走 storeCredential 唯一寫入路徑,不碰 extractor)。
interface AiConfig {
gemini_api_key?: string;
use_claude_for_extract?: boolean;
}
interface DaemonCapabilities {
has_claude: boolean;
daemon_version?: string;
os?: string;
}
function aiConfigKey(env: Bindings): string { return `${portalTenant(env)}:portal:ai_config`; }
function daemonCapsKey(env: Bindings): string { return `${portalTenant(env)}:portal:daemon_caps`; }
async function getAiConfig(env: Bindings): Promise<AiConfig | null> {
const raw = await env.WEBHOOKS.get(aiConfigKey(env), 'text');
if (!raw) return null;
try { return JSON.parse(raw) as AiConfig; } catch { return null; }
}
async function getDaemonCaps(env: Bindings): Promise<DaemonCapabilities | null> {
const raw = await env.WEBHOOKS.get(daemonCapsKey(env), 'text');
if (!raw) return null;
try { return JSON.parse(raw) as DaemonCapabilities; } catch { return null; }
}
// 將 ai_config 同步回 extractor_configdaemon/config 讀 extractor_config,保持相容)。
async function syncExtractorFromAiConfig(env: Bindings, cfg: AiConfig): Promise<void> {
const exCfg: ExtractorConfig = {
engine: cfg.use_claude_for_extract ? 'claude' : 'gemma',
};
if (!cfg.use_claude_for_extract && cfg.gemini_api_key) {
exCfg.gemini_api_key = cfg.gemini_api_key;
}
await env.WEBHOOKS.put(extractorConfigKey(env), JSON.stringify(exCfg));
}
// POST /portal/admin/ai — body {gemini_api_key?, use_claude_for_extract?}t131)。
// 同時設定 AI 問答金鑰(chat)與萃取引擎(extractor)。admin 閘。
portalRouter.post('/portal/admin/ai', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as { gemini_api_key?: string; use_claude_for_extract?: boolean } | null;
const newKey = String(body?.gemini_api_key ?? '').trim();
const useClause = typeof body?.use_claude_for_extract === 'boolean' ? body.use_claude_for_extract : undefined;
// 讀現有設定做合併(留空欄位=不變更)
const existing = await getAiConfig(c.env) ?? {};
const merged: AiConfig = {
gemini_api_key: newKey || existing.gemini_api_key,
use_claude_for_extract: useClause !== undefined ? useClause : (existing.use_claude_for_extract ?? false),
};
if (!merged.gemini_api_key) return c.json({ error: '請貼上你的 Gemini API Key' }, 400);
// 更新 chatrag_chat workflow)——容忍 404workflow 未安裝時暫存,安裝後再寫入)
if (newKey) {
const tenant = portalTenant(c.env);
const kvKey = `${tenant}:wf:rag_chat`;
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
if (raw) {
try {
const record = JSON.parse(raw) as Record<string, unknown>;
const visit = (o: unknown): void => {
if (Array.isArray(o)) { o.forEach(visit); return; }
if (o && typeof o === 'object') {
const rec = o as Record<string, unknown>;
for (const k of Object.keys(rec)) {
if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = newKey; }
else visit(rec[k]);
}
}
};
visit(record['graph']);
visit(record['config']);
await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record));
} catch { /* 工作流記錄損壞時靜默略過,金鑰仍存 ai_config */ }
}
// 若 rag_chat 不存在(raw===null),跳過,等 acr init 安裝後再用舊 chat-key 端點補入
}
// 存合併設定
await c.env.WEBHOOKS.put(aiConfigKey(c.env), JSON.stringify(merged));
// 同步回 extractor_configdaemon/config 走這個)
await syncExtractorFromAiConfig(c.env, merged);
return c.json({
success: true,
has_key: true,
use_claude_for_extract: merged.use_claude_for_extract ?? false,
});
}),
);
// GET /portal/admin/ai — 回 has_key/use_claude_for_extract/claude_availablet131)。
portalRouter.get('/portal/admin/ai', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const cfg = await getAiConfig(c.env);
const caps = await getDaemonCaps(c.env);
return c.json({
success: true,
has_key: !!(cfg?.gemini_api_key),
use_claude_for_extract: cfg?.use_claude_for_extract ?? false,
claude_available: caps?.has_claude ?? false,
});
}),
);
// POST /portal/daemon/report-capabilities — body {email, password, has_claude, daemon_version?, os?}t131)。
// daemon 連線成功後回報本機能力;認證同 /portal/daemon/config(帳密)。
// ⚠️ daemon 端改動屬 arcrun-rag repo,本端只做「收端點+存 KV+供 GET /portal/admin/ai 用」。
portalRouter.post('/portal/daemon/report-capabilities', (c) =>
run(c, async () => {
const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string; has_claude?: boolean; daemon_version?: string; os?: string } | null;
const email = String(body?.email ?? '').trim().toLowerCase();
const password = String(body?.password ?? '');
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多', }, 429);
const recordId = await findUserRecordId(c.env, email);
const rec = recordId ? await getRecordById(c.env, recordId) : null;
if (!rec) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); }
if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) {
await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const caps: DaemonCapabilities = {
has_claude: body?.has_claude === true,
...(body?.daemon_version ? { daemon_version: String(body.daemon_version) } : {}),
...(body?.os ? { os: String(body.os) } : {}),
};
const TTL_7D = 7 * 24 * 60 * 60;
await c.env.WEBHOOKS.put(daemonCapsKey(c.env), JSON.stringify(caps), { expirationTtl: TTL_7D });
return c.json({ success: true });
}),
);
// t176`POST /portal/daemon/report-capabilities` 已移除。
// 它的用途是收 daemon 回報的 has_claude 去解鎖 portal 的 Claude 勾選框;
// 但 daemon 端從未實作這個呼叫(arcrun-rag 全庫 grep = 0 命中),
// 導致 daemon_caps KV 永遠空、勾選框恆 disabled。t176 起地端模型由小幫手自己設,
// 這條回報鏈整條不需要了。
// POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。
// 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。
@@ -1087,45 +951,11 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
}),
);
// POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}t122)。
// 保留舊端點相容(新 UI 走 /portal/admin/ai)。
// admin 閘(同 chat-key 等級)。金鑰不落 log;存 WEBHOOKS KV。
portalRouter.post('/portal/admin/extractor', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as { engine?: string; gemini_api_key?: string; llm_model?: string } | null;
const engine = String(body?.engine ?? '').trim().toLowerCase();
if (engine !== 'gemma' && engine !== 'claude') {
return c.json({ error: 'engine 只能是 gemma 或 claude' }, 400);
}
const cfg: ExtractorConfig = { engine: engine as 'gemma' | 'claude' };
if (engine === 'gemma') {
const key = String(body?.gemini_api_key ?? '').trim();
if (key) cfg.gemini_api_key = key;
}
const model = String(body?.llm_model ?? '').trim();
if (model) cfg.llm_model = model;
await c.env.WEBHOOKS.put(extractorConfigKey(c.env), JSON.stringify(cfg));
return c.json({ success: true, engine: cfg.engine, has_key: engine === 'gemma' && !!cfg.gemini_api_key });
}),
);
// GET /portal/admin/extractor — 回 engine + has_key(不回金鑰明文)(t122)。
// 保留舊端點相容(新 UI 走 /portal/admin/ai)。
portalRouter.get('/portal/admin/extractor', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const cfg = await getExtractorConfig(c.env);
return c.json({
success: true,
engine: cfg?.engine ?? 'gemma',
has_key: cfg?.engine === 'gemma' && !!cfg?.gemini_api_key,
llm_model: cfg?.llm_model ?? null,
});
}),
);
// t176`POST|GET /portal/admin/extractor`t122已移除
// 這是「雲端指定地端萃取引擎」的舊入口,且**沒有任何伺服器端驗證**——
// 只要打這條就能把 extractor_config 設成 claude,而那把 KV 全租戶共用
// ⇒ 所有沒裝 Claude Code 的機器萃取全滅(08-03 事故)。
// 地端模型現由同步小幫手托盤「AI 設定…」自己設,雲端不再有這個概念。
// GET /portal/admin/libraries — 庫目錄列表。
// t52leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
@@ -1201,41 +1031,67 @@ portalRouter.get('/portal/admin/libraries', (c) =>
}),
);
// POST /portal/admin/libraries — 登記一個庫。body {name, display_name?, description?}。
portalRouter.post('/portal/admin/libraries', (c) =>
// t160leo 07-31:「要直通 daemon,同步,**沒有登記這回事**」):
// 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現
// /portal/daemon/librariest159)。現行 UIe28e190 起)本就零呼叫此端點(死端點);
// 人工登記只會製造對不上的空庫(07-27 leo 拿掉表單時已定調)。
// GET 列表與 PATCH(管理已存在的庫:改名/停用/graph_source)照舊。
// POST /portal/daemon/libraries — 小幫手(daemon)連線精靈時把看守資料夾的庫報上來自動登記。
// t1592026-07-31 leo prod 實走揪出):daemon registerLibrariesarcrun-tray main.go:505t52
// 一直在打這個端點,但 cypher 從來沒有它 ⇒ 404 被 daemon「失敗不擋連線」靜默吞掉
// ⇒ portal_library 登記簿永遠空 ⇒ portal「庫目錄管理」空(資料同步倒是全正常——
// triplet records 的 library slot 都在,病只在登記簿沒人寫)。
// 契約照 daemon 既有呼叫:body {email, password, libraries:[{name, display_name}]}。
// 帳密驗證=與 /portal/session 同一套(daemon 只在精靈那一刻拿到帳密,不存)。
// 冪等:已登記(同 name)跳過——重跑精靈不堆重複。
portalRouter.post('/portal/daemon/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null);
const name = String(body?.name ?? '').trim();
if (!isValidLibraryName(name) || name === '*') {
return c.json({ error: '庫名限 A-Za-z0-9_-1-64 字元;"*" 是保留值不可登記)' }, 400);
const email = String(body?.email ?? '').trim().toLowerCase();
const password = String(body?.password ?? '');
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
const items = Array.isArray(body?.libraries) ? body.libraries : [];
if (items.length === 0) return c.json({ success: true, registered: [], skipped: [] });
// 帳密驗證(沿用 /portal/session 的鎖定與驗證機制)
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
const recordId = await findUserRecordId(c.env, email);
const rec = recordId ? await getRecordById(c.env, recordId) : null;
if (!rec || (rec.values.status ?? '') !== 'active'
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const seeded = await ensurePortalTemplates(c.env);
if (seeded.errors.length > 0) {
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
}
const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
if (existing.some((l) => (l.values.name ?? '') === name)) {
return c.json({ error: `${name} 已登記` }, 409);
}
const have = new Set(existing.map((l) => l.values.name ?? ''));
const ns = portalNamespace(c.env);
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
body: JSON.stringify({
template: LIBRARY_TEMPLATE,
owner_id: ns,
values: {
name,
display_name: String(body?.display_name ?? '').trim() || name,
description: String(body?.description ?? '').trim(),
status: 'active',
},
}),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_library)→ ${res.status}`);
const created = (await res.json()) as { record?: PortalRecord };
return c.json({ success: true, library: created.record ? toPublicLibrary(created.record) : { name } });
const registered: string[] = [];
const skipped: string[] = [];
for (const it of items) {
const name = String(it?.name ?? '').trim();
const displayName = String(it?.display_name ?? '').trim() || name;
if (!isValidLibraryName(name) || name === '*') { skipped.push(name || '(空)'); continue; }
if (have.has(name)) { skipped.push(name); continue; }
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
body: JSON.stringify({
template: LIBRARY_TEMPLATE,
owner_id: ns,
values: { name, display_name: displayName, description: '', status: 'active' },
}),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_librarydaemon 登記)→ ${res.status}`);
have.add(name);
registered.push(name);
}
return c.json({ success: true, registered, skipped });
}),
);
@@ -1275,6 +1131,48 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
}),
);
// ── AI 設定(arcrun-rag#10)────────────────────────────────────────────────────
//
// 🔴 為什麼這段存在(2026-08-01 真因,別再讓它消失):
// 前端設定頁**一直**在打 `GET|POST /portal/admin/ai`,但**後端從來沒有這條 route**
// ⇒ 用戶填 Gemini key → 404 → **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
// leo 實撞成「重裝後 key 不見」,但真相是「從來沒存進去,所以重填也沒用」。
// 產物層鐵證:bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=0。
//
// 設計約束:
// - **不另造第二套儲存**POST 內部轉呼 credentials.ts 既有的 `storeCredential()`
// (唯一寫入路徑=Workers Secret 明文 + D1 目錄列 ref)。
// - **永不回傳 key 本身**D36):GET 只回 `has_key` 布林。
// - credential 的 `api_key` 欄=租戶 slug`portalTenant`),與安裝器 seedCredential
// 寫 `kbdb_internal_token` 用的 ns 同一個值 ⇒ 兩者落在同一租戶分區,查得到彼此。
// - t176Claude 偏好(use_claude_for_extractclaude_available)已整組移除——
// 地端用哪個模型由同步小幫手自己設,雲端不再有這個概念。這裡只管 Gemini 金鑰。
// GET /portal/admin/ai — 回 AI 設定現況(role=admin 閘)。**只回 has_key 布林,永不回 key**。
portalRouter.get('/portal/admin/ai', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const tenantSlug = portalTenant(c.env);
let hasKey = false;
try {
const row = await c.env.CREDENTIALS_DB
.prepare('SELECT 1 FROM credentials WHERE api_key = ? AND name = ? LIMIT 1')
.bind(tenantSlug, 'gemini_api_key')
.first();
hasKey = !!row;
} catch {
// D1 未就緒 ⇒ 當作沒設定(不擋頁面),但也不假裝有
hasKey = false;
}
// t176:不再有 claude_availableuse_claude_for_extract——地端用哪個模型
// 由同步小幫手自己設,雲端不介入(leo 08-03)。
return c.json({ success: true, has_key: hasKey });
}),
);
// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。
// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。
@@ -1303,6 +1201,35 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
}),
);
// POST /portal/admin/ai — 存 Gemini keyrole=admin 閘)。body: { gemini_api_key: string }
// t176:Claude 偏好欄位已移除(地端模型由同步小幫手自己設,雲端不下發)。
portalRouter.post('/portal/admin/ai', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null) as { gemini_api_key?: unknown } | null;
const rawKey = typeof body?.gemini_api_key === 'string' ? body.gemini_api_key.trim() : '';
if (!rawKey) {
return c.json({ error: '沒有要變更的項目(金鑰留空)' }, 400);
}
const tenantSlug = portalTenant(c.env);
try {
// 唯一寫入路徑(credentials.ts):Workers Secret 存值 + D1 存 ref。
await storeCredential(c.env, tenantSlug, 'gemini_api_key', rawKey, 'gemini');
} catch (e) {
// 誠實回報寫入失敗——這正是本 bug 的教訓:不能讓前端以為存好了。
return c.json(
{ error: `金鑰儲存失敗:${e instanceof Error ? e.message : String(e)}` },
502,
);
}
return c.json({ success: true, has_key: true });
}),
);
// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。
// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。
// 資料若有的話,重新同步後會以 auto 庫重新出現。
+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 共用同一把 auth(例:kbdb_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,
+26 -10
View File
@@ -30,6 +30,7 @@ import type { GraphNode } from '../types';
import { extractCronExpr } from '../lib/cron-match';
import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index';
import { recordTelemetry } from '../lib/telemetry';
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
@@ -177,16 +178,9 @@ webhooksNamedRouter.get('/workflows/search', async (c) => {
// 預設優先語意;caller 傳 mode=keyword 才強制關鍵字。KBDB 端未開 Vectorize 會自動降級。
const mode = c.req.query('mode') === 'keyword' ? 'keyword' : 'semantic';
const base = (c.env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const params = new URLSearchParams({
q,
owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow)
entry_type: 'workflow', // base 通用 filterQ4),只回 workflow entry
mode,
});
const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers });
// KBDB 轉發抽到 lib/workflow-search.tst159 target 參數):本路由與
// POST /cypher/search { target:"workflow" } 共用同一條路,行為必然一致。
const res = await fetchTenantWorkflowSearch(c.env, apiKey, q, mode);
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -471,6 +465,28 @@ webhooksNamedRouter.get('/q/:ns/:name', async (c) => {
return queryNamed(c, c.req.param('ns'), c.req.param('name'), queryStringContext(c));
});
// GET /webhooks/named/:name/definition — 吐 workflow 的可攜定義(t158 export 原語)。
// leo 07-31:「如果我要把我做的工作流分享給同事,我要怎麼 export?他要如何 import
// 在從前就是寫成幾個 yaml 丟過去讓新的送進 KBDB 不是嗎?」
// 回 record 原樣(graphconfigdescription)=import 端可直接 POST /webhooks/named 送進
// 任何實例(acr workflow import/安裝器同一條路)。執行語義不驗證(部署≠發現)。
webhooksNamedRouter.get('/webhooks/named/:name/definition', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const name = c.req.param('name');
const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
if (!raw) return c.json({ error: `找不到 workflow "${name}"` }, 404);
const rec = JSON.parse(raw) as NamedWorkflowRecord;
return c.json({
name: rec.name,
description: rec.description ?? '',
graph: rec.graph,
config: rec.config ?? {},
created_at: rec.created_at ?? '',
...(rec.cron_expr ? { cron_expr: rec.cron_expr } : {}),
});
});
// GET /webhooks/named — 列出當前 api_key 下所有 workflow
webhooksNamedRouter.get('/webhooks/named', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
+14
View File
@@ -68,6 +68,13 @@ export type Bindings = {
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
WORKER_SUBDOMAIN: string;
/**
* t162 bundle `YYYY-MM-DD+<commit7>`
* deployBundledWorker worker.js:805** daemon **
* daemon `/health` leo 07-31
* dev undefined/health
*/
ARCRUN_BUNDLE_VERSION?: string;
// Platform telemetry api_key(可選,wrangler secret
// 對應 SDD .agents/specs/llm-interface/ M1.2
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
@@ -127,6 +134,10 @@ export type Bindings = {
// 未設 → 純文字顯示,行為與現狀一字不變。知識庫 repo 是 private 時點了會要登入——要不要
// 設由實例自己決定(demo 知識庫是 public,適用)。
PORTAL_SOURCE_WEB_BASE?: string;
// 零件 registry worker base URL(可選,非機密)。未設 → 用 WORKER_SUBDOMAIN 現算
// https://arcrun-registry.<subdomain>.workers.devwasmWorkerUrl 慣例)。
// 本地 wrangler devself-hosted 把 registry 掛別處時覆蓋(/cypher/search 存在性查詢用)。
REGISTRY_BASE_URL?: string;
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
@@ -147,6 +158,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'; // 結構語意(記錄圖結構,不執行)
@@ -156,6 +168,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,99 @@
// 單元測試:execution-evaluator — 從 trace 導出每顆零件成敗 + 回寫 registry
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
import { describe, it, expect, vi, afterEach } from 'vitest';
import { componentVerdictsFromTrace, recordComponentStats } from '../src/actions/execution-evaluator';
import type { GraphNode, TraceStep } from '../src/types';
const NODES: GraphNode[] = [
{ id: 'input', type: 'Input' },
{ id: 'fetch', type: 'Component', componentId: 'http_request' },
{ id: 'transform', type: 'Component', componentId: 'code' },
{ id: 'output', type: 'Output' },
];
function step(nodeId: string, over: Partial<TraceStep> = {}): TraceStep {
return { nodeId, type: 'Component', input: {}, output: { ok: true }, duration_ms: 10, ...over };
}
describe('componentVerdictsFromTrace', () => {
it('只算 Component 節點;Input/Output 跳過', () => {
const verdicts = componentVerdictsFromTrace(NODES, [
step('input', { type: 'Input' }),
step('fetch'),
step('output', { type: 'Output' }),
]);
expect(verdicts).toEqual([{ component_id: 'http_request', success: true, duration_ms: 10 }]);
});
it('trace 有 error → 該零件記失敗', () => {
const verdicts = componentVerdictsFromTrace(NODES, [
step('fetch', { error: 'boom', output: null }),
]);
expect(verdicts).toEqual([{ component_id: 'http_request', success: false, duration_ms: 10 }]);
});
it('output.success === false → 記失敗(makeHttpRunner 對非 2xx 不 throw', () => {
const verdicts = componentVerdictsFromTrace(NODES, [
step('fetch', { output: { success: false, status: 500, error: 'oops' } }),
]);
expect(verdicts[0].success).toBe(false);
});
it('FOREACH 同節點多筆 trace → 每次執行各記一次樣本', () => {
const verdicts = componentVerdictsFromTrace(NODES, [
step('fetch'),
step('fetch', { error: 'x', output: null }),
step('fetch'),
]);
expect(verdicts).toHaveLength(3);
expect(verdicts.map(v => v.success)).toEqual([true, false, true]);
});
});
describe('recordComponentStats', () => {
afterEach(() => vi.unstubAllGlobals());
it('對每顆零件各發一次 POST /analytics/recordfire-and-forget', async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url: String(url), body: JSON.parse(String(init.body)) });
return new Response('{}', { status: 200 });
}));
await recordComponentStats(
{ REGISTRY_BASE_URL: 'http://registry.local' },
NODES,
[step('fetch'), step('transform', { error: 'bad', output: null })],
);
expect(calls).toHaveLength(2);
expect(calls[0].url).toBe('http://registry.local/analytics/record');
expect(calls[0].body).toEqual({ canonical_id: 'http_request', success: true, duration_ms: 10 });
expect(calls[1].body).toEqual({ canonical_id: 'code', success: false, duration_ms: 10 });
});
it('registry 打不到也不 throw(統計失敗不影響執行)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); }));
await expect(
recordComponentStats({ REGISTRY_BASE_URL: 'http://registry.local' }, NODES, [step('fetch')]),
).resolves.toBeUndefined();
});
it('無 REGISTRY_BASE_URL 也無 WORKER_SUBDOMAIN → 靜默略過不打', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
await recordComponentStats({}, NODES, [step('fetch')]);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('未設 REGISTRY_BASE_URL → 用 wasmWorkerUrl 慣例組 registry URL', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
calls.push(String(url));
return new Response('{}', { status: 200 });
}));
await recordComponentStats({ WORKER_SUBDOMAIN: 'uncle6-me' }, NODES, [step('fetch')]);
expect(calls[0]).toBe('https://arcrun-registry.uncle6-me.workers.dev/analytics/record');
});
});
+6 -4
View File
@@ -4,13 +4,15 @@ import { healthRouter } from '../src/routes/health';
import type { Bindings, ExecutionContext } from '../src/types';
describe('GET /health — bundle_version 欄位', () => {
it('無 ARCRUN_BUNDLE_VERSION 時回空字串(老實例情境)', async () => {
// wrangler.test.toml 不設此 var → 走 ?? '' fallback
it('無 ARCRUN_BUNDLE_VERSION 時省略該欄(老實例情境)', async () => {
// wrangler.test.toml 不設此 var → health.ts 省略 bundle_version 欄位。
// daemon 端讀不到該欄=當作空字串=判 stale,對老實例而言**這是正確行為**
//(見 health.ts 檔頭註解)。此處驗「省略」而非「回空字串」,與實作對齊。
const res = await SELF.fetch('http://localhost/health');
const data = await res.json() as { ok: boolean; bundle_version: string };
const data = await res.json() as { ok: boolean; bundle_version?: string };
expect(res.status).toBe(200);
expect(data.ok).toBe(true);
expect(data.bundle_version).toBe('');
expect(data.bundle_version).toBeUndefined();
});
it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => {
@@ -0,0 +1,67 @@
/**
* /init/seed 3.12 KV SDD: workflow-discovery task 3.12/3.13
*
*
* 3.12 `RecipeDefinition` body_template / response_map / auth / binding_name
* `/init/seed` **** recipe record
* recipe canonical_id endpoint
* auth HTTP fetch@cf/
* 2026-08-02 `syncManifest()` `manifest.daemon`
* **西**
*
* KV Workers AI
*/
import { describe, it, expect } from 'vitest';
import { env, SELF } from 'cloudflare:test';
import { API_RECIPE_SEEDS } from '../src/lib/api-recipe-seeds';
type StoredRecipe = {
canonical_id: string;
endpoint: string;
auth?: string;
binding_name?: string;
body_template?: Record<string, unknown>;
response_map?: { text_path?: string; answer_marker?: string; strip_prefixes?: string[] };
};
async function seedThenRead(canonicalId: string): Promise<StoredRecipe> {
const res = await SELF.fetch('https://example.com/init/seed', { method: 'POST' });
// 測試環境沒有 KBDB binding ⇒ portal template 那段必然失敗、整體回 207(誠實回報,非本測目標)。
// 本檔只管 API recipe 那半,所以驗它自己的計數,不驗整體 status。
const body = await res.json<{ api_recipes: { seeded: number; failed: number; errors: string[] } }>();
expect(body.api_recipes.errors).toEqual([]);
expect(body.api_recipes.failed).toBe(0);
const uuid = await env.RECIPES.get(`idx:installed:${canonicalId}`);
expect(uuid, `${canonicalId} 沒有被 seed 進 KV`).toBeTruthy();
return JSON.parse((await env.RECIPES.get(`recipe:${uuid}`))!) as StoredRecipe;
}
describe('/init/seed 不得靜默吃掉 recipe 的 3.12 欄位', () => {
it('workers_ai_chat 種子本身宣告齊四個欄位(種子端)', () => {
const seed = API_RECIPE_SEEDS.find(s => s.canonical_id === 'workers_ai_chat');
expect(seed, 'workers_ai_chat 種子不存在=裝完不會有免金鑰問答').toBeDefined();
expect(seed!.auth).toBe('binding');
expect(seed!.binding_name).toBe('AI');
expect(seed!.endpoint.startsWith('@cf/'), 'binding 型的 endpoint=模型 id').toBe(true);
expect(seed!.body_template).toBeDefined();
expect(seed!.response_map?.text_path).toBe('response');
});
it('seed 之後 KV 裡讀回來的仍帶 auth/binding_name/body_template/response_mapKV 端)', async () => {
const stored = await seedThenRead('workers_ai_chat');
expect(stored.auth, 'auth 掉了 ⇒ 會被當成 HTTP recipe 去 fetch 一個不是網址的字串').toBe('binding');
expect(stored.binding_name).toBe('AI');
expect(stored.body_template, 'body_template 掉了 ⇒ 整包 ctx 被當 payload 送給模型').toBeDefined();
expect(stored.response_map?.text_path, 'response_map 掉了 ⇒ 下游拿不到 text').toBe('response');
expect(stored.response_map?.answer_marker).toBe('【答】');
});
it('既有 HTTP 種子不受影響:沒宣告新欄位就是 undefined,不憑空長出來', async () => {
const stored = await seedThenRead('telegram_send');
expect(stored.auth).toBeUndefined();
expect(stored.binding_name).toBeUndefined();
expect(stored.body_template).toBeUndefined();
expect(stored.response_map).toBeUndefined();
expect(stored.endpoint).toContain('api.telegram.org');
});
});
@@ -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,133 @@
/**
* GET|POST /portal/admin/ai arcrun-rag#10
*
* 🔴
* route **** Gemini key 404
* **key **
* leo key
* bundle tier2/ui grep 'portal/admin/ai'=1tier2/cypher=**0**
* **route **
*
*
* 1. 401 admin 403 404route
* 2. GET has_key ** key **D36
* 3. POST body 400
* 4. POST Claude key credential
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { hashPassword } from '../src/lib/portal-auth';
const KBDB = 'https://kbdb.test';
let storedHash: string;
beforeAll(async () => {
fetchMock.activate();
fetchMock.disableNetConnect();
storedHash = await hashPassword('unit-test-pw-1', 10_000);
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
return SELF.fetch(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
function mockGetRecord(recordId: string, values: Record<string, string>) {
fetchMock
.get(KBDB)
.intercept({ path: `/records/${recordId}`, method: 'GET' })
.reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
}
function adminValues(overrides: Record<string, string> = {}): Record<string, string> {
return {
email: 'admin@example.com',
display_name: '管理員',
status: 'active',
role: 'admin',
password_hash: storedHash,
libraries: '["*"]',
created_at: '2026-07-14T00:00:00.000Z',
updated_at: '2026-07-14T00:00:00.000Z',
...overrides,
};
}
async function seedSession(token: string, recordId: string) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
const authHdr = (t: string) => ({ Authorization: `Bearer ${t}` });
describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () => {
it('未登入 → 401(不是 404 ⇒ route 真的在)', async () => {
const res = await json('GET', '/portal/admin/ai');
expect(res.status).toBe(401);
expect(res.status).not.toBe(404);
});
it('非 admin → 403', async () => {
await seedSession('tok-user', 'rec_user');
mockGetRecord('rec_user', adminValues({ role: 'user', email: 'u@example.com' }));
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-user'));
expect(res.status).toBe(403);
});
});
describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => {
it('回 has_key 布林,且回應完全不含金鑰值', async () => {
await seedSession('tok-a1', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1'));
expect(res.status).toBe(200);
const raw = await res.text();
const d = JSON.parse(raw) as Record<string, unknown>;
expect(typeof d.has_key).toBe('boolean');
// D36:回應裡不得出現任何疑似金鑰的欄位
expect(raw).not.toContain('gemini_api_key_value');
expect(d).not.toHaveProperty('key');
expect(d).not.toHaveProperty('value');
expect(d).not.toHaveProperty('secret_ref');
});
// t176 回歸守衛(leo 08-03):雲端不再有「地端用哪個模型」的概念。
// 這兩個欄位若復活,代表又走回「雲端控制地端」的老路——那正是 08-03 事故根因
//extractor_config 全租戶共用一把,任一處設 claude 就讓所有人萃取全滅)。
it('不再回 claude_availableuse_claude_for_extract(地端模型改由小幫手自己設)', async () => {
await seedSession('tok-a1b', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1b'));
const d = (await res.json()) as Record<string, unknown>;
expect(d).not.toHaveProperty('claude_available');
expect(d).not.toHaveProperty('use_claude_for_extract');
});
});
describe('POST /portal/admin/ai — 不假裝成功', () => {
it('空 body(沒帶金鑰)→ 400,不回 success', async () => {
await seedSession('tok-a2', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2'));
expect(res.status).toBe(400);
const d = (await res.json()) as Record<string, unknown>;
expect(d.success).toBeUndefined();
expect(String(d.error)).toContain('沒有要變更');
});
// t176 回歸守衛:只送 Claude 偏好=沒有要變更的項目 → 400(該欄位已不存在)。
it('只送 use_claude_for_extract(已廢欄位)→ 400,不得假裝成功', async () => {
await seedSession('tok-a3', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3'));
expect(res.status).toBe(400);
const d = (await res.json()) as Record<string, unknown>;
expect(d.success).toBeUndefined();
});
});
+40 -227
View File
@@ -300,42 +300,16 @@ describe('PATCH libraries(每帳號可查庫)', () => {
// ═══════════════ 5. 庫目錄管理 ═══════════════
describe('/portal/admin/libraries', () => {
it('POST 建庫:寫 {tenant}::portal 子 namespace;重複登記 → 409', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockTemplatesExist();
mockListByTemplate('portal_library', []);
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return {
success: true,
record: { record_id: 'rec_lib1', template_id: 'tpl_pl', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
};
});
it('t160:人工建庫端點已刪(leo「沒有登記這回事」)——POST → 404;庫只從 daemon 同步來', async () => {
// 舊測試驗「POST 建庫 200+重複 409」——t160 拔掉人工建庫(e744ad1)後規格為:
// 庫由 /portal/daemon/libraries(連線精靈自動登記,t159)產生,admin 只能 GET/PATCH。
const res = await json(
'POST',
'/portal/admin/libraries',
{ name: 'finance', display_name: '財務庫' },
{ Authorization: 'Bearer tok-admin' },
);
expect(res.status).toBe(200);
const rec = JSON.parse(recordBody) as { owner_id: string; template: string };
expect(rec.owner_id).toBe(NS);
expect(rec.template).toBe('portal_library');
// 重複登記
await seedAdminSession('tok-admin3');
mockGetRecord('rec_admin', adminValues());
mockTemplatesExist();
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
]);
const dup = await json('POST', '/portal/admin/libraries', { name: 'finance' }, { Authorization: 'Bearer tok-admin3' });
expect(dup.status).toBe(409);
expect(res.status).toBe(404);
});
it('PATCH graph_sourceboolean 進、slot 存字串;非 boolean → 400', async () => {
@@ -564,13 +538,20 @@ describe('DELETE /portal/admin/librariest135', () => {
});
});
// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
// ═══════════════ 6. t176:雲端不再管地端 LLM 設定(取代原 t122/t131 兩組測試)═══════════════
//
// leo 2026-08-03 架構翻案:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,
// 而不是雲端設置後控制地端」。原因是 extractor_config 的 KV key 由 portalTenant() 組出,
// 而 portalTenant 是 **worker 層級**環境變數 ⇒ **全租戶共用一把**:任一處設了 claude,
// 所有人的 daemon 都收到 claude,沒裝 Claude Code 的機器萃取全滅,
// 而 portal 的 Claude 勾選框又恆 disableddaemon 從未回報 has_claude)⇒ 用戶自己解不開。
//
// 以下是**回歸守衛**:這些端點/欄位若復活,代表又走回「雲端控制地端」的老路。
describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122', () => {
describe('t176:雲端不再下發/設定地端 LLM', () => {
const USER_EMAIL = 'daemon@example.com';
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算)
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(外層 beforeAll 計算)
const USER_RECORD = 'rec_daemon_user';
const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; // wrangler.test.toml CONSOLE_TENANT=leo
/** mock email head lookupfindUserRecordId 走這個路徑)*/
function mockEmailLookup(email: string, recordId: string | null) {
@@ -584,47 +565,37 @@ describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
}
it('未設定 → daemon/config 下發 extractor=gemma,無 gemini_api_key', async () => {
// 確保 KV 沒有 extractor config
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
it('POST /portal/daemon/config 只回連線欄位,**不含任何 LLM 欄位**', async () => {
mockEmailLookup(USER_EMAIL, USER_RECORD);
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; config: Record<string, string> };
expect(data.success).toBe(true);
expect(data.config.extractor).toBe('gemma');
expect('gemini_api_key' in data.config).toBe(false);
const d = (await res.json()) as { config: Record<string, unknown> };
// 連線欄位照舊(daemon 靠它上線)
expect(d.config.cypher_url).toBeTruthy();
expect(d.config.namespace).toBeTruthy();
expect(d.config.library).toBe('kb');
// LLM 欄位一律不下發(t176 核心)
expect(d.config).not.toHaveProperty('extractor');
expect(d.config).not.toHaveProperty('gemini_api_key');
expect(d.config).not.toHaveProperty('llm_model');
});
it('設定 gemma+金鑰後 → daemon/config 下發含 gemini_api_key', async () => {
await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-test-key-999' }));
mockEmailLookup(USER_EMAIL, USER_RECORD);
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; config: Record<string, string> };
expect(data.config.extractor).toBe('gemma');
expect(data.config.gemini_api_key).toBe('AIza-test-key-999');
// cleanup
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
// 註:route 不存在 ⇒ 在認證之前就 404,因此不需要(也不能)預先掛 record mock
// 否則 afterEach 的 assertNoPendingInterceptors 會因「mock 沒被用到」而失敗。
it('POST /portal/admin/extractor 已移除(雲端不再有指定地端引擎的入口)', async () => {
const res = await json('POST', '/portal/admin/extractor', { engine: 'claude' }, { Authorization: 'Bearer tok-ex' });
expect(res.status).toBe(404);
});
it('GET /portal/admin/extractor → has_key=true,回應不含金鑰明文', async () => {
await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-secret-key' }));
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
const res = await json('GET', '/portal/admin/extractor', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; engine: string; has_key: boolean };
expect(data.engine).toBe('gemma');
expect(data.has_key).toBe(true);
// 回應主體不含金鑰明文
const raw = JSON.stringify(data);
expect(raw).not.toContain('AIza-secret-key');
expect(raw).not.toContain('gemini_api_key');
// cleanup
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
it('POST /portal/daemon/report-capabilities 已移除(has_claude 回報鏈整條退役)', async () => {
const res = await json('POST', '/portal/daemon/report-capabilities', {
email: USER_EMAIL, password: USER_PW, has_claude: true,
});
expect(res.status).toBe(404);
});
});
@@ -658,166 +629,8 @@ describe('GET /portalP4 admin 頁 HTML 殼)', () => {
});
});
// ═══════════════ 8. t131 合併 AI 設定 ═══════════════
describe('/portal/admin/ai + /portal/daemon/report-capabilitiest131', () => {
const USER_EMAIL = 'ai-test@example.com';
const USER_PW = 'unit-test-pw-1';
const USER_RECORD = 'rec_ai_user';
const AI_CONFIG_KEY = 'leo:portal:ai_config';
const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config';
const DAEMON_CAPS_KEY = 'leo:portal:daemon_caps';
function aiAdminVals(): Record<string, string> {
return { email: USER_EMAIL, display_name: 'AI 測試 admin', status: 'active', role: 'admin', password_hash: storedHash };
}
// 與全域 seedAdminSession 相同格式(JSON.stringify({record_id})),fetchMock 由各測試自行 mock
async function seedAiSession(token = 'tok-ai-admin', recordId = USER_RECORD) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
function mockAiRecord(recordId = USER_RECORD) {
fetchMock.get(KBDB).intercept({ path: `/records/${recordId}`, method: 'GET' }).reply(200, {
success: true,
record: { record_id: recordId, template_id: 'tpl_pu', values: aiAdminVals() },
});
}
function mockEmailLookup(email: string, recordId: string | null) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock.get(KBDB).intercept({
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
method: 'GET',
}).reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
}
afterEach(async () => {
await env.WEBHOOKS.delete(AI_CONFIG_KEY);
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
await env.WEBHOOKS.delete(DAEMON_CAPS_KEY);
});
it('POST /ai — 首次設定:同時寫 ai_configextractor_config+更新 rag_chat workflow', async () => {
const ragChatKey = 'leo:wf:rag_chat';
const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': '{{credential.gemini}}' } }] }, config: {} };
await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
await seedAiSession();
mockAiRecord();
const res = await json('POST', '/portal/admin/ai',
{ gemini_api_key: 'AIza-new-key-123', use_claude_for_extract: false },
{ Authorization: 'Bearer tok-ai-admin' }
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean };
expect(data.success).toBe(true);
expect(data.has_key).toBe(true);
expect(data.use_claude_for_extract).toBe(false);
const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
expect(stored.gemini_api_key).toBe('AIza-new-key-123');
expect(stored.use_claude_for_extract).toBe(false);
const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
expect(exCfg.engine).toBe('gemma');
expect(exCfg.gemini_api_key).toBe('AIza-new-key-123');
const updated = JSON.parse((await env.WEBHOOKS.get(ragChatKey, 'text')) ?? '{}') as typeof workflow;
expect((updated.graph as { nodes: Array<{ config: Record<string, string> }> }).nodes[0].config['x-goog-api-key']).toBe('AIza-new-key-123');
await env.WEBHOOKS.delete(ragChatKey);
});
it('POST /ai — rag_chat 不存在時不報錯(容忍,金鑰存 ai_config 即可)', async () => {
await seedAiSession();
mockAiRecord();
const res = await json('POST', '/portal/admin/ai',
{ gemini_api_key: 'AIza-no-workflow-key' },
{ Authorization: 'Bearer tok-ai-admin' }
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; has_key: boolean };
expect(data.success).toBe(true);
expect(data.has_key).toBe(true);
const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
expect(stored.gemini_api_key).toBe('AIza-no-workflow-key');
});
it('POST /ai — use_claude_for_extract=trueextractor engine=claude,不附 gemini_api_key', async () => {
await seedAiSession();
mockAiRecord();
const res = await json('POST', '/portal/admin/ai',
{ gemini_api_key: 'AIza-key-888', use_claude_for_extract: true },
{ Authorization: 'Bearer tok-ai-admin' }
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; use_claude_for_extract: boolean };
expect(data.use_claude_for_extract).toBe(true);
const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
expect(exCfg.engine).toBe('claude');
expect('gemini_api_key' in exCfg).toBe(false);
});
it('GET /ai — 不回明文金鑰;has_key=trueclaude_available 依 daemon_caps', async () => {
await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-secret-456', use_claude_for_extract: false }));
await env.WEBHOOKS.put(DAEMON_CAPS_KEY, JSON.stringify({ has_claude: true }));
await seedAiSession();
mockAiRecord();
const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean; claude_available: boolean };
expect(data.has_key).toBe(true);
expect(data.use_claude_for_extract).toBe(false);
expect(data.claude_available).toBe(true);
const raw = JSON.stringify(data);
expect(raw).not.toContain('AIza-secret-456');
expect(raw).not.toContain('gemini_api_key');
});
it('GET /ai — 沒有 daemon_caps → claude_available=false', async () => {
await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-key-777' }));
await seedAiSession();
mockAiRecord();
const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { claude_available: boolean };
expect(data.claude_available).toBe(false);
});
it('POST /portal/daemon/report-capabilities — 有 claudedaemon_caps 寫入 has_claude=true', async () => {
mockEmailLookup(USER_EMAIL, USER_RECORD);
mockAiRecord();
const res = await json('POST', '/portal/daemon/report-capabilities', {
email: USER_EMAIL, password: USER_PW, has_claude: true, daemon_version: '1.2.0', os: 'darwin',
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
const caps = JSON.parse((await env.WEBHOOKS.get(DAEMON_CAPS_KEY, 'text')) ?? '{}');
expect(caps.has_claude).toBe(true);
expect(caps.daemon_version).toBe('1.2.0');
});
it('舊端點 /portal/admin/chat-key 仍可用(相容)', async () => {
const ragChatKey = 'leo:wf:rag_chat';
const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': 'old' } }] }, config: {} };
await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
await seedAiSession();
mockAiRecord();
const res = await json('POST', '/portal/admin/chat-key', { key: 'AIza-compat-key' }, { Authorization: 'Bearer tok-ai-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; replaced: number };
expect(data.success).toBe(true);
expect(data.replaced).toBeGreaterThan(0);
await env.WEBHOOKS.delete(ragChatKey);
});
});
// ═══════════════ t181:daemon 萃取走 Workers AI(免金鑰)═══════════════
//
// leo 08-04 列為最優先:「daemon 的 AI 改用 workers AI」——
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」。
// 舊路徑要用戶自備 Gemini key,實測撞到「不知道去哪設定」「Google 帳號被 flag 403」
// 「52 檔全滅還要把金鑰傳給別人才查得出原因」三種災難。
// t131/t122 測試已隨 main 的 t176(刪除雲端下發 LLM 設定)一併移除;
// 此處只保留 t181daemon 走 Workers AI)的守衛。
describe('POST /portal/daemon/extractt181Workers AI 萃卡,免金鑰)', () => {
// 認證=X-Arcrun-API-Key(=namespacewrangler.test.toml CONSOLE_TENANT=leo),
@@ -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);
});
});