/** * Named Webhook(acr push 使用) * * POST /webhooks/named * Header: X-Arcrun-API-Key * Body: { name, graph, config?, description? } * → 以 {api_key}:wf:{name} 存入 WEBHOOKS KV * → 回傳 webhook_url * * POST /webhooks/named/:name/trigger * Header: X-Arcrun-API-Key * Body: 任意 JSON(作為 trigger context) * → 以 {api_key}:wf:{name} 讀取執行圖,執行後回傳結果 * * GET /webhooks/named * Header: X-Arcrun-API-Key * → 列出當前 api_key 下所有 named webhook * * DELETE /webhooks/named/:name * Header: X-Arcrun-API-Key * → 刪除指定 workflow */ import { Hono } from 'hono'; import type { Context } from 'hono'; import type { Bindings } from '../types'; import { executeWebhookGraph } from '../actions/webhook-handlers'; import { writeExecutionVerdict } from '../actions/execution-logger'; 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'; export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>(); type NamedWorkflowRecord = { name: string; graph: Record; config?: Record; description: string; created_at: string; // 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對 // 對應 SDD: arcrun.md 三-A P1 #3 cron_expr?: string; // 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。保留欄位只為向後相容舊 KV record。 exposure_consent?: unknown; }; function kvKey(apiKey: string, name: string): string { return `${apiKey}:wf:${name}`; } /** * workflow-discovery R2/Phase 2.1:部署時雙寫一個 embeddable entry 到 KBDB,讓 workflow 可被語意搜尋。 * * 雙寫(design 方案 C):WEBHOOKS KV record 照舊(list/get/trigger 不動),另寫 entry_type=workflow 的 * entry 供 search。owner_id = api_key(租戶隔離,與 kbdb-proxy 同身份模型)。 * content = description(被 embed 的主體);metadata.embed:true → 命中 #7 精耕條件進 Vectorize(模組開時)。 * * 非阻塞 + 失敗不致命(waitUntil + catch):search 可發現性是加值,不該擋部署成功(對齊 #7 embedOnWrite 慣例)。 * KBDB 連法沿用既有慣例(KBDB_BASE_URL fetch + 選用 token),不新增 service binding(rule 02 §3.1)。 */ async function writeWorkflowSearchEntry( env: Bindings, apiKey: string, name: string, description: string, workflowId?: string, ): Promise { const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, ''); const headers: Record = { 'Content-Type': 'application/json' }; if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`; await fetch(`${base}/entries`, { method: 'POST', headers, body: JSON.stringify({ entry_type: 'workflow', owner_id: apiKey, // 租戶隔離(與 kbdb-proxy 同身份) page_name: name, content: description, // 被 embed / LIKE 命中的主體 // KBDB createEntry 吃 metadata_json(TEXT),embed.ts isEmbeddable 讀 metadata_json.embed === true。 metadata_json: JSON.stringify({ embed: true, // #7 精耕開關:標 true 才進 Vectorize workflow_name: name, workflow_id: workflowId ?? name, }), }), }); } // POST /webhooks/named — 部署(acr push 呼叫) webhooksNamedRouter.post('/webhooks/named', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } const body = await c.req.json().catch(() => null) as { name?: string; graph?: Record; config?: Record; description?: string; } | null; if (!body?.name || !body.graph) { return c.json({ error: '缺少必要欄位:name, graph' }, 400); } // workflow-discovery R1:description 強制非空(供語意搜尋,工作流可被發現)。 // 定位(Q2 定案):要求操盤的 AI 據實寫一句「這工作流能做什麼」,非逼 low-code 用戶手填、 // 非介面層機械塞佔位。空 → 擋下,由操盤 CC 據實補一句再部署(用戶可改)。 if (typeof body.description !== 'string' || body.description.trim() === '') { return c.json({ error: 'description 必填:請操盤的 AI 據實寫一句「這工作流能做什麼」(如「呼叫可 Upsert Google Sheets」),用戶可再改。供語意搜尋用,不是寫文章。', requires: 'description', }, 400); } const name = body.name.trim(); if (!/^[\w-]+$/.test(name)) { return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400); } // 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):部署 webhook 不再需要人類確認,直接放行。 // 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled() const cronExpr = extractCronExpr(body.graph); const record: NamedWorkflowRecord = { name, graph: body.graph, config: body.config, description: body.description.trim(), // R1:已驗非空(見上),存 trim 後的值 created_at: new Date().toISOString(), cron_expr: cronExpr ?? undefined, }; const start = Date.now(); await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record)); // 維護單一 cron index key(8.P0):有 cron_expr 就 upsert / 沒有就移除 // (避免 push 改 yaml 拿掉 cron 後殘留)。scheduled() 每分鐘只 get 這一個 key。 await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, cronExpr); // workflow-discovery Phase 2.1:雙寫 embeddable search-entry(讓此 workflow 可被語意搜尋)。 // 非阻塞(waitUntil)+ 失敗不致命(catch):可發現性是加值,不擋部署成功(對齊 #7 embedOnWrite 慣例)。 c.executionCtx.waitUntil( writeWorkflowSearchEntry(c.env, apiKey, name, record.description).catch(() => {}), ); // Implicit telemetry (LI M1.2) recordTelemetry(c.env, apiKey, { event_type: 'deploy_success', workflow_name: name, duration_ms: Date.now() - start, agent_user_agent: c.req.header('User-Agent') ?? undefined, }, c.executionCtx); const baseUrl = new URL(c.req.url).origin; return c.json({ name, webhook_url: `${baseUrl}/webhooks/named/${name}/trigger`, description: record.description, created_at: record.created_at, }, 201); }); // GET /workflows/search?q=&mode= — workflow-discovery R2:語意搜尋本租戶的工作流。 // 轉發 KBDB /entries/search(限 entry_type=workflow + 本租戶 owner_id)。優先語意、未開 Vectorize // 降級 keyword + capability_hint(KBDB 端已實作 #7 閉環,本端純轉發 + 注 entry_type/owner_id)。 // 形態對齊 u6u_search_components:自然語言 q 進、結果 + capability_hint 出。flag 安全:AI 主動 pull,無輪詢。 webhooksNamedRouter.get('/workflows/search', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); const q = c.req.query('q'); if (!q) return c.json({ error: 'q 必填:用自然語言描述要找的工作流(如「把資料寫進 Google Sheets」)' }, 400); // 預設優先語意;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 = { '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 通用 filter(Q4),只回 workflow entry mode, }); const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // POST /workflows/backfill-search-entries — workflow-discovery R3:把既有 workflow 補成可搜的 search-entry。 // 有 description 的 → 補寫 entry(讓它們可被 u6u_search_workflows 搜到);無 description 的 → 列出待 re-deploy。 // 誠實:不自動編造 description(無 desc 的只列出、不假裝)。flag 安全:人/AI 主動呼叫一次,非 cron/輪詢。 webhooksNamedRouter.post('/workflows/backfill-search-entries', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); const prefix = `${apiKey}:wf:`; const list = await c.env.WEBHOOKS.list({ prefix }); const backfilled: string[] = []; const needsDescription: string[] = []; const errors: string[] = []; for (const k of list.keys) { const name = k.name.slice(prefix.length); const raw = await c.env.WEBHOOKS.get(k.name, 'text'); if (!raw) continue; const rec = JSON.parse(raw) as NamedWorkflowRecord; const desc = rec.description?.trim(); if (!desc) { // 不自動編造:無 description 的列出來,請操盤 CC re-deploy 時據實補(誠實,mindset §7)。 needsDescription.push(name); continue; } try { await writeWorkflowSearchEntry(c.env, apiKey, name, desc); backfilled.push(name); } catch (e) { errors.push(`${name}: ${e instanceof Error ? e.message : String(e)}`); } } return c.json({ backfilled, backfilled_count: backfilled.length, needs_description: needsDescription, needs_description_count: needsDescription.length, errors, hint: needsDescription.length > 0 ? `${needsDescription.length} 個工作流缺 description 無法被搜尋。請操盤的 AI re-deploy 它們時據實補一句「能做什麼」(不自動編造)。` : undefined, }); }); // POST /webhooks/named/migrate-cron-index — 一次性 migration(8.P0):把舊的 per-key // cron-idx:{apiKey}:{name} 折進單一 cron-idx:_all(這裡才 list 一次,非每分鐘 tick)。 // 增量寫、不刪舊 key(重跑安全、冪等)。部署 8.P0 後跑一次,讓既有 cron workflow 不漏掉。 // 必須在 /:name/trigger 之前註冊,否則 :name 會攔截 "migrate-cron-index"。 webhooksNamedRouter.post('/webhooks/named/migrate-cron-index', async (c) => { const list = await c.env.WEBHOOKS.list({ prefix: 'cron-idx:' }); let migrated = 0, skipped = 0; const errors: string[] = []; for (const k of list.keys) { if (k.name === CRON_INDEX_KEY) { skipped++; continue; } // 跳過新的集中 key 自己 const parts = k.name.split(':'); // cron-idx:{apiKey}:{name} if (parts.length < 3) { skipped++; continue; } const apiKey = parts[1]; const name = parts.slice(2).join(':'); try { const raw = await c.env.WEBHOOKS.get(k.name, 'text'); if (!raw) { skipped++; continue; } const idx = JSON.parse(raw) as { cron_expr?: string }; if (!idx.cron_expr) { skipped++; continue; } await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, idx.cron_expr); migrated++; } catch (e) { errors.push(`${k.name}: ${e instanceof Error ? e.message : String(e)}`); } } return c.json({ success: errors.length === 0, migrated, skipped, errors }); }); // POST /webhooks/named/:name/trigger — 觸發執行(api_key 走 header;標準/向後相容) webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } return triggerNamed(c, apiKey, c.req.param('name')); }); // POST /webhooks/named/:ns/:name/trigger — namespace 走 URL path(給公開表單用)。 // self-hosted namespace 是明碼分區標籤(非密碼),故可放 path 讓無法帶 header 的公開呼叫者觸發。 // 要防外部濫用 → 對 webhook 加保護(mindset §6);arcrun 不做授權判斷(mindset §3)。 // SDD: sdk-and-website/self-hosted-init.md(壓測 §7.2 第3點 runtime 觸發) webhooksNamedRouter.post('/webhooks/named/:ns/:name/trigger', async (c) => { return triggerNamed(c, c.req.param('ns'), c.req.param('name')); }); // 共用觸發邏輯(header 路徑與 path 路徑都用,避免分叉) async function triggerNamed( c: Context<{ Bindings: Bindings }>, apiKey: string, name: string, ) { const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text'); if (!raw) { return c.json({ error: `找不到 workflow "${name}",請先執行 acr push` }, 404); } let record: NamedWorkflowRecord; try { record = JSON.parse(raw) as NamedWorkflowRecord; } catch { return c.json({ error: 'workflow 定義損毀' }, 500); } let triggerContext: Record = {}; try { const body = await c.req.json().catch(() => null); if (body && typeof body === 'object') { triggerContext = body as Record; } } catch { // 無 body 時使用空 context } const graph = record.graph as { id?: string; nodes?: unknown[] }; const workflowId = graph.id ?? name; const nodes = Array.isArray(graph.nodes) ? (graph.nodes as GraphNode[]) : []; const userAgent = c.req.header('User-Agent') ?? undefined; // resumable-workflow SDD §5:?async=1 → 背景執行(waitUntil)+ 立回 202,不依賴呼叫端連線。 // 不帶 ?async=1 維持原同步行為(向後相容)。 if (c.req.query('async') === '1') { c.executionCtx.waitUntil( executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent) .then(result => writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''), ), ); return c.json({ accepted: true }, 202); } const result = await executeWebhookGraph( c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent, ); c.executionCtx.waitUntil( writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''), ); return c.json(result, result.success ? 200 : 500); } // ── 同步查詢 trigger(sync query)───────────────────────────────────────────── // // 動機:named webhook 的 /trigger 預設路徑雖已同步(await),但它回傳的是 // `{ success, data, trace, duration_ms }` **信封**、且只有 POST 形態。 // 查詢面(console / MCP 打 graph neighbors / traverse 之類)需要 request→response // **直接拿「工作流最終節點輸出」** 當 HTTP response body,且常是一個 GET。 // 這組端點補上這個泛化:同步 await 執行 workflow graph → 把 **result.data(最終節點輸出)本身** // 當 response body 回(非 202、非信封)。補上它,任何查詢端點都能是一個 workflow。 // // 認證:沿用 X-Arcrun-API-Key(header 形態)或 namespace 走 path(公開形態,與 /trigger path 版對稱; // self-hosted namespace 是明碼分區標籤非密碼,故可放 path — mindset §3 arcrun 不做授權判斷)。 // 誠實(mindset §7):節點失敗回錯誤 + trace 摘要(非把錯誤當輸出假綠);paused 工作流無法同步 // 給答案 → 明講(409),不假裝成功。 // 同步查詢輸出上限(防超大 response body 撐爆 Worker / 呼叫端)。 // 超過 → 回 413 + 誠實錯誤(請在 workflow 內先聚合/分頁),不截斷假裝成功。 const MAX_QUERY_OUTPUT_BYTES = 5 * 1024 * 1024; // 5 MiB // GET:把 query string 全部欄位當 triggerContext(值皆 string)。 function queryStringContext(c: Context<{ Bindings: Bindings }>): Record { return { ...c.req.query() }; } // POST:body(JSON object)當 triggerContext;非物件 / 無 body → 空 context。 async function bodyContext(c: Context<{ Bindings: Bindings }>): Promise> { const body = await c.req.json().catch(() => null); return body && typeof body === 'object' ? (body as Record) : {}; } // 共用同步查詢邏輯(header 路徑與 path 路徑、GET 與 POST 都用,避免分叉)。 async function queryNamed( c: Context<{ Bindings: Bindings }>, apiKey: string, name: string, triggerContext: Record, ) { const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text'); if (!raw) { return c.json({ error: `找不到 workflow "${name}",請先執行 acr push` }, 404); } let record: NamedWorkflowRecord; try { record = JSON.parse(raw) as NamedWorkflowRecord; } catch { return c.json({ error: 'workflow 定義損毀' }, 500); } const graph = record.graph as { id?: string; nodes?: unknown[] }; const workflowId = graph.id ?? name; const nodes = Array.isArray(graph.nodes) ? (graph.nodes as GraphNode[]) : []; const userAgent = c.req.header('User-Agent') ?? undefined; // 同步執行(await,非 waitUntil):查詢端點必須 request→response 拿到結果。 const result = await executeWebhookGraph( c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent, ); // 執行判決寫入不阻塞回應(waitUntil,與 /trigger 一致)。 c.executionCtx.waitUntil( writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''), ); if (!result.success) { // paused(如 claude_api 等外部 callback resume)無法同步給答案 → 明講,不假裝成功。 const paused = typeof result.error === 'string' && /workflow paused/i.test(result.error); return c.json( { success: false, error: result.error ?? '工作流執行失敗', trace: result.trace, ...(paused ? { paused: true, hint: '此工作流會暫停等待非同步 callback,無法當同步查詢端點;改用 /webhooks/named/:name/trigger?async=1 + /workflows/resume。' } : {}), }, paused ? 409 : 500, ); } // 成功 → 回「最終節點輸出」本身當 response body(非 202、非信封)。 const serialized = JSON.stringify(result.data ?? null); const byteLen = new TextEncoder().encode(serialized).byteLength; if (byteLen > MAX_QUERY_OUTPUT_BYTES) { return c.json( { success: false, error: `查詢輸出過大(${byteLen} bytes > 上限 ${MAX_QUERY_OUTPUT_BYTES})。請在 workflow 內先聚合 / 分頁再回。`, }, 413, ); } return new Response(serialized, { status: 200, headers: { 'Content-Type': 'application/json; charset=UTF-8', 'X-Arcrun-Duration-Ms': String(result.duration_ms), }, }); } // GET /webhooks/named/:name/query — header 認證,input 走 query string(console/MCP 主用) webhooksNamedRouter.get('/webhooks/named/:name/query', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); return queryNamed(c, apiKey, c.req.param('name'), queryStringContext(c)); }); // POST /webhooks/named/:name/query — header 認證,input 走 body webhooksNamedRouter.post('/webhooks/named/:name/query', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); return queryNamed(c, apiKey, c.req.param('name'), await bodyContext(c)); }); // POST /webhooks/named/:ns/:name/query — namespace 走 path(公開查詢,與 /trigger path 版對稱) webhooksNamedRouter.post('/webhooks/named/:ns/:name/query', async (c) => { return queryNamed(c, c.req.param('ns'), c.req.param('name'), await bodyContext(c)); }); // GET /q/:ns/:name — 簡短查詢入口(namespace 走 path,input 走 query string) webhooksNamedRouter.get('/q/:ns/:name', async (c) => { return queryNamed(c, c.req.param('ns'), c.req.param('name'), queryStringContext(c)); }); // GET /webhooks/named — 列出當前 api_key 下所有 workflow webhooksNamedRouter.get('/webhooks/named', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } const prefix = `${apiKey}:wf:`; const list = await c.env.WEBHOOKS.list({ prefix }); // workflow-discovery 方向①:list 回完整欄位(description/created_at),讓 MCP u6u_list_workflows // 改讀本端點時欄位齊(取代舊的讀 workflow_metadata record)。需 get 每個 record 取 description。 const baseUrl = new URL(c.req.url).origin; const result = await Promise.all( list.keys.map(async (k) => { const name = k.name.slice(prefix.length); const raw = await c.env.WEBHOOKS.get(k.name, 'text'); const rec = raw ? (JSON.parse(raw) as NamedWorkflowRecord) : null; return { name, description: rec?.description ?? '', created_at: rec?.created_at ?? '', cron_expr: rec?.cron_expr, webhook_url: `${baseUrl}/webhooks/named/${name}/trigger`, }; }), ); return c.json({ workflows: result, total: result.length }); }); // DELETE /webhooks/named/:name — 刪除 workflow webhooksNamedRouter.delete('/webhooks/named/:name', 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 existing = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text'); if (!existing) { return c.json({ error: `找不到 workflow "${name}"` }, 404); } await c.env.WEBHOOKS.delete(kvKey(apiKey, name)); await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, null); return c.json({ deleted: true, name }); });