5c5109cd45
# Conflicts: # system-dev/docs/3-specs/workflow-discovery/tasks.md
538 lines
23 KiB
TypeScript
538 lines
23 KiB
TypeScript
/**
|
||
* 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';
|
||
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-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<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_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<string, unknown>;
|
||
config?: Record<string, unknown>;
|
||
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';
|
||
|
||
// KBDB 轉發抽到 lib/workflow-search.ts(t159 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 — 一次性 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<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 ?? ''),
|
||
),
|
||
);
|
||
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<string, unknown> {
|
||
return { ...c.req.query() };
|
||
}
|
||
|
||
// POST:body(JSON 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 ?? ''),
|
||
);
|
||
|
||
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/:name/definition — 吐 workflow 的可攜定義(t158 export 原語)。
|
||
// leo 07-31:「如果我要把我做的工作流分享給同事,我要怎麼 export?他要如何 import?
|
||
// 在從前就是寫成幾個 yaml 丟過去讓新的送進 KBDB 不是嗎?」
|
||
// 回 record 原樣(graph+config+description)=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 });
|
||
});
|