/** * Executions routes — LI SDD M2.1 * * 對應 .agents/specs/llm-interface/ Milestone 2.1。給 AI 看 workflow 執行狀態的端點。 * * - GET /executions/paused — 列當前所有 paused 的 workflow(等 callback resume) * - GET /executions/:task_id — 看單一 paused state 細節(含 trace、graph、node id) * - GET /workflows/:name/executions — 列某 workflow 最近 N 次執行 verdict * * 設計:純讀,無 side effect。所有路由要 api_key auth(防偷看他人 workflow state)。 */ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { listPausedRunsByApiKey } from '../lib/paused-runs'; import { kbdbBase } from './kbdb-proxy'; export const executionsRouter = new Hono<{ Bindings: Bindings }>(); /** * GET /executions/paused — 列當前 api_key 下所有 paused workflow * * 走 per-user index `paused_idx:{api_key}`(單 KV get,強 consistent,無 KV list 延遲) * 取代舊的 `paused_run:*` prefix scan(CF KV list 30-60 秒 eventual consistent) */ executionsRouter.get('/executions/paused', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ ok: false, error_code: 'auth_missing', human_message: '缺 X-Arcrun-API-Key header', next_actions: ['call /me 取得你的 ak_xxx,加進 header'], }, 401); } const limitParam = c.req.query('limit'); const limit = Math.min(Math.max(parseInt(limitParam || '20', 10), 1), 100); const paused = await listPausedRunsByApiKey(c.env.EXEC_CONTEXT, apiKey, limit); return c.json({ ok: true, data: { count: paused.length, paused }, hints: paused.length > 0 ? [`${paused.length} 個 workflow 等 callback resume。call get_execution_trace(task_id) 看細節`] : ['沒有任何 paused workflow'], }); }); /** * GET /executions/:task_id — 看單一 paused workflow 的 state(trace、graph、context) * * task_id 來源:trigger workflow 時 response 含 paused 結果,task_id 在 error 字串裡, * 或前端 list_paused_executions 回的 task_id。 * * 隔離:只能讀自己 api_key 的 state。 */ executionsRouter.get('/executions/:task_id', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ ok: false, error_code: 'auth_missing', human_message: '缺 X-Arcrun-API-Key header', next_actions: ['加 X-Arcrun-API-Key header'], }, 401); } const taskId = c.req.param('task_id'); const raw = await c.env.EXEC_CONTEXT.get(`paused_run:${taskId}`); if (!raw) { return c.json({ ok: false, error_code: 'not_found', human_message: `task_id "${taskId}" 沒對應的 paused state(可能已 resume 完、過 24h TTL 被 GC、或從未存在)`, next_actions: [ 'call /executions/paused 看當前所有 paused,確認 task_id 正確', '若該 workflow 不是 paused 型,看 /workflows/:name/executions 查歷史 verdict', ], }, 404); } let state: { run_id: string; graph?: unknown; paused_node_id: string; paused_context?: Record; paused_pending_result?: Record; trace_so_far?: unknown; api_key?: string; expires_at?: number; }; try { state = JSON.parse(raw); } catch { return c.json({ ok: false, error_code: 'internal_error', human_message: 'paused state JSON 損毀', next_actions: ['告訴 leo / 平台維護者'], }, 500); } if (state.api_key !== apiKey) { return c.json({ ok: false, error_code: 'not_found', // 不洩漏存在性 human_message: `task_id "${taskId}" 找不到`, next_actions: ['確認 task_id 屬於你 (用 /executions/paused 列出)'], }, 404); } return c.json({ ok: true, data: { task_id: taskId, run_id: state.run_id, paused_node_id: state.paused_node_id, paused_context: state.paused_context, paused_pending_result: state.paused_pending_result, trace_so_far: state.trace_so_far, expires_at: state.expires_at, }, hints: [ 'paused 狀態 = workflow 等 daemon callback。等對應 service 回 POST /workflows/resume 即可繼續', '若 daemon 掛了,看 expires_at — 過 24h KV TTL 會 GC 此 state', ], }); }); /** * GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict * * KV 額度事故修復(2026-08-07):改打 KBDB `GET /execution-log`(原走 ANALYTICS_KV * `stats:{workflowId}:*` prefix scan,免費層 list 也是 1,000/日,裝十幾支 workflow * 的實例刷 90 次 portal 就見底)。KBDB=API-as-Wall(leo 2026-06-14):本路由**不直連 * 任何 D1**,一律走 HTTP,連法比照既有 kbdbBase() 慣例(kbdb-proxy.ts)。 * * workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name,與舊 KV * key 同語意,沿用既有限制不在這次修復裡處理)。 */ executionsRouter.get('/workflows/:name/executions', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ ok: false, error_code: 'auth_missing', human_message: '缺 X-Arcrun-API-Key header', next_actions: ['加 X-Arcrun-API-Key header'], }, 401); } const name = c.req.param('name'); const limitParam = c.req.query('limit'); const limit = Math.min(Math.max(parseInt(limitParam || '10', 10), 1), 100); // 確認 workflow 是該 api_key 的(防偷看他人) const wfRaw = await c.env.WEBHOOKS.get(`${apiKey}:wf:${name}`, 'text'); if (!wfRaw) { return c.json({ ok: false, error_code: 'not_found', human_message: `workflow "${name}" 不存在或不屬於你`, next_actions: ['call /webhooks/named 看你有什麼 workflow'], }, 404); } const { base, headers } = kbdbBase(c.env); const params = new URLSearchParams({ workflow_id: name, owner_id: apiKey, limit: String(limit) }); const kbdbRes = await fetch(`${base}/execution-log?${params.toString()}`, { headers }); const kbdbBody = await kbdbRes.json().catch(() => null) as { success?: boolean; executions?: Array<{ verdict: string; duration_ms: number; message: string; target?: string; recorded_at: number; }> } | null; const executions = (kbdbRes.ok && kbdbBody?.success ? kbdbBody.executions ?? [] : []).map((r) => ({ timestamp: String(r.recorded_at), workflow_id: name, verdict: r.verdict, duration_ms: r.duration_ms, message: r.message ?? '', ...(r.target ? { target: r.target } : {}), })); return c.json({ ok: true, data: { workflow_name: name, count: executions.length, executions, }, hints: executions.length === 0 ? ['尚未有任何執行紀錄。先 call /webhooks/named/:name/trigger 跑一次'] : [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`], }); });