diff --git a/.claude/rules/01-tech-stack.md b/.claude/rules/01-tech-stack.md index 4cd393a..a12cb8d 100644 --- a/.claude/rules/01-tech-stack.md +++ b/.claude/rules/01-tech-stack.md @@ -63,8 +63,14 @@ ## 網路部署 -- **平台 API**:`cypher.arcrun.dev`(cypher-executor) -- **每個零件**:獨立 Worker,URL 慣例 `{component-name-kebab}.arcrun.dev` - - 例:`auth-static-key.arcrun.dev`、`gmail.arcrun.dev` +- **平台 API(對外)**:`cypher.arcrun.dev`(cypher-executor) - **Landing**:`arcrun.dev` +- **每個零件 Worker**: + - **對內(cypher-executor 用來 fetch component,避開同 zone 死鎖)**:`arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev` + - 例:`arcrun-kbdb-get.uncle6-me.workers.dev` + - cypher-executor 從 `wrangler.toml [vars] WORKER_SUBDOMAIN` 組出此 URL + - **對外(可選,零件對全網開放被 curl 用)**:`{kebab}.arcrun.dev` + - 例:`gmail.arcrun.dev`、`kbdb-get.arcrun.dev` + - 仍允許保留,但**禁止 cypher-executor 透過此 URL fetch**(會撞同 zone 自循環,見 [docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md)) + - **新增 component worker 部署清單**:`name = "arcrun-{kebab}"` + `[[routes]]` 對外(可選)+ dashboard 啟用 workers.dev(必須) - **部署工具**:Wrangler diff --git a/.claude/rules/03-component-architecture.md b/.claude/rules/03-component-architecture.md index e0678e9..9794c0f 100644 --- a/.claude/rules/03-component-architecture.md +++ b/.claude/rules/03-component-architecture.md @@ -1,15 +1,23 @@ # 零件架構與部署模式(必讀,CC 最常搞錯的地方) -## 第一核心概念:每個 WASM 零件 = 一個獨立 Worker = 一個公開 URL +## 第一核心概念:每個 WASM 零件 = 一個獨立 Worker = **兩個** URL **不是**從 R2 即時載入 WASM 執行。 **不是**用 service binding 串零件。 **不是**一個 Worker 裡跑多個零件。 -**是**:每個零件都是獨立部署的 Worker,每個都有自己的 URL,例如: -- `https://if-control.arcrun.dev` -- `https://gmail.arcrun.dev` -- `https://auth-static-key.arcrun.dev` +**是**:每個零件都是獨立部署的 Worker,每個都有**兩個 URL**: + +| URL 類型 | Pattern | 用途 | +|---|---|---| +| 對內(cypher-executor 用)| `arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev` | cypher-executor fetch component 走這個,避開同 zone 自循環死鎖(P0 #9)| +| 對外(直接 curl 用,可選)| `{kebab}.arcrun.dev` | 用戶單獨打 component 測試或 self-hosted 用法 | + +例:`kbdb_get` 零件: +- 對內:`arcrun-kbdb-get.uncle6-me.workers.dev`(cypher-executor 走這個) +- 對外:`kbdb-get.arcrun.dev`(用戶 / 直 curl) + +**為什麼這樣設計**:CF Workers 「同 zone 自循環防護」會讓綁 `cypher.arcrun.dev/*` 的 cypher-executor fetch 同 zone `*.arcrun.dev` 撞 522。完整事件報告:[docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md)。改走 workers.dev 子域繞過。 ### 零件 Worker 的結構 @@ -141,7 +149,13 @@ credential 解密、JWT signing、template 展開(`{{secret.X}}`)全部屬 ``` - 複製 `auth_static_key.wasm` 到此目錄為 `component.wasm` 4. `cd .component-builds/auth_static_key && pnpm install && pnpm deploy` -5. 驗證:`curl https://auth-static-key.arcrun.dev` → 應回 `{ok: true, component: "auth_static_key"}` -6. 在 cypher-executor 的 auth-dispatcher 註冊對應 URL(或用慣例 `{name}.arcrun.dev`) +5. **Dashboard 啟用 workers.dev URL**(必須,否則 cypher-executor fetch 不到): + - Workers & Pages → `arcrun-auth-static-key` → Settings → Domains & Routes → workers.dev → Enable + - 啟用後 URL:`arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` +6. 驗證對外:`curl https://auth-static-key.arcrun.dev` → 應回 `{ok: true, component: "auth_static_key"}` +7. 驗證對內:`curl https://arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` → 應同樣回 200 +8. cypher-executor 透過 `wasmWorkerUrl()` 自動組對內 URL 呼叫(不用手動註冊) **這是唯一正確的部署流程**。任何偏離這個流程的「替代方案」都要先和 richblack 確認。 + +**Step 5 為什麼必須**:見 arcrun.md P0 #9(2026-05-13)。cypher-executor 走對內 URL 避開同 zone 自循環死鎖;若 workers.dev 未啟用,cypher-executor fetch 該 component 會 404。 diff --git a/cypher-executor/src/actions/auth-dispatcher.ts b/cypher-executor/src/actions/auth-dispatcher.ts index b57fef1..b957f46 100644 --- a/cypher-executor/src/actions/auth-dispatcher.ts +++ b/cypher-executor/src/actions/auth-dispatcher.ts @@ -53,7 +53,8 @@ export async function tryAuthDispatch( if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null; // 走新路徑:HTTP POST 到對應 auth primitive Worker - const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`); + // 走 workers.dev 避開同 zone 死鎖(P0 #9) + const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN); const res = await fetch(primitiveUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index d02c1f4..3c33748 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -375,13 +375,7 @@ export class GraphExecutor { switch (edge.type as EdgeType) { case 'PIPE': { - const baseResult = (typeof result === 'object' && result !== null) - ? (result as Record) - : {}; - const pipeContext: Record = { - ...(context as Record), - ...baseResult, - }; + const pipeContext: Record = propagateCtx(context, result, node.id); if (kvStore) { const kvOutput = await kvGetNodeOutput(kvStore, node.id); @@ -405,17 +399,17 @@ export class GraphExecutor { } case 'ON_SUCCESS': { - // 只在上游節點成功時執行:success !== false 且無 error key if (!isFailure(result)) { - result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore); + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); } break; } case 'ON_FAIL': { - // 只在上游節點失敗時執行:success === false 或有 error key if (isFailure(result)) { - result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore); + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); } break; } @@ -423,23 +417,28 @@ export class GraphExecutor { case 'IF': { const passes = evaluateCondition(edge.condition ?? 'true', result); if (passes) { - result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore); + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); } break; } case 'FOREACH': { const iteratorKey = edge.iterator ?? 'item'; - const items = getIterableFromContext(result, iteratorKey); + // 找 iterable 順序:先看上游 output (result),沒有再看完整 context (含上游 chain 累積的 fields) + // 2026-05-13:原本只看 result,但 result 是當前節點 output (如 create_wiki_page 只回 {data, success}) + // 不含更上游節點給的 paragraphs。propagateCtx 已把 paragraphs spread 進 ctx,FOREACH 該能取到 + let items = getIterableFromContext(result, iteratorKey); + if (items.length === 0) { + items = getIterableFromContext(context, iteratorKey); + } const iterResults: unknown[] = []; - // FOREACH itemContext 順序:原 ctx 全局欄位(api_key 等)優先 < result(上游輸出)< item(當前迭代) - // 之前只 spread result,全局 api_key 會丟,下游 {{api_key}} 抓不到 - const baseCtx = (typeof context === 'object' && context !== null) ? context as Record : {}; + // FOREACH itemContext 順序:propagateCtx + 加 iterator key + const baseForeachCtx = propagateCtx(context, result, node.id); for (const item of items) { const itemContext = { - ...baseCtx, - ...(result as Record), + ...baseForeachCtx, [iteratorKey]: item, }; const itemResult = await this.executeNode(nextNode, graph, itemContext, new Set(), trace, fanIn, kvStore); @@ -470,8 +469,8 @@ export class GraphExecutor { } case 'ON_CLICK': { - // 前端觸發:payload 已在 context 中,直接執行下游節點 - result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore); + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); break; } @@ -498,30 +497,61 @@ export class GraphExecutor { } } -/** node.data 的 string 值支援 {{variable}} 替換,從 context 取值 +/** 給下游節點組 ctx:merge 原 context + 上游 output (spread) + 上游 output 用 node id namespace + * 讓下游能用: + * {{api_key}}(global,從 baseCtx) + * {{data.text}}(上一節點 output spread 進來,會被下下個節點覆蓋) + * {{classify.data.text}}(指名某節點 output,永不被覆蓋因 node id 唯一) + * 優先順位:baseCtx(含先前 node namespace)< 上游 output spread < 當前 node namespace + */ +function propagateCtx( + context: unknown, + upstreamResult: unknown, + upstreamNodeId: string, +): Record { + const baseCtx = (typeof context === 'object' && context !== null) ? context as Record : {}; + const baseResult = (typeof upstreamResult === 'object' && upstreamResult !== null) ? upstreamResult as Record : {}; + return { + ...baseCtx, + ...baseResult, + [upstreamNodeId]: upstreamResult, + }; +} + +/** node.data 內所有 string 值(含 nested object / array)支援 {{variable}} 替換 * 支援嵌套 path:{{item.content}} → ctx.item.content * 支援 array index:{{paragraphs.0.entity}} → ctx.paragraphs[0].entity - * 非 string 值(object/array)會 JSON.stringify + * 非 string 值(object/array)遞迴展開內部 string;undefined / null / number / bool 保留原值 + * 2026-05-13 加遞迴:原本只跑 top-level,set 零件 values 嵌套 / kbdb_create_block content 內含 {{x.y}} 用不了。 */ +function interpolateString(s: string, ctx: Record): string { + return s.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => { + const val = getNestedValue(ctx, key); + if (val === undefined) return `{{${key}}}`; + if (typeof val === 'string') return val; + return JSON.stringify(val); + }); +} + +function interpolateValue(v: unknown, ctx: Record): unknown { + if (typeof v === 'string') return interpolateString(v, ctx); + if (Array.isArray(v)) return v.map(item => interpolateValue(item, ctx)); + if (v !== null && typeof v === 'object') { + const result: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + result[k] = interpolateValue(val, ctx); + } + return result; + } + return v; +} + function interpolateData( data: Record | undefined, ctx: Record, ): Record { if (!data) return {}; - const result: Record = {}; - for (const [k, v] of Object.entries(data)) { - if (typeof v === 'string') { - result[k] = v.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => { - const val = getNestedValue(ctx, key); - if (val === undefined) return `{{${key}}}`; - if (typeof val === 'string') return val; - return JSON.stringify(val); - }); - } else { - result[k] = v; - } - } - return result; + return interpolateValue(data, ctx) as Record; } /** 從 ctx 用 dot path 取嵌套值:'a.b.0.c' → ctx.a.b[0].c */ @@ -580,6 +610,20 @@ function getIterableFromContext(context: unknown, key: string): unknown[] { if (!context || typeof context !== 'object') return []; const plural = key + 's'; const obj = context as Record; - const items = obj[plural] ?? obj[key]; + // 先看 top-level(最常見) + let items = obj[plural] ?? obj[key]; + // 若找不到,掃一層內部 object 看 nested(巢狀 FOREACH 場景: + // 外層 FOREACH 把 paragraph 注入 ctx,內層 FOREACH 要找 paragraph.triplets) + if (!Array.isArray(items)) { + for (const v of Object.values(obj)) { + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const nested = (v as Record)[plural] ?? (v as Record)[key]; + if (Array.isArray(nested)) { + items = nested; + break; + } + } + } + } return Array.isArray(items) ? items : []; } diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index 63e6605..6854360 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -55,9 +55,20 @@ const WASM_HTTP_RUNNER_IDS: ReadonlySet = new Set([ 'kbdb_patch_block', ]); -/** canonical_id → 獨立 Worker URL(慣例:snake_case → kebab-case + .arcrun.dev) */ -export function wasmWorkerUrl(canonicalId: string): string { - return `https://${canonicalId.replace(/_/g, '-')}.arcrun.dev`; +/** + * canonical_id → component worker URL(走 workers.dev 子域,避開同 zone 自循環死鎖) + * + * 為何不用 *.arcrun.dev:cypher-executor 本身綁 cypher.arcrun.dev/*, + * fetch 同 zone *.arcrun.dev 會撞 CF 的 zone 自循環防護回 522。 + * 詳見 arcrun.md P0 #9(2026-05-13)。 + * + * subdomain 來自 wrangler.toml [vars] WORKER_SUBDOMAIN(預設 uncle6-me,self-hosted fork 改自己的)。 + */ +export function wasmWorkerUrl(canonicalId: string, subdomain: string): string { + const kebab = canonicalId.replace(/_/g, '-'); + // 平台慣例:component worker 名稱 = `arcrun-{kebab}`(見 rule 03 / rule 05), + // 例如 canonical_id=kbdb_get → worker 名 arcrun-kbdb-get → URL arcrun-kbdb-get.{subdomain}.workers.dev + return `https://arcrun-${kebab}.${subdomain}.workers.dev`; } /** 邏輯零件 canonical_id → Service Binding key */ @@ -123,9 +134,10 @@ export function createComponentLoader(env: Bindings) { // 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL // Phase 3 後 6 個 API 零件(http_request / gmail / telegram / line_notify / // google_sheets / cron)與 4 個 auth primitive 都從這裡走。 - // 對應 Worker 部署於 `{canonical-id-kebab}.arcrun.dev`(rule 03)。 + // 對應 Worker 部署於 arcrun-{canonical-id-kebab}.{WORKER_SUBDOMAIN}.workers.dev + // (見 P0 #9 / rule 03)。 if (WASM_HTTP_RUNNER_IDS.has(componentId)) { - return makeHttpRunner(wasmWorkerUrl(componentId)); + return makeHttpRunner(wasmWorkerUrl(componentId, env.WORKER_SUBDOMAIN)); } // 8. 找不到 @@ -177,8 +189,8 @@ function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner | } // Service Binding 未配置時 fallback 到公網(自製零件 or 開發環境) - const fallbackUrl = `https://${canonicalId.replace(/_/g, '-')}.arcrun.dev`; - return makeHttpRunner(fallbackUrl); + // 走 workers.dev 子域避開同 zone 死鎖(P0 #9) + return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN)); } function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner { diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 82babf8..9e197ed 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -53,6 +53,10 @@ export type Bindings = { // KBDB 整合 KBDB_INTERNAL_TOKEN?: string; KBDB_BASE_URL?: string; // 預設 https://kbdb.inkstone.app + // Component Worker subdomain(workers.dev 帳號 subdomain) + // 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9) + // self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain + WORKER_SUBDOMAIN: string; }; // 圖結構定義 diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index 2c0ee52..06bdd42 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -105,6 +105,12 @@ ENVIRONMENT = "production" KBDB_BASE_URL = "https://kbdb.finally.click" # KBDB_INTERNAL_TOKEN 透過 wrangler secret set 設定 +# Component worker subdomain(workers.dev 帳號 subdomain) +# cypher-executor fetch component worker 一律走 arcrun-{name}.{WORKER_SUBDOMAIN}.workers.dev +# 避開同 zone (*.arcrun.dev) 自循環死鎖,見 arcrun.md P0 #9(2026-05-13) +# Self-hosted fork:改成自己的 CF 帳號 subdomain(Workers & Pages → 你的帳號 → subdomain settings) +WORKER_SUBDOMAIN = "uncle6-me" + [[routes]] pattern = "cypher.arcrun.dev/*" zone_name = "arcrun.dev"