feat(cypher-executor): trigger_workflow 內建零件 — 繞 CF self-fetch

mira_feed_watcher 之前用 http_request 自打 cypher.arcrun.dev / 自身 workers.dev URL
都被 CF self-fetch 防護擋(Worker→自身的 subrequest 攔截)。

新增 `trigger_workflow` 內建 orchestration 零件:
- 在 createComponentLoader 最前面攔截 component_id === 'trigger_workflow'
- 從 WEBHOOKS KV 撈 `{api_key}:wf:{name}` 拿 graph
- 動態 import 避循環依賴
- in-process 呼叫 executeWebhookGraph,沒有任何外部 HTTP
- 預設 wait=true(claude_api paused 仍視為 success 回傳)

不違反「業務邏輯走 WASM」鐵律:trigger_workflow 是 orchestrator 自己的 routing
能力(像既有的 CALLS_SUBFLOW),不是業務邏輯。

對應 mira_feed_watcher.yaml 同步改用此零件(在 polaris/mira/ repo)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:54:26 +08:00
parent 1084e0102a
commit b8ecef0b41
@@ -94,6 +94,14 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
export function createComponentLoader(env: Bindings) { export function createComponentLoader(env: Bindings) {
return async (componentId: string): Promise<ComponentRunner> => { return async (componentId: string): Promise<ComponentRunner> => {
// 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,最優先) // 1. 內建零件(純 JS,最優先)
const builtin = BUILTIN_COMPONENTS.get(componentId); const builtin = BUILTIN_COMPONENTS.get(componentId);
if (builtin) return builtin; 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.devWorker → 自身 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 (預設 trueawait 完成;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<string, unknown> : {};
const workflowName = String(c.workflow_name ?? '');
const apiKey = String(c.api_key ?? '');
const input = (c.input && typeof c.input === 'object')
? c.input as Record<string, unknown>
: {};
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<string, unknown> };
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 { function makeHttpRunner(url: string): ComponentRunner {
return async (ctx: unknown) => { return async (ctx: unknown) => {
const res = await fetch(url, { const res = await fetch(url, {