Files
Arcrun/cypher-executor/src/routes/webhooks-named.ts
T
uncle6me-web 60688c3108 fix(kv-quota): workflow 執行紀錄搬離 KV,改走 KBDB template 機制(A1/A2/A7)
事故:cypher-executor/src/actions/execution-logger.ts 舊版每跑完一次 workflow 就
ANALYTICS_KV.put() 一筆新 key(註解寫「避免覆蓋」)= 只增不減,封測者 Evan 處理約 690 個
檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write),整個實例 429。

A1 少記:workflow 執行紀錄改走 KBDB template 機制(entries 表 entry_type='execution_log',
kbdb/migrations/0004_execution_log_template.sql 只 seed 一列 template 定義,零建表/改表)。
儲存精神比照既有 recipe_stat(kbdb/src/actions/recipe-stat.ts):template 只負責文件化,
實際一筆執行是 entries 表一列(1 次執行=1 次 D1 寫入,不走 entry_values 全展開)。欄位收斂:
時間/workflow/verdict/duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記(訊息截斷長度
不對稱:200 vs 2000 字)。target 只認 trigger context 的 page_name/path,不整包存 input。

A2 自我降級:D1 額度仍與知識卡共用同一顆 100,000 rows/日,本模組自設 20% 軟上限(可用
EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫),超過 80% 降成只記失敗、超過 100% 完全停止記錄,
但 workflow 執行永遠照跑(cypher-executor 端 fire-and-forget 永不 throw)。

A7 讀取端:/workflows/:name/executions、/portal/data/workflows 的 last_execution、MCP
list_recent_executions 全部改打 KBDB HTTP API(GET /execution-log、/execution-log/latest),
取代原本的 ANALYTICS_KV list/get(免費層 list 也是 1,000/日)。

架構鐵律修正(本次施工中兩度被抓到走偏,過程留痕於 commit 訊息供後續參考):
- KBDB 三張表打天下(entries/templates/entry_values),永遠不加新 table——新資料類型
  一律用 template + entries,不建表、不 ALTER TABLE。
- KBDB = API-as-Wall,零 SQL:cypher-executor 端一律走 KBDB 的 HTTP API(連法比照既有
  recordRecipeStats/kbdbFetch 慣例),不直連任何 D1、不對 arcrun-kbdb 下任何原生 SQL。

順帶修復:kbdb/src/actions/entry-crud.ts listEntries 的 ORDER BY 補 `, rowid DESC` 二級
排序——entries.created_at 是 unixepoch() 秒級解析度,高頻寫入(execution_log 一秒內多筆)
常同秒,單靠 created_at DESC 不保證「最新一筆」正確,此為本次測試(latestExecutionLog)
發現的既有潛在缺陷,順手補上決定性排序,不改變任何既有查詢在 created_at 不同時的行為。

隔離:portal-data.ts INTERNAL_ENTRY_TYPES 加入 execution_log/execution_log_usage(與既有
value/workflow 同層級排除),避免用戶知識搜尋混進執行 log;本模組從不設 metadata_json.embed,
故永不進 Vectorize 語意搜尋索引。

不動:registry/src/actions/recordAnalytics.ts(零件市場統計,獨立 Worker、獨立 KV 命名空間、
不同資料模型,非本次事故根因所指範圍);cypher-executor/{wrangler.toml,kbdb/wrangler.toml}
未變動(repo 層級 deny 規則保護這兩個生產設定檔不被 AI 編輯)——ANALYTICS_KV binding
因此仍留在 wrangler.toml 宣告中但程式碼零讀寫點(見 PR 說明的完整 grep 佐證)。

KV 裡既有的 stats:* 舊資料不搬移(是統計不是真相源,維持原樣任其依 90 天 TTL 自然過期)。

測試:kbdb/tests/execution-log.test.ts(13 個,含零建表證明/少記/A2 降級/route)、
cypher-executor/tests/execution-logger.test.ts(payload 正確性/永不 throw)、
cypher-executor/tests/executions-route.test.ts(讀取端轉發)、portal-data.test.ts 對應區塊
改寫。kbdb 全測試 104/104 通過;cypher-executor 320 個測試中 9 個失敗為 main 既有(與本次
改動無關,改動前後 stash 對照確認)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:13:00 +08:00

538 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Named Webhookacr 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';
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
type NamedWorkflowRecord = {
name: string;
graph: Record<string, unknown>;
config?: Record<string, unknown>;
description: string;
created_at: string;
// 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對
// 對應 SDD: arcrun.md 三-A P1 #3
cron_expr?: string;
// 暴露 consent 閘已移除(leo 2026-06-29Arcrun#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 bindingrule 02 §3.1)。
*/
async function writeWorkflowSearchEntry(
env: Bindings,
apiKey: string,
name: string,
description: string,
workflowId?: string,
): Promise<void> {
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}`;
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_jsonTEXT),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<string, unknown>;
config?: Record<string, unknown>;
description?: string;
} | null;
if (!body?.name || !body.graph) {
return c.json({ error: '缺少必要欄位:name, graph' }, 400);
}
// workflow-discovery R1description 強制非空(供語意搜尋,工作流可被發現)。
// 定位(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-29Arcrun#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 key8.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_hintKBDB 端已實作 #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';
// 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' } });
});
// 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 — 一次性 migration8.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<string, unknown> = {};
try {
const body = await c.req.json().catch(() => null);
if (body && typeof body === 'object') {
triggerContext = body as Record<string, unknown>;
}
} 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 ?? '', triggerContext, apiKey),
),
);
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 ?? '', triggerContext, apiKey),
);
return c.json(result, result.success ? 200 : 500);
}
// ── 同步查詢 triggersync 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-Keyheader 形態)或 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<string, unknown> {
return { ...c.req.query() };
}
// POSTbodyJSON object)當 triggerContext;非物件 / 無 body → 空 context。
async function bodyContext(c: Context<{ Bindings: Bindings }>): Promise<Record<string, unknown>> {
const body = await c.req.json().catch(() => null);
return body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
}
// 共用同步查詢邏輯(header 路徑與 path 路徑、GET 與 POST 都用,避免分叉)。
async function queryNamed(
c: Context<{ Bindings: Bindings }>,
apiKey: string,
name: string,
triggerContext: Record<string, unknown>,
) {
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 ?? '', triggerContext, apiKey),
);
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 stringconsole/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 走 pathinput 走 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/: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');
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 });
});