fix(cypher): deploy P0 #9/#10/#10衍生 fixes (workers.dev URL + nested FOREACH + propagateCtx)

arcrun.md 一直標  已解決但 fix 在 working tree 沒推。今天 mira 7B.3f 端對端
跑不通才發現 production 還是舊版(fetch *.arcrun.dev 同 zone 自循環 → 522)。

涵蓋:
- P0 #9: wasmWorkerUrl() 從 *.arcrun.dev 改 arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev
  + types.ts/wrangler.toml 加 WORKER_SUBDOMAIN binding (uncle6-me)
  + auth-dispatcher.ts 用新 signature
- P0 #10A: interpolateData() 拆 interpolateString + interpolateValue 遞迴 nested
- P0 #10B: propagateCtx() helper 把上游 output spread + 用 node id namespace 存
  讓下游能 {{node_id.data.text}} 永不被覆蓋。5 個 edge type 全用此 helper
- P0 #10C: FOREACH 找 iterable 先看 result 沒有再看 ctx + 掃 nested object 一層
  解雙重 FOREACH(paragraph→triplets)內層跑 0 次

rules/01-tech-stack.md + rules/03-component-architecture.md 同步補 workers.dev 慣例說明。

未推 5 個 worker 改動,今晚才發現實際沒部署過。
This commit is contained in:
2026-05-14 11:02:44 +08:00
parent 4e746986b4
commit 6f6e31dbee
7 changed files with 142 additions and 55 deletions
+9 -3
View File
@@ -63,8 +63,14 @@
## 網路部署
- **平台 API**`cypher.arcrun.dev`cypher-executor
- **每個零件**:獨立 WorkerURL 慣例 `{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
+21 -7
View File
@@ -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 #92026-05-13)。cypher-executor 走對內 URL 避開同 zone 自循環死鎖;若 workers.dev 未啟用,cypher-executor fetch 該 component 會 404。
@@ -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' },
+78 -34
View File
@@ -375,13 +375,7 @@ export class GraphExecutor {
switch (edge.type as EdgeType) {
case 'PIPE': {
const baseResult = (typeof result === 'object' && result !== null)
? (result as Record<string, unknown>)
: {};
const pipeContext: Record<string, unknown> = {
...(context as Record<string, unknown>),
...baseResult,
};
const pipeContext: Record<string, unknown> = 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 進 ctxFOREACH 該能取到
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<string, unknown> : {};
// FOREACH itemContext 順序:propagateCtx + 加 iterator key
const baseForeachCtx = propagateCtx(context, result, node.id);
for (const item of items) {
const itemContext = {
...baseCtx,
...(result as Record<string, unknown>),
...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,31 +497,62 @@ export class GraphExecutor {
}
}
/** node.data 的 string 值支援 {{variable}} 替換,從 context 取值
/** 給下游節點組 ctxmerge 原 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<string, unknown> {
const baseCtx = (typeof context === 'object' && context !== null) ? context as Record<string, unknown> : {};
const baseResult = (typeof upstreamResult === 'object' && upstreamResult !== null) ? upstreamResult as Record<string, unknown> : {};
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遞迴展開內部 stringundefined / null / number / bool 保留原值
* 2026-05-13 加遞迴:原本只跑 top-levelset 零件 values 嵌套 / kbdb_create_block content 內含 {{x.y}} 用不了。
*/
function interpolateData(
data: Record<string, unknown> | undefined,
ctx: Record<string, unknown>,
): Record<string, unknown> {
if (!data) return {};
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) {
if (typeof v === 'string') {
result[k] = v.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => {
function interpolateString(s: string, ctx: Record<string, unknown>): 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);
});
} else {
result[k] = v;
}
function interpolateValue(v: unknown, ctx: Record<string, unknown>): 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<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
result[k] = interpolateValue(val, ctx);
}
return result;
}
return v;
}
function interpolateData(
data: Record<string, unknown> | undefined,
ctx: Record<string, unknown>,
): Record<string, unknown> {
if (!data) return {};
return interpolateValue(data, ctx) as Record<string, unknown>;
}
/** 從 ctx 用 dot path 取嵌套值:'a.b.0.c' → ctx.a.b[0].c */
function getNestedValue(ctx: unknown, path: string): unknown {
@@ -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<string, unknown>;
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<string, unknown>)[plural] ?? (v as Record<string, unknown>)[key];
if (Array.isArray(nested)) {
items = nested;
break;
}
}
}
}
return Array.isArray(items) ? items : [];
}
+19 -7
View File
@@ -55,9 +55,20 @@ const WASM_HTTP_RUNNER_IDS: ReadonlySet<string> = 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.devcypher-executor 本身綁 cypher.arcrun.dev/*
* fetch 同 zone *.arcrun.dev 會撞 CF 的 zone 自循環防護回 522。
* 詳見 arcrun.md P0 #92026-05-13)。
*
* subdomain 來自 wrangler.toml [vars] WORKER_SUBDOMAIN(預設 uncle6-meself-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 {
+4
View File
@@ -53,6 +53,10 @@ export type Bindings = {
// KBDB 整合
KBDB_INTERNAL_TOKEN?: string;
KBDB_BASE_URL?: string; // 預設 https://kbdb.inkstone.app
// Component Worker subdomainworkers.dev 帳號 subdomain
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
WORKER_SUBDOMAIN: string;
};
// 圖結構定義
+6
View File
@@ -105,6 +105,12 @@ ENVIRONMENT = "production"
KBDB_BASE_URL = "https://kbdb.finally.click"
# KBDB_INTERNAL_TOKEN 透過 wrangler secret set 設定
# Component worker subdomainworkers.dev 帳號 subdomain
# cypher-executor fetch component worker 一律走 arcrun-{name}.{WORKER_SUBDOMAIN}.workers.dev
# 避開同 zone (*.arcrun.dev) 自循環死鎖,見 arcrun.md P0 #92026-05-13
# Self-hosted fork:改成自己的 CF 帳號 subdomainWorkers & Pages → 你的帳號 → subdomain settings
WORKER_SUBDOMAIN = "uncle6-me"
[[routes]]
pattern = "cypher.arcrun.dev/*"
zone_name = "arcrun.dev"