313aeb13bf
Part 1(框架,cypher-executor webhooks-named.ts):加同步查詢 trigger。
現有 named webhook /trigger 回 {success,data,trace,duration_ms} 信封、只有 POST;
查詢面(console/MCP 打 graph neighbors/traverse)要 GET + 直接拿最終節點輸出當 response。
新端點同步 await 執行 workflow graph → 回 result.data(最終節點輸出)本身當 body(非 202):
- GET /q/:ns/:name (namespace 走 path,input 走 query string)
- GET /webhooks/named/:name/query (X-Arcrun-API-Key header,input 走 query string)
- POST /webhooks/named/:name/query (header,input 走 body)
- POST /webhooks/named/:ns/:name/query (namespace 走 path,input 走 body)
認證沿用 X-Arcrun-API-Key。誠實(mindset §7):節點失敗回 error+trace(500,非假綠);
paused 工作流無法同步回答 → 409 明講;輸出 5 MiB 硬上限(超過 413);duration 走 header 不污染 body。
Part 2(A 類 workflow.yaml):registry/examples/graph-neighbors/。
http_request 打 base custom domain kbdb.finally.click(避 CF 1042)撈 triplet records
→ code 零件記憶體 BFS(對照 kbdb-graph-plugin graph-traverse.ts)→ 同步回鄰居。
把 graph plugin 內建 GET /graph/neighbors 泛化成查詢面 workflow 的示範。
測試:cypher-executor/tests/query-trigger.test.ts(7 測,全綠)——同步回輸出(非 202)、
GET/POST × header/path 四端點、節點失敗回錯+trace、缺 key 401、不存在 404。
用內建 comp_uppercase(純記憶體)證明 Part 1 同步 trigger 機制本身可用。
待驗:graph_neighbors 需 code 零件部署 leo21c 後才能 live 端到端(另線處理);
triplet template id 上線前對一次(workflow 已參數化未寫死)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
133 lines
5.5 KiB
TypeScript
133 lines
5.5 KiB
TypeScript
/**
|
||
* 同步查詢 trigger(sync query)測試 — webhooks-named.ts 的 /query 與 /q/* 端點。
|
||
*
|
||
* 驗證重點(對應交辦 Part 1):
|
||
* 1. 同步回「最終節點輸出」本身(非 202、非 {success,data,...} 信封)— GET(query string) 與 POST(body) 皆是。
|
||
* 2. namespace 走 path 的公開形態(/q/:ns/:name、POST /webhooks/named/:ns/:name/query)。
|
||
* 3. 節點失敗 → 誠實回錯誤 + trace(500),非假綠。
|
||
* 4. 認證:缺 X-Arcrun-API-Key → 401;workflow 不存在 → 404。
|
||
*
|
||
* 用內建零件(comp_uppercase / comp_passthrough,純記憶體、無外部 fetch)當「極簡 workflow」,
|
||
* 因此本測試就是「Part 1 同步 trigger 機制本身可用」的證據(確實同步回值而非 202)。
|
||
*/
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import { env, SELF } from 'cloudflare:test';
|
||
|
||
const API_KEY = 'test-tenant-query';
|
||
|
||
function kvKey(name: string, apiKey = API_KEY): string {
|
||
return `${apiKey}:wf:${name}`;
|
||
}
|
||
|
||
// 極簡 workflow:單一 comp_uppercase 節點。caller input(text)→ 大寫 → 當最終輸出回。
|
||
// 無 Input/Output 節點:最終節點輸出即 comp_uppercase 的回傳,乾淨可斷言。
|
||
const UPPER_WF = {
|
||
name: 'q_upper',
|
||
graph: {
|
||
id: 'q_upper',
|
||
name: 'sync query upper',
|
||
nodes: [{ id: 'upper', type: 'Component', componentId: 'comp_uppercase' }],
|
||
edges: [],
|
||
},
|
||
description: '同步查詢:把 text 轉大寫回傳(測試用)',
|
||
created_at: new Date().toISOString(),
|
||
};
|
||
|
||
// 會失敗的 workflow:引用不存在的零件 → 節點執行失敗。
|
||
const FAIL_WF = {
|
||
name: 'q_fail',
|
||
graph: {
|
||
id: 'q_fail',
|
||
name: 'sync query fail',
|
||
nodes: [{ id: 'boom', type: 'Component', componentId: 'comp_does_not_exist' }],
|
||
edges: [],
|
||
},
|
||
description: '同步查詢:故意引用不存在零件(測試失敗路徑)',
|
||
created_at: new Date().toISOString(),
|
||
};
|
||
|
||
beforeAll(async () => {
|
||
await env.WEBHOOKS.put(kvKey(UPPER_WF.name), JSON.stringify(UPPER_WF));
|
||
await env.WEBHOOKS.put(kvKey(FAIL_WF.name), JSON.stringify(FAIL_WF));
|
||
});
|
||
|
||
describe('同步查詢 trigger — 成功回最終節點輸出(非 202、非信封)', () => {
|
||
it('POST /webhooks/named/:name/query(header 認證,body input)同步回輸出', async () => {
|
||
const res = await SELF.fetch('http://localhost/webhooks/named/q_upper/query', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': API_KEY },
|
||
body: JSON.stringify({ text: 'hello' }),
|
||
});
|
||
expect(res.status).toBe(200); // 關鍵:非 202
|
||
const data = await res.json() as Record<string, unknown>;
|
||
// 回的是「最終節點輸出本身」:頂層直接有 text=HELLO,而非包在 { data: ... } 信封裡
|
||
expect(data.text).toBe('HELLO');
|
||
expect(data).not.toHaveProperty('duration_ms'); // 信封欄位不該出現在 body
|
||
// duration 走 header,不污染輸出
|
||
expect(res.headers.get('X-Arcrun-Duration-Ms')).not.toBeNull();
|
||
});
|
||
|
||
it('GET /webhooks/named/:name/query(header 認證,query string input)同步回輸出', async () => {
|
||
const res = await SELF.fetch('http://localhost/webhooks/named/q_upper/query?text=world', {
|
||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
||
});
|
||
expect(res.status).toBe(200);
|
||
const data = await res.json() as Record<string, unknown>;
|
||
expect(data.text).toBe('WORLD');
|
||
});
|
||
|
||
it('GET /q/:ns/:name(namespace 走 path,query string input)同步回輸出', async () => {
|
||
const res = await SELF.fetch(`http://localhost/q/${API_KEY}/q_upper?text=abc`);
|
||
expect(res.status).toBe(200);
|
||
const data = await res.json() as Record<string, unknown>;
|
||
expect(data.text).toBe('ABC');
|
||
});
|
||
|
||
it('POST /webhooks/named/:ns/:name/query(namespace 走 path,body input)同步回輸出', async () => {
|
||
const res = await SELF.fetch(`http://localhost/webhooks/named/${API_KEY}/q_upper/query`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: 'path' }),
|
||
});
|
||
expect(res.status).toBe(200);
|
||
const data = await res.json() as Record<string, unknown>;
|
||
expect(data.text).toBe('PATH');
|
||
});
|
||
});
|
||
|
||
describe('同步查詢 trigger — 誠實錯誤(不假綠)', () => {
|
||
it('節點失敗 → 500 + error + trace(非把錯誤當輸出)', async () => {
|
||
const res = await SELF.fetch('http://localhost/webhooks/named/q_fail/query', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': API_KEY },
|
||
body: JSON.stringify({}),
|
||
});
|
||
expect(res.status).toBe(500);
|
||
const data = await res.json() as { success: boolean; error: string; trace: unknown };
|
||
expect(data.success).toBe(false);
|
||
expect(typeof data.error).toBe('string');
|
||
expect(data.error.length).toBeGreaterThan(0);
|
||
expect(data.trace).toBeDefined();
|
||
});
|
||
});
|
||
|
||
describe('同步查詢 trigger — 認證與存在性', () => {
|
||
it('缺 X-Arcrun-API-Key(header 形態)→ 401', async () => {
|
||
const res = await SELF.fetch('http://localhost/webhooks/named/q_upper/query', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: 'x' }),
|
||
});
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('workflow 不存在 → 404', async () => {
|
||
const res = await SELF.fetch('http://localhost/webhooks/named/no_such_wf/query', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': API_KEY },
|
||
body: JSON.stringify({}),
|
||
});
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|