/** * POST /workflows/resume * Webhook callback 進來時,從 paused state 撿起來繼續跑下游節點 * SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md Phase 3 * * 安全:因為這是 daemon 主動 callback,沒有 partner key(daemon 不知道用戶 key) * 靠 task_id 為 nonce + 24h TTL + idempotent consume 保護 */ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { WorkflowPaused } from '../types'; import { GraphExecutor } from '../graph-executor'; import { createComponentLoader } from '../lib/component-loader'; import { consumePausedRun } from '../lib/paused-runs'; export const resumeRouter = new Hono<{ Bindings: Bindings }>(); resumeRouter.post('/workflows/resume', async (c) => { let body: Record; try { body = await c.req.json(); } catch { return c.json({ error: 'request body 必須為 JSON' }, 400); } const taskId = typeof body.task_id === 'string' ? body.task_id : undefined; if (!taskId) { return c.json({ error: 'task_id 必填' }, 400); } // consume = load + delete(idempotent:重複 callback 第二次找不到 state,回 200) const state = await consumePausedRun(c.env.EXEC_CONTEXT, taskId); if (!state) { return c.json({ success: true, noop: true, reason: `paused state 不存在或已過期 (task_id=${taskId})`, }); } const callbackResult = { success: body.success ?? true, data: body.data, error: body.error, }; const loader = createComponentLoader(c.env); const executor = new GraphExecutor(loader, undefined, c.env, state.api_key); const start = Date.now(); try { const result = await executor.resumeFromPaused({ graph: state.graph, paused_node_id: state.paused_node_id, paused_context: state.paused_context, callback_result: callbackResult, prior_trace: state.trace_so_far, kvNamespace: c.env.EXEC_CONTEXT, recipe_output_format: state.recipe_output_format, recipe_output_required_fields: state.recipe_output_required_fields, }); const duration_ms = Date.now() - start; return c.json({ success: true, resumed: true, task_id: taskId, run_id: state.run_id, data: result.data, trace: result.trace, duration_ms, }); } catch (err) { if (err instanceof WorkflowPaused) { // resume 後又遇到 pending(v2 nested 情境)— v1 仍持久化但回 paused-again return c.json({ success: true, paused_again: true, task_id: err.task_id, run_id: err.run_id, paused_node_id: err.paused_node_id, }); } const errMsg = err instanceof Error ? err.message : String(err); return c.json({ success: false, error: errMsg, task_id: taskId, run_id: state.run_id }, 500); } });