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
@@ -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 判斷該走哪條路 ⇒ 這正是「全變成 code」的根(Arcrun#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'=1、tier2/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×23、for×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);
});
});