diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index 7595c42..3ad8595 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -94,6 +94,14 @@ const LOGIC_BINDING_MAP: Record = { export function createComponentLoader(env: Bindings) { return async (componentId: string): Promise => { + // 0. 平台內建 orchestration 零件(需要 env / 跨 workflow 能力) + // 這類零件「是 orchestrator 的職責」(不是業務邏輯),故不違反「業務邏輯走 WASM」規則。 + // 目前只有 trigger_workflow:用 in-process call 觸發另一個 named workflow, + // 繞掉 CF 同 zone self-fetch 死鎖(避免 cypher-executor 自打 http_request → 1042)。 + if (componentId === 'trigger_workflow') { + return makeTriggerWorkflowRunner(env); + } + // 1. 內建零件(純 JS,最優先) const builtin = BUILTIN_COMPONENTS.get(componentId); if (builtin) return builtin; @@ -152,6 +160,68 @@ export function createComponentLoader(env: Bindings) { // ── 執行器工廠 ──────────────────────────────────────────────────────────────── +/** + * trigger_workflow 內建 orchestration 零件 + * + * 用途:在 workflow A 內 in-process 觸發 workflow B,繞 CF 同 zone self-fetch 死鎖。 + * + * 動機:mira_feed_watcher 之前用 http_request 自打 cypher.arcrun.dev → CF 1042。 + * 就算改打 arcrun-cypher-executor.{subdomain}.workers.dev,Worker → 自身 URL 仍 + * 被 CF 「self subrequest」防護擋(即使 hostname 不同)。 + * 改用 in-process call executeWebhookGraph 徹底繞掉外部 HTTP。 + * + * 不違反「業務邏輯走 WASM」鐵律:trigger_workflow 是 orchestrator 自己的 routing 能力 + * (像 CALLS_SUBFLOW),不是業務邏輯(不解密 / 不簽 JWT / 不打外部 API)。 + * + * Input ctx: + * - workflow_name: string (必填,目標 workflow 名稱) + * - api_key: string (必填,KV 查 key prefix) + * - input: object (可選,傳給子 workflow 當 triggerContext) + * - wait: boolean (預設 true,await 完成;false = fire-and-forget 用 waitUntil) + * + * 動態 import webhook-handlers 避循環依賴(webhook-handlers → component-loader → 自己)。 + */ +function makeTriggerWorkflowRunner(env: Bindings): ComponentRunner { + return async (ctx: unknown) => { + const c = (ctx && typeof ctx === 'object') ? ctx as Record : {}; + const workflowName = String(c.workflow_name ?? ''); + const apiKey = String(c.api_key ?? ''); + const input = (c.input && typeof c.input === 'object') + ? c.input as Record + : {}; + const wait = c.wait !== false; // 預設 true + + if (!workflowName) return { success: false, error: 'trigger_workflow 缺 workflow_name' }; + if (!apiKey) return { success: false, error: 'trigger_workflow 缺 api_key' }; + + // 從 WEBHOOKS KV 撈目標 workflow 的 graph + const wfKey = `${apiKey}:wf:${workflowName}`; + const wfRaw = await env.WEBHOOKS.get(wfKey, 'text'); + if (!wfRaw) return { success: false, error: `找不到 workflow "${workflowName}" (key=${wfKey})` }; + + let record: { graph?: Record }; + try { record = JSON.parse(wfRaw); } + catch { return { success: false, error: `workflow "${workflowName}" KV 內容非 JSON` }; } + if (!record.graph) return { success: false, error: `workflow "${workflowName}" 缺 graph 欄位` }; + + // 動態 import 避循環依賴 + const { executeWebhookGraph } = await import('../actions/webhook-handlers'); + + const triggerContext = { ...input, _triggered_by: 'trigger_workflow' }; + + if (wait) { + const r = await executeWebhookGraph(env, record.graph, triggerContext, workflowName, apiKey); + return { success: r.success, triggered_workflow: workflowName, sub_result: r }; + } else { + // fire-and-forget — 不 await,但因為沒拿到 ctx.waitUntil,這裡 promise 可能被 cancel + // 目前不啟用,留 wait=true 為預設。未來想要 fire-and-forget 需 plumb ExecutionContext + void executeWebhookGraph(env, record.graph, triggerContext, workflowName, apiKey) + .catch((e) => console.error('[trigger_workflow] fire-and-forget fail', workflowName, e)); + return { success: true, triggered_workflow: workflowName, mode: 'fire_and_forget' }; + } + }; +} + function makeHttpRunner(url: string): ComponentRunner { return async (ctx: unknown) => { const res = await fetch(url, {