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:
@@ -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' },
|
||||
|
||||
@@ -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 進 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<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,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<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)遞迴展開內部 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, 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);
|
||||
});
|
||||
}
|
||||
|
||||
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 {};
|
||||
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) => {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 從 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<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 : [];
|
||||
}
|
||||
|
||||
@@ -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.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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// 圖結構定義
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user