Compare commits

..

10 Commits

Author SHA1 Message Date
uncle6me-web 3d3973ecbc feat(storage): 工作流與 recipe 的家搬到 KBDB,KV 降成可丟棄的快取(Arcrun#16+#17)
leo 08-12:「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,
數據,一個 entry」「如果零件和工作流的 recipe 不見了,是很可怕的事情」。
同日實害:一次例行更新讓九支工作流在畫面上全部消失。#97 已修掉直接原因
(別再照名字猜使用者的資源、別再擅自新建一顆空的綁上去);這裡修更下面那一句——
**資產本來就不該只存在於一個會被換掉的暫存層裡**。

做法(換 binding,不改四十幾處呼叫端):
- lib/asset-keys.ts    哪些 KV key 是資產、對應 KBDB 哪一列。**唯一**要人看懂的那張表。
- lib/durable-store.ts 讀=KV 先行、miss 回源 KBDB 並補快取;寫=先 KBDB 再 KV,
                       KBDB 失敗就拋錯(禁假綠);**列舉一律回源**——空 KV 列出來是
                       「零筆」而不是「查不到」,那正是東西消失的形狀。
- index.ts             入口把 WEBHOOKS/RECIPES 換成上面那層。逐處改寫一定會漏,
                       而漏掉的那一處就是下一次「東西不見了」的入口。
- routes/storage.ts    /storage/audit(搬前搬後各數一次)+ /storage/migrate-to-kbdb
                       (只增不刪、冪等、逐筆回報成敗)。
- kbdb                 migration 0005 seed 四列 template(零 schema 異動,手法同 0003/0004)
                       + PUT /entries/:id 指定 id 的整列 upsert(通用原語,不是為誰開特例)。
- 衍生資料(idx:*、cron-idx:_all)不進 KBDB,讀不到就從資產重算。

⚠️ 狀態=◐ 半通,**別因為程式碼看起來完整就先合併**。
已實測:5 份 migration 在本機 D1 全數套用(含 0005);兩顆 worker 都能以改動後的
程式碼在本機開起來;tsc 錯誤數 7→7(既有,未新增)。
**沒跑到**:「砍掉 KV、資產還在」那一次端到端驗證——本次施工環境的權限閘不放行
執行 vitest/node/curl。那一次已寫成 scripts/verify-kv-retirement.sh,
在能執行的機器上跑一次就是證據。建議順序:先跑腳本、綠了再合併。

規格層依 D35 走 pending-changes.md「P-KV」提案,等 leo confirm(現行 active SDD
是 workflow-discovery,本案不在它的 tasks 內,故不自建 SDD、不改 rules 那張儲存表)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:51:58 +08:00
uncle6me-web a24f2912eb chore(builds): 重編執行檔——把 wait 的修法真的放進引擎成品(Arcrun#93 的閘抓到的)
#101 併進 main 後,.worker-builds/arcrun-cypher-executor 還記著 a5e4caf,
比源碼 HEAD 少了 f1370e2(wait 搬回引擎那筆)。

⇒ 這正是 #93 那道閘存在的理由:**沒有它,這次出貨會送出一個
「引擎裡沒有 wait 修法」的成品**——leo 更新完照樣燒 CPU,而出貨線全綠。
閘寫出來的隔天就抓到一次,抓到的還是我。

arcrun-cypher-executor  source a5e4caff1370e22  sha256=8411ed59b7ad

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:30:26 +08:00
Leo 1791ffa497 Merge pull request '#101 等待搬回引擎——WASI 沙箱裡沒有「不花 CPU 地等」這種東西' (#103) from fix/wait-not-in-wasm-101 into main 2026-08-12 07:26:00 +00:00
Leo 296fb01247 Merge pull request 'portal 安裝完成清單拿掉「去 Google 申請 AI 金鑰」那一項(arcrun-rag#81)' (#102) from fix/portal-onboarding-drop-gemini-key-81 into main 2026-08-12 07:24:47 +00:00
uncle6me-web f1370e2275 fix(engine): 等待搬回引擎——WASI 沙箱裡沒有「不花 CPU 地等」這種東西(Arcrun#101)
leo 在 youlin stage 實測(只有 input >> wait 兩個節點):
  ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
四個值同一種死法、與 ms 無關 ⇒ 病不是「等待很貴」,是「等待從來沒成功過」。

修法:wait 移進 BUILTIN_COMPONENTS,由引擎 await 一個 timer。
只花 wall-clock、不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改。

🔴 誠實標明:原本註解斷言「Workers 時鐘在同步執行期間凍結,所以自旋永不結束」。
寫測試去證,反而被打臉——workerd 裡自旋 2553 圈後 Date.now() 就前進了。
那條假斷言已刪除(不是改鬆),完整機制降級為推測。修法不依賴它:
純 WASI 沙箱本來就沒有睡覺這個手段,會等的只有宿主。

實測:
  npx vitest run tests/wait-builtin.test.ts  → 12 passed (12)
  npx vitest run(全套)                      → 386 passed / 14 failed
                                              (14 = 動工前的既有紅燈數,未新增)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:15:08 +08:00
uncle6me-web d58a6e152d fix(portal): 安裝完成清單拿掉「去 Google 申請 AI 金鑰」那一項(arcrun-rag#81)
leo 2026-08-12:「已經改用 workers AI,刪掉。」

那一項長在**安裝完成清單裡而且是勾選項** ⇒ 使用者會以為不做這步就沒裝完,
而它要人離開流程、去第三方網站申請帳號、把金鑰貼進表單
——**這是整條安裝路徑上最重的一個動作,而它現在是白做的**。

連帶:步數不再寫死「還差 2 步」,改成由剩下的項目算出來
(少一項卻還寫 2,是另一種說謊)。
2026-08-12 14:44:38 +08:00
uncle6me-web 793a94ecb5 chore(worker-builds): 重編——#100 的修法要進執行檔才會送到用戶那台
cypher-executor source 525faaf → a5e4caf(sha 49d59597 → d1765930)。
今天第三次在同一件事上被提醒:改完源碼沒重編,出貨與安裝送出去的還是舊的。
2026-08-12 13:36:39 +08:00
uncle6me-web cbeddf7535 merge: 讀不到就說讀不到——總圖不再把「讀不到」畫成「你沒有」(Arcrun#100)
總管審過並自己跑了驗證:
  cypher-executor 374 pass(原 357,+17 條),14 個失敗與 main 基準線完全相同
  反向驗證:拿掉帶認證那一行 ⇒ 14 → 17 failed;還原 ⇒ 回到 14

它挖到兩件我沒交辦、而且比原題更嚴重的:
  · /triplets/stats 的 total 是**分頁長度不是總數**(預設 limit=100)
    ⇒ 只修 401 的話畫面會從「0」變成「100」——一樣是假的
  · 數 entries 原本不帶 owner 過濾 ⇒ **會混到別的租戶**(實測 459,137 vs leo 的 458,732)
2026-08-12 13:36:17 +08:00
uncle6me-web d7c6bd0680 merge: 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
總管審過並自己跑了驗證(那條線被權限閘擋住,沒能執行):
  新測試 20 條全過(含四種「說不準就停手」情境)|CLI 全套 38 pass / 0 fail
  反向驗證:把「照名字 ensure」原語加回去 ⇒ 紅線測試當場變紅(0 pass / 1 fail)
  重編 dist:舊的 ensureKvNamespace/ensureD1Database 在產物裡 0 個

它做的比交辦的多:加了兩條紅線測試(cf-api 不得再提供「找不到同名就順手建一顆」的原語、
只有 resource-resolver 能決定要不要建),以及一條我沒想到的——
**部署出去的 toml 不得殘留官方帳號的資源 id**(自架寫進官方庫=跨租戶外洩)。

誠實標記:驗證是本機模擬(假 CF API),**沒有在真機上跑過**。
2026-08-12 13:36:17 +08:00
uncle6me-web 45a546a686 fix(cli): 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。
使用者的資源是安裝器建的(`arcrun-rag-<x>-kv-webhooks`),更新找的是 `WEBHOOKS`
⇒ 找不到 ⇒ 新建一顆空的並綁上去。

2026-08-12 實撞(leo21c):一次例行更新後
  KV 9 顆 → 18 顆、D1 1 顆 → 2 顆,worker 全綁到新建的空的
  ⇒ 工作流一支都看不到、portal 登出、總圖空的、80 把 recipe 解不出來
  leo 原話:「leo21c 是掛掉的」。資料沒掉,但從他的角度就是東西全不見了。

修法方向:**已經部署上去的 worker 綁著什麼,那就是事實**——
名字是使用者那側的事,不是更新指令可以決定的。

📍 repo:matrix/arcrun(cli/)|📍 票:Leo/Arcrun#97
2026-08-12 13:30:17 +08:00
25 changed files with 3010 additions and 231 deletions
+140 -16
View File
@@ -2650,7 +2650,7 @@ var init_recipes = __esm({
});
// cypher-executor/src/lib/constants.ts
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, BUILTIN_COMPONENTS;
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, WAIT_MAX_MS, BUILTIN_COMPONENTS;
var init_constants3 = __esm({
"cypher-executor/src/lib/constants.ts"() {
"use strict";
@@ -2698,6 +2698,7 @@ var init_constants3 = __esm({
"CLICK": "ON_CLICK",
"SUBFLOW": "CALLS_SUBFLOW"
};
WAIT_MAX_MS = 3e4;
BUILTIN_COMPONENTS = /* @__PURE__ */ new Map([
["comp_passthrough", (ctx) => ctx],
["comp_uppercase", (ctx) => {
@@ -2707,6 +2708,54 @@ var init_constants3 = __esm({
["comp_counter", (ctx) => {
const c = ctx;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
["wait", async (ctx) => {
const c = ctx && typeof ctx === "object" ? ctx : {};
const requested = typeof c.ms === "number" ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: "ms \u5FC5\u9808\u5927\u65BC 0" };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
await new Promise((resolve) => setTimeout(resolve, ms));
const passthrough = c.context && typeof c.context === "object" && !Array.isArray(c.context) ? c.context : {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}]
]);
}
@@ -3060,7 +3109,12 @@ var init_component_loader = __esm({
filter: "SVC_FILTER",
merge: "SVC_MERGE",
try_catch: "SVC_TRY_CATCH",
wait: "SVC_WAIT",
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: "SVC_SET",
array_ops: "SVC_ARRAY_OPS",
string_ops: "SVC_STRING_OPS",
@@ -3145,6 +3199,11 @@ function graphBase(env) {
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, "");
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
function graphHeaders(env) {
const headers = {};
if (env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return headers;
}
var kbdbProxyRouter, NEED_KEY;
var init_kbdb_proxy = __esm({
"cypher-executor/src/routes/kbdb-proxy.ts"() {
@@ -3278,8 +3337,7 @@ var init_kbdb_proxy = __esm({
kbdbProxyRouter.get("/kbdb/graph/neighbors/:name", async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param("name"))}`, { headers });
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
@@ -14537,6 +14595,20 @@ async function fetchJson(url, headers) {
return null;
}
}
async function fetchTripletTotal(env, tenant2) {
const { base, headers } = kbdbBase(env);
const data = await fetchJson(
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant2)}`,
headers
);
if (!data || !Array.isArray(data.stats)) return null;
let total = 0;
for (const row of data.stats) {
if (typeof row?.triplet_count !== "number") return null;
total += row.triplet_count;
}
return total;
}
async function fetchEntryTotal(env, filters) {
const { base, headers } = kbdbBase(env);
const params = new URLSearchParams({ ...filters, limit: "1" });
@@ -14644,6 +14716,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal
@@ -14655,7 +14728,13 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)),
fetchJson(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson(`${graphUrl}/triplets/stats`),
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
fetchJson(
`${graphUrl}/triplets/stats`,
graphHeaders(c.env)
),
fetchTripletTotal(c.env, tenant2),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant2 }),
fetchEntryTotal(c.env, { entry_type: "wiki_card", owner_id: tenant2 }),
@@ -14763,13 +14842,14 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
system: {
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
embed: embedStatus ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } : null,
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
graph: { ok: graphStats !== null, triplets: tripletTotal },
workflow_total: workflowTotal
},
kb: {
entries_total: entriesTotal,
wiki_card_total: wikiCardTotal,
triplets_total: graphStats?.total ?? null
triplets_total: tripletTotal
},
generated_at: new Date(now2).toISOString()
});
@@ -14777,22 +14857,22 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now2 = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson(
`${base}/entries?${new URLSearchParams({ owner_id: tenant2, entry_type: "wiki_card", limit: "1" }).toString()}`,
headers
),
fetchJson(`${graphUrl}/triplets/stats`),
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
fetchTripletTotal(c.env, tenant2),
fetchJson(`${base}/embed/backfill/status`, headers)
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
return c.json({
wiki_card_total: typeof wikiCards?.total === "number" ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now2, latestMs),
triplets_total: typeof graphStats?.total === "number" ? graphStats.total : null,
triplets_total: tripletTotal,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now2).toISOString()
@@ -14944,6 +15024,27 @@ function findBestNodeMatch(searchTerm, nodeNames) {
if (hits.length === 0) return null;
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
async function tripletCount(env, owner) {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
if (!res.ok) return null;
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.stats)) return null;
let total = 0;
for (const row of body.stats) {
if (typeof row?.triplet_count !== "number") return null;
total += row.triplet_count;
}
return total;
} catch {
return null;
}
}
async function tripletCensus(env, tenant2) {
const owned = await tripletCount(env, tenant2);
if (owned !== 0) return { owned, any: null };
return { owned, any: await tripletCount(env, "") };
}
async function fuzzyFindNode(env, tenant2, searchTerm) {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
@@ -15045,8 +15146,7 @@ portalDataRouter.get(
return c.json(mapGraphWorkflowOutput(result.data));
}
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
@@ -15081,12 +15181,19 @@ portalDataRouter.get(
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
}
const tenant2 = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`),
tripletCensus(c.env, tenant2)
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
const body = await res.json().catch(() => null);
const records = body && Array.isArray(body.records) ? body.records : [];
if (!body || !Array.isArray(body.records)) {
return c.json({ error: "\u4E09\u5143\u7D44\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = /* @__PURE__ */ new Set();
const edges = [];
@@ -15112,7 +15219,24 @@ portalDataRouter.get(
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
let emptyReason = null;
if (nodes.length === 0) {
if (census.owned === null) emptyReason = "unreadable";
else if (census.owned > 0) emptyReason = "scope_mismatch";
else if (census.any === null) emptyReason = "unreadable";
else emptyReason = census.any > 0 ? "scope_mismatch" : "confirmed_empty";
}
return c.json({
nodes,
edges,
node_count: nodes.length,
edge_count: edges.length,
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
truncated: truncated || records.length >= 500,
triplets_total: tripletsTotal,
empty_confirmed: nodes.length > 0 || emptyReason === "confirmed_empty",
empty_reason: emptyReason
});
})
);
portalDataRouter.get(
+5 -5
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T04:21:48.581Z",
"repo_head": "b302c03ea8076bcfe82bbcebb1523dcec2d1e830",
"generated_at": "2026-08-12T07:29:58.626Z",
"repo_head": "1791ffa4972b4135dacd4208e805f67b747479c4",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 570290,
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
"js_bytes": 577374,
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
+23 -40
View File
@@ -10,7 +10,6 @@ import chalk from 'chalk';
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
REQUIRED_KV_NAMESPACES,
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
@@ -135,7 +134,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
/**
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
* 驗 token → 建 KV(冪等,數量見 REQUIRED_KV_NAMESPACES)→ 查 subdomain → 下載 release 部署 Worker
* 驗 token → 查 subdomain → 下載部署物 → 解析資源(沿用既有/必要才新建)→ 部署 Worker
* → seed auth+api recipe → 寫 config → 印手動 secret 提示。
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
*/
@@ -185,41 +184,13 @@ async function initSelfHosted(
process.exit(1);
}
// 2. KV namespace(冪等)
// 2. KV / D1 / Vectorize 不在這裡預先建(Arcrun#97)。
// 舊版在這一步「照名字 ensure」一輪再往下傳,acr update 沿用同一段程式碼
// ⇒ 對一台安裝器裝出來的實例(資源名字不同)等於每次更新都重建一整套空的綁上去。
// 現在資源解析統一在 downloadAndDeploy 內:**先看已部署的 worker 綁著什麼**,
// 對得上就沿用、確定沒人綁過才建、說不準就停手。init 走 mode:'init'(允許從零建起)。
// 不建 R2R2 是 dead storageregistry-canon Phase 1.5),且 CF R2 首次啟用強制綁信用卡,
// 違背 arcrun「開源免費自架,Workers + KV 免費額度即可運行」核心理念(壓測 2026-06-04 #3)。
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
process.stdout.write(chalk.gray(` → KV ${title}...`));
const id = await cf.ensureKvNamespace(title, existing);
kvNamespaceIds[title] = id;
console.log(chalk.green(' ✓'));
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
// (kbdb-base SDD Q4). idempotent: reuse if exists.
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
if (/auth/i.test(em)) {
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
} else {
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
}
}
// 3. 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用)
let workerSubdomain = '';
@@ -245,8 +216,20 @@ async function initSelfHosted(
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
// selfHosted: true → deploy 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 MCP 401)。
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx);
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx, 'main', { mode: 'init', api: cf });
// 資源解析喊停(例:這台其實已經裝過、但某顆綁著的資源不見了)→ 什麼都沒建、什麼都沒部。
if (deploy.blocked) {
console.log(chalk.yellow('\n ⚠ 安裝沒有進行,你的 Cloudflare 帳號維持原樣。\n'));
console.log(' ' + deploy.message.split('\n').join('\n '));
console.log('');
process.exit(1);
}
// 實際用上的資源(沿用既有的,或這次新建的)——寫 config / 驗收都以這份為準,不再自己查名字。
const kvNamespaceIds = deployCtx.kvNamespaceIds ?? {};
const d1DatabaseId = deployCtx.d1DatabaseId ?? '';
const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
// self-hosted 自己的 MCP worker URLmcp-account-source §3.mcp.json 指自己,不 fallback 官方)。
@@ -290,8 +273,8 @@ async function initSelfHosted(
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
const verify = await verifyInstall({
cf,
requiredKv: REQUIRED_KV_NAMESPACES,
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
kvNamespaceIds,
d1DatabaseId: d1DatabaseId || undefined,
cypherUrl,
});
printPreflight('安裝驗收(裝完檢查)', verify.items);
@@ -301,7 +284,7 @@ async function initSelfHosted(
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${Object.keys(kvNamespaceIds).length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
+17 -36
View File
@@ -14,11 +14,9 @@
import chalk from 'chalk';
import { loadConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
wranglerAvailable,
downloadAndDeploy,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../lib/deploy.js';
@@ -44,43 +42,15 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// D1KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限)
// → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。
// update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`${em}`));
if (/auth/i.test(em)) {
console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update'));
}
}
// 🔴 Arcrun#97:這裡**曾經**先「照名字 ensure」一輪 KV + D1 再往下傳
// binding 名(WEBHOOKS)被當成 CF 上的資源標題去找,安裝器建的資源不叫那個名字
// ⇒ 每次都對不上 ⇒ 每次都新建一顆空的綁上去 ⇒ 使用者的工作流/登入/子庫從畫面上消失
// 現在資源解析整段搬進 downloadAndDeploy:先讀「你已部署的 worker 現在綁著什麼」再決定,
// 而且是**下載完、看得到這版要哪些 binding 之後**才決定,不再由這裡預先造一批。
const ctx: DeployContext = {
accountId: config.cloudflare_account_id,
apiToken: config.cf_api_token,
workerSubdomain: extractSubdomain(config.cypher_executor_url),
kvNamespaceIds,
d1DatabaseId: d1DatabaseId || undefined,
// self-hosted → 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
@@ -93,7 +63,18 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
kbdbEmbed: config.kbdb_embed !== false,
};
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
//Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
// 資源解析階段喊停:什麼都沒建、什麼都沒部。原文照印,然後非零離開——
// 不能混進「部分失敗」的黃字裡帶過(那正是使用者不會發現的那種失敗)。
if (result.blocked) {
console.log(chalk.yellow('\n ⚠ 更新沒有進行,你的實例維持原樣。\n'));
console.log(' ' + result.message.split('\n').join('\n '));
console.log('');
process.exit(1);
}
if (result.implemented) {
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
+103 -14
View File
@@ -3,6 +3,8 @@
* 使 CF REST API KV namespace Wrangler CLI
*/
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
export interface CfKvClientOptions {
@@ -83,7 +85,7 @@ export class CfKvClient {
* CfKvClient namespace KV
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
*/
export class CfAccountClient {
export class CfAccountClient implements ResourceApi {
private accountBase: string;
private headers: Record<string, string>;
@@ -96,6 +98,16 @@ export class CfAccountClient {
}
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
const { ok, status, result, error } = await this.cfRaw<T>(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result as T;
}
/** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */
private async cfRaw<T>(
path: string,
init?: RequestInit,
): Promise<{ ok: boolean; status: number; result?: T; error?: string }> {
const res = await fetch(`${this.accountBase}${path}`, {
...init,
headers: { ...this.headers, ...(init?.headers ?? {}) },
@@ -104,10 +116,13 @@ export class CfAccountClient {
| { success: boolean; result: T; errors?: Array<{ message: string }> }
| null;
if (!res.ok || !data?.success) {
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
throw new Error(`CF API ${path} 失敗:${msg}`);
return {
ok: false,
status: res.status,
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
};
}
return data.result;
return { ok: true, status: res.status, result: data.result };
}
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
@@ -126,12 +141,16 @@ export class CfAccountClient {
return map;
}
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listKvNamespaces());
const found = known.get(title);
if (found) return found;
/**
* KV namespace
*
* 🔴 Arcrun#97**** ensure
* 使
* binding
* resource-resolver planResources
* worker binding create
*/
async createKvNamespace(title: string): Promise<string> {
const result = await this.cf<{ id: string; title: string }>(
'/storage/kv/namespaces',
{ method: 'POST', body: JSON.stringify({ title }) },
@@ -139,6 +158,24 @@ export class CfAccountClient {
return result.id;
}
/**
* worker **使**Arcrun#97
* CF`GET /accounts/{id}/workers/scripts/{script}/settings` `result.bindings[]`
*
* - script 404 `{ deployed: false }`
* - throw****
* #97
*/
async getScriptBindings(script: string): Promise<ScriptBindings> {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [] };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
}
/** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
@@ -153,14 +190,66 @@ export class CfAccountClient {
return map;
}
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listD1Databases());
const found = known.get(name);
if (found) return found;
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */
async createD1Database(name: string): Promise<string> {
const result = await this.cf<{ uuid: string; name: string }>(
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
}
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
async listVectorizeIndexes(): Promise<string[]> {
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
return (result ?? []).map(i => i.name);
}
/**
* KBDB embed Vectorize index**bge-m3 = 1024 / cosine** deploy.ts
* 409 / already exists ensure
* planResources Arcrun#97
*/
async createVectorizeIndex(name: string): Promise<string> {
const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
}
}
/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */
interface RawWorkerBinding {
type?: string;
name?: string;
namespace_id?: string;
id?: string;
database_id?: string;
index_name?: string;
}
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] {
const out: LiveBinding[] = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
+235 -69
View File
@@ -20,6 +20,19 @@ import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import chalk from 'chalk';
import { CfAccountClient } from './cf-api.js';
import {
applyResourcePlan,
bindingKey,
parseWranglerRequirements,
planResources,
ResourcePlanBlocked,
TABLE_KIND,
type BindingRequirement,
type ResourceApi,
type ResourceKind,
type ResolvedResource,
} from './resource-resolver.js';
/** manifest worker content hash
* acr update worker 2026-06-1222/23
@@ -107,7 +120,15 @@ export function buildDownloadHeaders(token = giteaToken()): Record<string, strin
}
/**
* init KV namespacetitle
* arcrun worker KV **binding **
*
* 🔴 Arcrun#97 ** CF **
* wrangler.toml parseWranglerRequirements
* resource-resolver worker
* **** toml
* ensure
*
*
* 7 .claude/rules/01-tech-stack.md cypher-executor
* SUBMISSIONS_KVregistry worker component 稿 registry deploy
* §2.6/#1120/21registry/wrangler.toml SUBMISSIONS_KV
@@ -151,8 +172,11 @@ export interface DeployContext {
accountId: string;
apiToken: string;
workerSubdomain: string;
kvNamespaceIds: Record<string, string>; // title → id
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
/** binding KV namespace id** downloadAndDeploy **
* Arcrun#97 ensure 使*/
kvNamespaceIds?: Record<string, string>;
/** KBDB Base D1 id;同上,由資源解析填入。*/
d1DatabaseId?: string;
// self-hosted 單租戶旗標。trueself-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars]
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
@@ -190,6 +214,11 @@ export interface DeployResult {
cypherExecutorUrl?: string;
mcpUrl?: string; // self-hosted 自己的 MCP worker URLmcp-account-source §3
message: string;
/** true = Arcrun#97** worker **
* message */
blocked?: boolean;
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
resources?: Map<string, ResolvedResource>;
}
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
@@ -219,8 +248,10 @@ export function wranglerAvailable(): boolean {
export async function downloadAndDeploy(
ctx: DeployContext,
ref = 'main',
opts: { force?: boolean } = {},
opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
): Promise<DeployResult> {
const mode = opts.mode ?? 'update';
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
// 1. 下載 + 解壓 Gitea archive tarball
let root: string;
try {
@@ -262,28 +293,123 @@ export async function downloadAndDeploy(
}
const failures: string[] = [];
const allDirs = [...tier1, ...tier2];
// 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等),
// 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
// 失敗不致命(收進 failuresbase 仍可部署、維持 keyword
if (ctx.kbdbEmbed) {
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
//
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
//
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
// → 整趟停手,**在動任何東西之前**。
//
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
const requirements: BindingRequirement[] = [];
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
for (const dir of allDirs) {
const tomlPath = join(dir, 'wrangler.toml');
if (!existsSync(tomlPath)) continue;
const raw = readFileSync(tomlPath, 'utf8');
tomlPreviews.set(dir, raw);
const preview = renderWranglerToml(raw, ctx, new Map());
const parsed = parseWranglerRequirements(preview);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) {
requirements.push({ ...b, worker: parsed.script });
}
}
let resolved = new Map<string, ResolvedResource>();
if (requirements.length > 0) {
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
let plan;
try {
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
await ensureVectorizeIndex(ctx);
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
await ensureVectorizeMetadataIndexes(ctx);
plan = await planResources(api, requirements, mode);
} catch (e) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
if (plan.blockers.length > 0) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
plan.blockers.map((b) => `${b}`).join('\n') +
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
console.log(chalk.yellow(' ✗'));
const raw = e instanceof Error ? e.message : String(e);
const detail = e instanceof ResourcePlanBlocked
? e.blockers.map((b) => `${b}`).join('\n')
: `${raw}`;
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
const hint = /d1/i.test(raw) && /auth/i.test(raw)
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
: '';
return {
implemented: true,
blocked: true,
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
console.log(chalk.green(' ✓'));
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
const created = [...resolved.values()].filter((r) => r.origin === 'created');
if (adopted.length > 0) {
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
for (const r of adopted) console.log(chalk.gray(` = ${r.binding}${r.value}(讀自 ${r.from}`));
}
if (created.length > 0) {
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
for (const r of created) console.log(chalk.yellow(` + ${r.binding}${r.value}`));
}
}
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
ctx.kvNamespaceIds = Object.fromEntries(
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
);
ctx.d1DatabaseId =
resolved.get(bindingKey('d1', 'DB'))?.value
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
if (vectorizeIndex) {
try {
process.stdout.write(chalk.gray(` → 語義查詢 metadata index${vectorizeIndex}...`));
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(' ⚠'));
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
}
}
// 3. 對每個 worker:注入 KV id+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
const allDirs = [...tier1, ...tier2];
let deployed = 0;
let skipped = 0;
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
@@ -296,7 +422,7 @@ export async function downloadAndDeploy(
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
try {
injectWranglerConfig(tomlPath, ctx);
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir));
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
const hash = dirContentHash(dir, ctx.accountId);
if (manifest[label] === hash) {
@@ -434,35 +560,6 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
}
}
/**
* KBDB embed Vectorize index issue #7 / T2.4
* REST `POST /accounts/{id}/vectorize/v2/indexes`dimensions=1024 / metric=cosine bge-m3
* `**dimensions=1024**/metric``*` `/` block comment TS1127
* `kbdb/src/embed.ts` `DEFAULT_EMBED_MODEL` upsert CF
* CF already exists init apiToken+accountId
*/
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: KBDB_VECTORIZE_INDEX,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
signal: AbortSignal.timeout(60_000),
});
if (res.ok) return;
// 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。
const json = (await res.json().catch(() => null)) as
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
| null;
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) return;
throw new Error(msg);
}
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
@@ -471,9 +568,12 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
* Vectorize v2 metadata filter metadata index 0
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`indexType=string
* 409 / already existsasync upsert reindex
*
* 🔴 index = 沿**** KBDB_VECTORIZE_INDEX
* 使 index metadata index
*/
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
const res = await fetch(url, {
method: 'POST',
@@ -596,21 +696,31 @@ export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: stri
* - worker toml `workers_dev = true` strip routes workers.dev URL
* - R2`[[r2_buckets]]` dead storageregistry-canon Phase 1.5
*/
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
function injectWranglerConfig(
tomlPath: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
original?: string,
): void {
if (!existsSync(tomlPath)) return;
let toml = readFileSync(tomlPath, 'utf8');
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
if (!id) continue;
const re = new RegExp(
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
'g',
);
toml = toml.replace(re, `$1${id}$2`);
}
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
const toml = original ?? readFileSync(tomlPath, 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8');
}
/**
* repo wrangler.toml
*
* `resolved` =
* id toml
* binding Arcrun#97
*
*/
export function renderWranglerToml(
toml: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
): string {
// cypher-executor 的 WORKER_SUBDOMAINvars)換成用戶帳號 subdomain
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
toml = toml.replace(
@@ -629,14 +739,6 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
);
}
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
toml = toml.replace(
/(database_id\s*=\s*")[^"]*(")/,
`$1${ctx.d1DatabaseId}$2`,
);
}
// self-hosted:注入 MULTI_TENANT="false" 到 [vars]mcp-account-source §5.5)。
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
// 只對有 [vars] 的 workermcp / cypher-executor)生效;其餘無 [vars] 的不動。
@@ -668,7 +770,71 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
}
writeFileSync(tomlPath, toml, 'utf8');
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
// 空 map = 預覽模式,這步什麼也不做。
return applyResolvedBindings(toml, resolved);
}
/**
* id binding
*
* `[[table]]` `binding = "X"`
* KV`id`D1`database_id`Vectorize`index_name`
* 🔴 **** database_idcypher`CREDENTIALS_DB`
* kbdb`DB` D1 西使
* 使
*/
export function applyResolvedBindings(
toml: string,
resolved: Map<string, ResolvedResource>,
): string {
if (resolved.size === 0) return toml;
const VALUE_KEY: Record<ResourceKind, string> = {
kv_namespace: 'id',
d1: 'database_id',
vectorize: 'index_name',
};
const out: string[] = [];
let block: string[] = [];
let kind: ResourceKind | null = null;
const flush = (): void => {
if (kind) {
const binding = block
.map((l) => l.trim())
.filter((l) => !l.startsWith('#'))
.map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
.find((b): b is string => !!b);
const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
if (hit) {
const key = VALUE_KEY[kind];
const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
if (at >= 0) {
block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
} else {
// 區塊裡本來沒有這個欄位(例如新版 toml 只寫 binding)→ 補一行,不要靜默略過。
block.push(`${key} = "${hit.value}"`);
}
}
}
out.push(...block);
block = [];
};
for (const line of toml.split('\n')) {
const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
kind = TABLE_KIND[table[1]] ?? null;
}
block.push(line);
}
flush();
return out.join('\n');
}
/**
+38 -20
View File
@@ -6,9 +6,11 @@
* test_arcrun/4 D1
* - ****KV / D1 / migration / cypher
* + harness/MCP console.log
* - ****ensureKvNamespace / ensureD1Database
* - ****
*
* + cf-api ensure* init
* + resource-resolver deploy.ts
* 🔴 Arcrun#97 fix acr update
* KV使
*/
import { execFileSync } from 'node:child_process';
@@ -77,42 +79,58 @@ export function printPreflight(title: string, items: PreflightItem[]): void {
*/
export async function verifyInstall(opts: {
cf: CfAccountClient;
requiredKv: readonly string[];
expectD1Name?: string;
/** binding → KV namespace id(部署實際用上的那幾顆)。*/
kvNamespaceIds: Record<string, string>;
/** 部署實際用上的 D1 id(沒有 D1 就不傳)。*/
d1DatabaseId?: string;
cypherUrl?: string;
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
const items: PreflightItem[] = [];
// KV實查 CF 上現有 namespace,比對必需清單
// KV核對「部署實際綁上去的那幾顆 id」在帳號上還在不在。
// 🔴 Arcrun#97:這裡**不能**用「帳號上有沒有叫 WEBHOOKS 的 namespace」來驗。
// 安裝器裝出來的實例,資源名字是 arcrun-rag-<instance>-kv-webhooks——照名字驗會誤報「缺」,
// 而那句誤報底下就寫著「fix: acr update(冪等重建)」⇒ 使用者照做,就被重建成空的。
// 驗的對象永遠是 id(我們真的綁上去的那顆),不是名字。
const kvBindings = Object.entries(opts.kvNamespaceIds);
try {
const existing = await opts.cf.listKvNamespaces();
const have = new Set(existing.keys());
const missing = opts.requiredKv.filter((t) => !have.has(t));
const ids = new Set((await opts.cf.listKvNamespaces()).values());
const missing = kvBindings.filter(([, id]) => !ids.has(id)).map(([b]) => b);
items.push(
missing.length === 0
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
: { name: 'KV namespaces', ok: false, detail: `${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
? { name: `KV namespaces (${kvBindings.length})`, ok: true }
: {
name: 'KV namespaces',
ok: false,
detail: `這幾個 binding 綁著的 namespace 在帳號上找不到:${missing.join(', ')}`,
fix: '先確認那幾顆是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
},
);
} catch (e) {
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: '檢查 CF token 的 KV 讀取權限' });
}
// D1實查 CF 上是否有該庫
if (opts.expectD1Name) {
// D1同理,核對實際綁上去的那顆 id 還在不在(不是核對有沒有叫 arcrun-kbdb 的庫)。
if (opts.d1DatabaseId) {
try {
const dbs = await opts.cf.listD1Databases();
const ids = new Set((await opts.cf.listD1Databases()).values());
items.push(
dbs.has(opts.expectD1Name)
? { name: `D1 ${opts.expectD1Name}`, ok: true }
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' },
ids.has(opts.d1DatabaseId)
? { name: `D1 ${opts.d1DatabaseId}`, ok: true }
: {
name: `D1 ${opts.d1DatabaseId}`,
ok: false,
detail: '這顆 D1 在帳號上找不到',
fix: '先確認它是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
},
);
} catch (e) {
// D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
// D1 讀不到最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
const m = msg(e);
const fix = /auth/i.test(m)
? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update'
: 'acr update(冪等重試)';
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix });
: '檢查 CF token 的 D1 讀取權限';
items.push({ name: `D1 ${opts.d1DatabaseId}`, ok: false, detail: m, fix });
}
}
+408
View File
@@ -0,0 +1,408 @@
/**
* resource-resolver.ts worker
*
* 🔴 Arcrun#972026-08-12 leo
* ensure`acr update` **binding **`WEBHOOKS` Cloudflare
* ****** worker **
* `arcrun-rag-<instance>-kv-webhooks`
* 9 KV1 D1使****
* worker 使西
*
* KV **使**
* **使**
*
* ****
*
*
* 1. ** worker ** 沿
* 2. **** binding
* 3. **** binding
* worker ****
*
* plan / apply
* `planResources()` ****沿
* `applyResourcePlan()` blocker
* ****
* early return#97
*/
/** R2/Queue/Hyperdrive
* ensure */
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize';
/** 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。 */
export interface LiveBinding {
kind: ResourceKind;
binding: string;
value: string;
}
export interface ScriptBindings {
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
deployed: boolean;
bindings: LiveBinding[];
}
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
export interface ResourceApi {
getScriptBindings(script: string): Promise<ScriptBindings>;
/** title → id */
listKvNamespaces(): Promise<Map<string, string>>;
/** name → uuid */
listD1Databases(): Promise<Map<string, string>>;
listVectorizeIndexes(): Promise<string[]>;
createKvNamespace(title: string): Promise<string>;
createD1Database(name: string): Promise<string>;
createVectorizeIndex(name: string): Promise<string>;
}
/** 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 */
export interface BindingRequirement {
kind: ResourceKind;
binding: string;
/** 需要它的 worker script 名(= wrangler.toml 的 `name`)。 */
worker: string;
createName: string;
}
export interface PlannedAdopt {
kind: ResourceKind;
binding: string;
value: string;
/** 從哪顆已部署的 worker 上讀到的 */
from: string;
}
export interface PlannedCreate {
kind: ResourceKind;
binding: string;
createName: string;
wantedBy: string[];
/** 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 */
alsoBind: string[];
}
export interface ResourcePlan {
adopt: PlannedAdopt[];
create: PlannedCreate[];
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
blockers: string[];
}
export interface ResolvedResource {
kind: ResourceKind;
binding: string;
value: string;
origin: 'adopted' | 'created';
from?: string;
}
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
constructor(readonly blockers: string[]) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
}
}
export function bindingKey(kind: ResourceKind, binding: string): string {
return `${kind}:${binding}`;
}
const KIND_LABEL: Record<ResourceKind, string> = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
function msg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* binding 沿**西**
*
* @param mode 'update' = 'init' =
*/
export async function planResources(
api: ResourceApi,
requirements: readonly BindingRequirement[],
mode: 'update' | 'init',
): Promise<ResourcePlan> {
const blockers: string[] = [];
const adopt: PlannedAdopt[] = [];
const create: PlannedCreate[] = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
const live = new Map<string, LiveBinding[]>();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) live.set(script, res.bindings);
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
const byKey = new Map<string, BindingRequirement[]>();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
const existingCache = new Map<ResourceKind, Set<string>>();
const listExisting = async (kind: ResourceKind): Promise<Set<string>> => {
const hit = existingCache.get(kind);
if (hit) return hit;
let set: Set<string>;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
const found: Array<{ value: string; script: string }> = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
let existing: Set<string>;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers };
}
/**
* binding
*
* **toml **`database_name` / `index_name`使
* cypher `CREDENTIALS_DB` kbdb `DB` `database_name = "arcrun-kbdb"`
* **** #97 使
*
*
* D1KBDB credential
* 沿
*/
function shareSameResource(
adopt: PlannedAdopt[],
create: PlannedCreate[],
byKey: Map<string, BindingRequirement[]>,
): PlannedCreate[] {
const declaredName = (kind: ResourceKind, binding: string): string | undefined =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
const out: PlannedCreate[] = [];
const groups = new Map<string, PlannedCreate>();
for (const c of create) {
const groupKey = `${c.kind}${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* plan 沿
* blocker ResourcePlanBlocked****
*/
export async function applyResourcePlan(
api: ResourceApi,
plan: ResourcePlan,
): Promise<Map<string, ResolvedResource>> {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
const out = new Map<string, ResolvedResource>();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
const madeSoFar: string[] = [];
for (const c of plan.create) {
let value: string;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
export interface WranglerRequirements {
/** worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 */
script: string;
bindings: Array<{ kind: ResourceKind; binding: string; createName: string }>;
}
/** wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 */
export const TABLE_KIND: Record<string, ResourceKind> = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* wrangler.toml worker
*
* TOML parserinjectWranglerConfig
* ****
* kbdb `[[vectorize]]`
*/
export function parseWranglerRequirements(toml: string): WranglerRequirements {
let script = '';
let seenTable = false;
const bindings: WranglerRequirements['bindings'] = [];
let kind: ResourceKind | null = null;
let binding = '';
let createName = '';
const flush = (): void => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
+532
View File
@@ -0,0 +1,532 @@
/**
* Arcrun#97 使西
*
* 2026-08-12 leo portal
* worker 9 KV + 1 D1
*
* binding WEBHOOKS CF
* `arcrun-rag-<instance>-kv-webhooks`
*
* **** leo leo21c
* planResources / applyResourcePlan / renderWranglerToml
*
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
planResources,
applyResourcePlan,
parseWranglerRequirements,
bindingKey,
ResourcePlanBlocked,
type BindingRequirement,
type ResourceApi,
type ScriptBindings,
type LiveBinding,
type ResolvedResource,
} from '../src/lib/resource-resolver.ts';
import {
renderWranglerToml,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../src/lib/deploy.ts';
import { CfAccountClient } from '../src/lib/cf-api.ts';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
/** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */
const WORKER_TOMLS = [
'cypher-executor/wrangler.toml',
'registry/wrangler.toml',
'mcp/wrangler.toml',
'kbdb/wrangler.toml',
];
const CTX: DeployContext = {
accountId: 'acct-test',
apiToken: 'tok-test',
workerSubdomain: 'yuga3bse',
selfHosted: true,
kbdbEmbed: true,
};
// ─────────────────────────────────────────────────────────────────────────────
// 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名
// ─────────────────────────────────────────────────────────────────────────────
const INSTANCE = 'yuga3bse';
interface FakeOpts {
/** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */
failBindingsFor?: string;
/** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */
deleteKvTitle?: string;
/** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */
nothingDeployed?: boolean;
}
class FakeCloudflare implements ResourceApi {
/** title → id */
kv = new Map<string, string>();
/** name → uuid */
d1 = new Map<string, string>();
vectorize: string[] = [];
/** script → bindings */
scripts = new Map<string, LiveBinding[]>();
/** 使用者的東西:kvId → (key → value) */
kvData = new Map<string, Map<string, string>>();
/** d1Id → 子庫名單 */
d1Libraries = new Map<string, string[]>();
/** 這趟總共建立了什麼(驗「顆數不增加」用)。 */
createdKv: string[] = [];
createdD1: string[] = [];
createdVectorize: string[] = [];
constructor(private opts: FakeOpts = {}) {
// 安裝器的命名慣例:arcrun-rag-<instance>-kv-<binding 小寫>
for (const binding of REQUIRED_KV_NAMESPACES) {
const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`;
const id = `kvid-${binding.toLowerCase()}`;
this.kv.set(title, id);
this.kvData.set(id, new Map());
}
this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb');
this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`);
// 使用者的東西
this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}');
this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}');
this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}');
this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}');
this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']);
if (!opts.nothingDeployed) {
const kvB = (b: string): LiveBinding =>
({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` });
this.scripts.set('arcrun-cypher-executor', [
kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'),
kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'),
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' },
]);
this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]);
this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]);
this.scripts.set('arcrun-kbdb', [
{ kind: 'd1', binding: 'DB', value: 'd1id-kbdb' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` },
]);
}
if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle);
}
async getScriptBindings(script: string): Promise<ScriptBindings> {
if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)');
const b = this.scripts.get(script);
return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] };
}
async listKvNamespaces(): Promise<Map<string, string>> { return new Map(this.kv); }
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
const id = `NEW-kvid-${this.createdKv.length}`;
this.kv.set(title, id);
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
this.createdKv.push(title);
return id;
}
async createD1Database(name: string): Promise<string> {
const id = `NEW-d1id-${this.createdD1.length}`;
this.d1.set(name, id);
this.d1Libraries.set(id, []);
this.createdD1.push(name);
return id;
}
async createVectorizeIndex(name: string): Promise<string> {
this.vectorize.push(name);
this.createdVectorize.push(name);
return name;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑)
// ─────────────────────────────────────────────────────────────────────────────
function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
const requirements: BindingRequirement[] = [];
const tomls = new Map<string, string>();
for (const rel of WORKER_TOMLS) {
const raw = readFileSync(join(REPO, rel), 'utf8');
tomls.set(rel, raw);
const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
return { requirements, tomls };
}
/** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */
function deployAndReadBindings(
tomls: Map<string, string>,
resolved: Map<string, ResolvedResource>,
): Map<string, Map<string, string>> {
const out = new Map<string, Map<string, string>>();
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
const script = parseWranglerRequirements(rendered).script;
const bound = new Map<string, string>();
let kind: string | null = null;
let binding = '';
for (const line of rendered.split('\n')) {
const t = line.trim();
if (t.startsWith('#')) continue;
const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) { kind = table[1]; binding = ''; continue; }
const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!m) continue;
if (m[1] === 'binding') binding = m[2];
else if (binding && (
(kind === 'kv_namespaces' && m[1] === 'id')
|| (kind === 'd1_databases' && m[1] === 'database_id')
|| (kind === 'vectorize' && m[1] === 'index_name')
)) bound.set(binding, m[2]);
}
out.set(script, bound);
}
return out;
}
// ═════════════════════════════════════════════════════════════════════════════
// ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => {
const cf = new FakeCloudflare();
const { requirements, tomls } = collectRequirements();
const before = {
workflows: cf.kvData.get('kvid-webhooks')!.size,
sessions: cf.kvData.get('kvid-sessions_kv')!.size,
libraries: cf.d1Libraries.get('d1id-kbdb')!.length,
};
assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好');
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker');
const resolved = await applyResourcePlan(cf, plan);
const bound = deployAndReadBindings(tomls, resolved);
// 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要)
const cypher = bound.get('arcrun-cypher-executor')!;
assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks');
assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv');
assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb');
assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb');
assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv');
assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv');
assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`);
const after = {
workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size,
sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size,
libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length,
};
assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣');
});
// ═════════════════════════════════════════════════════════════════════════════
// ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆)
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ②:更新不會在帳號上多生任何資源', async () => {
const cf = new FakeCloudflare();
const kvBefore = cf.kv.size;
const d1Before = cf.d1.size;
const vecBefore = cf.vectorize.length;
assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]);
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index');
assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]);
});
test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => {
// 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與
// cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。
// 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。
const cf = new FakeCloudflare();
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建
}
const d1s = await cf.listD1Databases();
if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb');
assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)');
assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆');
assert.equal(cf.kv.size, 18, '9 → 18');
assert.equal(cf.d1.size, 2, '1 → 2');
// 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的
assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0);
});
// ═════════════════════════════════════════════════════════════════════════════
// ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ③-aworker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => {
const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.ok(plan.blockers.length > 0, '要有 blocker');
const said = plan.blockers.join('\n');
assert.match(said, /WEBHOOKS/, '要指名是哪個綁定');
assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源');
assert.match(said, /找不到/, '要說清楚發生什麼事');
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來');
assert.deepEqual(cf.createdD1, []);
});
test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => {
const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/);
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, []);
});
test('#97 ③-cupdate 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/);
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, []);
});
test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => {
const cf = new FakeCloudflare();
// registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊)
cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other';
cf.kv.set('some-other-kv', 'kvid-other');
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/);
assert.deepEqual(cf.createdKv, []);
});
// ═════════════════════════════════════════════════════════════════════════════
// 合法的新建:只有「確定沒人綁過」時才准
// ═════════════════════════════════════════════════════════════════════════════
test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => {
const cf = new FakeCloudflare();
cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的)
cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`);
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, []);
assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建');
await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, ['OAUTH_KV']);
assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆');
});
test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'init');
assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手');
await applyResourcePlan(cf, plan);
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length);
assert.deepEqual(cf.createdD1, ['arcrun-kbdb']);
assert.equal(cf.createdVectorize.length, 1);
});
test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => {
const cf = new FakeCloudflare();
cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb
const { requirements, tomls } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, []);
assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆');
const resolved = await applyResourcePlan(cf, plan);
const bound = deployAndReadBindings(tomls, resolved);
assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb',
'credential 目錄要跟 KBDB 在同一顆庫');
assert.deepEqual(cf.createdD1, []);
});
test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => {
// repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入,
// 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。
const cf = new FakeCloudflare();
const { requirements, tomls } = collectRequirements();
const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update'));
const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f';
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`);
assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`);
}
});
// ═════════════════════════════════════════════════════════════════════════════
// 做法本身的看守:不准再出現「照名字 ensure」這種原語
// ═════════════════════════════════════════════════════════════════════════════
test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => {
const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8');
assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/,
'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。'
+ '要建資源一律先過 resource-resolver 的 planResources。');
});
test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => {
for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) {
const src = readFileSync(join(REPO, rel), 'utf8');
assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/,
`${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`);
}
});
// ═════════════════════════════════════════════════════════════════════════════
// 底層零件
// ═════════════════════════════════════════════════════════════════════════════
test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => {
const toml = [
'name = "arcrun-kbdb" # 註解不影響',
'',
'[[d1_databases]]',
'binding = "DB"',
'database_name = "arcrun-kbdb"',
'database_id = "placeholder"',
'',
'[vars]',
'ENVIRONMENT = "production"',
'',
'# [[vectorize]]',
'# binding = "VECTORIZE"',
'# index_name = "arcrun-kbdb-embed-m3"',
].join('\n');
const r = parseWranglerRequirements(toml);
assert.equal(r.script, 'arcrun-kbdb');
assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]);
});
test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => {
const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"');
assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]);
});
test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => {
const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8');
const resolved = new Map<string, ResolvedResource>([
[bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }],
[bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }],
]);
const out = renderWranglerToml(cypher, CTX, resolved);
const bound = parseWranglerRequirements(out);
assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB'));
assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/);
assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/);
// 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved
assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/);
});
test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => {
for (const rel of WORKER_TOMLS) {
const raw = readFileSync(join(REPO, rel), 'utf8');
const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
const resolved = new Map<string, ResolvedResource>(
preview.bindings.map((b) => [
bindingKey(b.kind, b.binding),
{ kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const },
]),
);
const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved));
assert.deepEqual(
after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
`${rel}: 預覽與實際注入看到的綁定必須一致`,
);
}
});
test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => {
const { requirements } = collectRequirements();
const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))];
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});
test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
const orig = globalThis.fetch;
try {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
) as typeof fetch;
const cf = new CfAccountClient('a', 't');
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] });
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
) as typeof fetch;
await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/);
} finally {
globalThis.fetch = orig;
}
});
test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => {
const orig = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response(JSON.stringify({
success: true,
result: {
bindings: [
{ type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' },
{ type: 'd1', name: 'DB', id: 'db1' },
{ type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' },
{ type: 'plain_text', name: 'ENVIRONMENT', text: 'production' },
{ type: 'service', name: 'SVC_SET', service: 'arcrun-set' },
],
},
}), { status: 200 })) as typeof fetch;
const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor');
assert.equal(res.deployed, true);
assert.deepEqual(res.bindings, [
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' },
{ kind: 'd1', binding: 'DB', value: 'db1' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
]);
} finally {
globalThis.fetch = orig;
}
});
+23 -26
View File
@@ -1108,7 +1108,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
})();
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
// ── t53 完成安裝清單(進站必見,做完才消失)───────────────────────────────
// 件數演進:t53 三件 → t54 兩件(設定檔改由小幫手憑帳密自取)
// → arcrun-rag#81 一件(AI 問答改走 Workers AI,不再要用戶自備金鑰)。
function setupSteps() {
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
}
@@ -1123,7 +1125,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var p = S.profile || {};
if (p.role !== 'admin') return;
var s = setupSteps();
if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
// arcrun-rag#81leo 08-12:「已經改用 workers AI,刪掉」):只剩「下載小幫手」一件。
// 舊的 s.key(貼 Google AI 金鑰)已整條移除,見下方 innerHTML 處的說明。
// 相容:舊瀏覽器 localStorage 裡殘留的 s.key 不再被讀 —— 沒設過 key 的人也不會被卡住。
if (s.daemon) return; // t54 起設定改由小幫手輸入帳密自取,不再下載檔案
var cfg = window.ARCRUN_CONFIG || {};
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
@@ -1134,7 +1139,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var el = document.createElement('div');
el.id = 'setup-checklist';
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
el.innerHTML = '<b>還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
// 🔴 arcrun-rag#81leo 08-12):第二項「啟用 AI 問答(貼 Google AI 金鑰)」整條刪除。
// 為什麼不是「一個沒用的欄位」而已:它長在**安裝完成清單裡而且是勾選項**
// ⇒ 用戶會以為不做這步就沒裝完,而它要人離開流程、去第三方網站申請帳號、
// 把金鑰貼進表單——整條安裝路徑上最重的一個動作,而且是白做的。
// 真相:雲端問答走 t18108-04)改好的 Workers AI`env.AI` binding,免金鑰)——
// /portal/data/chat → tenant 的 rag_chat workflow → `workers_ai_chat` recipe
// api-recipe-seeds.ts:150endpoint `@cf/meta/llama-4-scout-17b-16e-instruct`)。
// 這裡貼的金鑰是打 POST /portal/admin/ai 存一筆雲端 gemini_api_key credential
// **現行問答鏈路一個地方都沒有讀它**。
// 後端 route 本身不動(同 08-09 拿掉檢修孔按鈕的處置:端點無害、已無任何 UI 呼叫,
// 純粹清路標);已經存過金鑰的人那筆 credential 也原封不動,不做刪除。
// ⚠️ 別把這個跟設定頁「AI 設定」面板講的地端萃取金鑰搞混——那把是填在
// **同步小幫手**托盤裡的,從來不經過這個清單。
el.innerHTML = '<b>還差 1 步,安裝就真的完成了</b>'
+ row(s.daemon, 'daemon',
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
+ (daemonUrl
@@ -1156,34 +1174,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
+ '<div class="muted" style="font-size:12.5px;margin-top:4px">裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。</div>')
+ row(s.key, 'key',
'<b>啟用 AI 問答</b><a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
document.body.appendChild(el);
var dl = document.getElementById('sc-daemon');
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
// t54config.json 下載鈕已移除(設定由小幫手憑帳密自取)
var kb = document.getElementById('sc-key-save');
if (kb) kb.addEventListener('click', function () {
var k = (document.getElementById('sc-key').value || '').trim();
var m = document.getElementById('sc-key-msg');
if (!k) { m.textContent = '請先貼上金鑰'; return; }
kb.disabled = true; m.textContent = '啟用中…';
fetch(API_BASE + '/portal/admin/ai', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ gemini_api_key: k })
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
kb.disabled = false;
if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; }
markStep('key');
})
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
});
// arcrun-rag#81:金鑰輸入框的送出邏輯(POST /portal/admin/ai)一併移除——
// 只藏畫面留著那條路,等於這個要求還在,只是變得更難發現。
var later = document.getElementById('sc-later');
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
}
+12 -2
View File
@@ -24,6 +24,8 @@ import { consoleAuthRouter } from './routes/console-auth';
import { consoleDashboardRouter } from './routes/console-dashboard';
import { portalRouter } from './routes/portal';
import { portalDataRouter } from './routes/portal-data';
import { storageRouter } from './routes/storage';
import { withDurableStores } from './lib/durable-store';
const app = new Hono<{ Bindings: Bindings }>();
@@ -97,11 +99,19 @@ app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
app.route('/', portalRouter); // portal-auth P2#24/#25):RAG Portal 多人授權——用戶模型+認證 API
app.route('/', portalDataRouter); // portal-auth P3/portal/data/* server-side enforceowner_idlibrary 注入,安全核心)
app.route('/', storageRouter); // KV 退休(#16/#17):資產遷移/盤點端點
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
// 邏輯在 src/scheduled.ts。對應 SDD: arcrun.md 三-A P1 #3。
//
// 🔴 KV 退休(Leo/Arcrun#16 + #17):WEBHOOKS / RECIPES 在這裡被換成 KBDB 撐腰的版本
//lib/durable-store.ts)。**這是唯一的接線點**——換在入口,四十幾處呼叫端一行不動,
// 也就沒有「某一處忘了改」這種漏洞(那正是資產會不見的入口)。
// 使用者的工作流與 recipe 從此住在 KBDBD1,一份資產一列 entry),KV 只是快取:
// KV 被換掉/重建之後,資料仍在,且會在下一次讀取時自己長回快取。
export default {
fetch: app.fetch,
scheduled: handleScheduled,
fetch: (req: Request, env: Bindings, ctx: ExecutionContext) => app.fetch(req, withDurableStores(env), ctx),
scheduled: (event: ScheduledController, env: Bindings, ctx: ExecutionContext) =>
handleScheduled(event, withDurableStores(env), ctx),
} satisfies ExportedHandler<Bindings>;
+156
View File
@@ -0,0 +1,156 @@
/**
* asset-keys KV key 使 KBDB
*
* KV 退Leo/Arcrun#16 + #17leo 2026-08-12
* KBDB KV RecipesCypher entry
* recipe
*
*
* ** KBDBKV **
* KV webhooks-named / portal / executions / component-loader /
* auth-dispatcher / wasi-shim
* 西 binding durable-store.ts
* 西** key **
*
* vs
* = 西使/AI recipe KBDB
* = 西 idx:*cron session
* TTL KVdurable-store
*
* KBDB
* 西
*
* KV key KBDB
* KBDB KVleo entry
* portal/console key ****
* entry_type + owner_id + page_name + KBDB
*/
/** KBDB 裡 arcrun 資產的 entry_type(每個都有一列 template 定義,見 kbdb/migrations/0005)。 */
export type AssetEntryType = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe';
export interface AssetRef {
entry_type: AssetEntryType;
/** KBDB entries.id——由 key 決定,同一份資產永遠同一列(冪等,重跑遷移不長重複)。 */
entry_id: string;
/** 租戶。recipe 是整台實例共用的庫,故為 null(與現行 RECIPES KV 無租戶前綴一致)。 */
owner_id: string | null;
/** 該型別的自然鍵(workflow 名 / recipe uuid / service 名),對應 entries.page_name。 */
page_name: string;
/** 原本的 KV key——反向重建快取時要用(KBDB → KV 回填)。 */
kv_key: string;
}
/** entries.id 的前綴,跟別人的資料分得開,也讓 `arcrun:` 一眼看得出是誰的列。 */
const ID_PREFIX = 'arcrun';
/**
* KV key KBDB null KV
*
* **** KBDB
* KBDB 2026-08-08 credential
*/
export function classifyAssetKey(key: string): AssetRef | null {
// ── 明確排除的衍生資料(放最前面,免得被下面的樣式誤收)────────────────
// idx:* recipe/component 反查索引(canonical→uuid、hash→canonical
// cron-idx:* cron 排程索引(8.P0 的單一 key
// 兩者都能從資產重算,見 durable-store.ts 的 rehydrate*。
if (key.startsWith('idx:') || key.startsWith('cron-idx:')) return null;
// auth_recipe:{service} — 「怎麼認證」的定義。注意只有定義,沒有任何密文
//(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md「Credential 儲存規範」)。
if (key.startsWith('auth_recipe:')) {
const service = key.slice('auth_recipe:'.length);
if (!service) return null;
return {
entry_type: 'auth_recipe',
entry_id: `${ID_PREFIX}:auth_recipe:${service}`,
owner_id: null,
page_name: service,
kv_key: key,
};
}
// prompt_recipe:{name}
if (key.startsWith('prompt_recipe:')) {
const name = key.slice('prompt_recipe:'.length);
if (!name) return null;
return {
entry_type: 'prompt_recipe',
entry_id: `${ID_PREFIX}:prompt_recipe:${name}`,
owner_id: null,
page_name: name,
kv_key: key,
};
}
// recipe:{uuid}recipe:{canonical_id}migration 前的舊 key,仍是資產,一樣要保住)
if (key.startsWith('recipe:')) {
const id = key.slice('recipe:'.length);
if (!id) return null;
return {
entry_type: 'api_recipe',
entry_id: `${ID_PREFIX}:recipe:${id}`,
owner_id: null,
page_name: id,
kv_key: key,
};
}
// {api_key}:wf:{name} — 具名工作流(acr push / portal 安裝器寫的那把)。
// 用**第一個** ':wf:' 切:api_key 不含冒號,而 workflow 名允許的字元集
//webhooks-named.ts 驗 /^[\w-]+$/)本來就不含冒號,所以切點唯一。
const wfAt = key.indexOf(':wf:');
if (wfAt > 0) {
const owner = key.slice(0, wfAt);
const name = key.slice(wfAt + ':wf:'.length);
if (!owner || !name) return null;
return {
entry_type: 'workflow_def',
entry_id: `${ID_PREFIX}:wf:${owner}:${name}`,
owner_id: owner,
page_name: name,
kv_key: key,
};
}
// 其餘一律衍生/暫存:匿名 webhook token、daemon-active、session、stats… 留在 KV。
return null;
}
/** 從 KBDB 的一列反推回原本的 KV key(快取回填、list 都要用)。 */
export function assetKvKey(entryType: AssetEntryType, ownerId: string | null, pageName: string): string {
switch (entryType) {
case 'workflow_def':
return `${ownerId ?? ''}:wf:${pageName}`;
case 'api_recipe':
return `recipe:${pageName}`;
case 'auth_recipe':
return `auth_recipe:${pageName}`;
case 'prompt_recipe':
return `prompt_recipe:${pageName}`;
}
}
/**
* KV list prefix KBDB
*
* list KBDB get KV ****
* KV list 2026-08-12
* get KV miss list
*
* null = prefix cron-idx: KV
*/
export function classifyListPrefix(prefix: string | undefined): { entry_type: AssetEntryType; owner_id?: string } | null {
if (!prefix) return null; // 無 prefix 的全域 listwebhooks-list)維持原行為
if (prefix.startsWith('idx:') || prefix.startsWith('cron-idx:')) return null;
if (prefix === 'auth_recipe:') return { entry_type: 'auth_recipe' };
if (prefix === 'prompt_recipe:') return { entry_type: 'prompt_recipe' };
if (prefix === 'recipe:') return { entry_type: 'api_recipe' };
// `{api_key}:wf:` — 列出某租戶的所有工作流(webhooks-named / portal-data 都用這個)
if (prefix.endsWith(':wf:')) {
const owner = prefix.slice(0, -':wf:'.length);
if (owner) return { entry_type: 'workflow_def', owner_id: owner };
}
return null;
}
+6 -1
View File
@@ -77,7 +77,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
filter: 'SVC_FILTER',
merge: 'SVC_MERGE',
try_catch: 'SVC_TRY_CATCH',
wait: 'SVC_WAIT',
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: 'SVC_SET',
array_ops: 'SVC_ARRAY_OPS',
string_ops: 'SVC_STRING_OPS',
+62
View File
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
'SUBFLOW': 'CALLS_SUBFLOW',
};
/**
* wait registry/components/wait/component.contract.yaml
* **調**Arcrun#101
*
*/
export const WAIT_MAX_MS = 30000;
/**
*
* WASM = Workercypher-executor HTTP URL R2
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
const c = ctx as Record<string, unknown>;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
['wait', async (ctx) => {
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: 'ms 必須大於 0' };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
await new Promise<void>((resolve) => setTimeout(resolve, ms));
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
? c.context as Record<string, unknown>
: {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}],
]);
export const SCORE_THRESHOLD = 0.5;
+448
View File
@@ -0,0 +1,448 @@
/**
* durable-store KV KBDB
*
* KV 退Leo/Arcrun#16 + #17leo 2026-08-12
* KV 使
* AI
* recipe
* 使
* cli/src/lib/resource-resolver.ts Arcrun#97
*
*
* ** KBDBD1 entryKV 退**
* KV KBDB
*
* binding
* WEBHOOKS / RECIPES webhooks-namedportalexecutionscomponent-loader
* auth-dispatcherwasi-shim **
* 西** worker binding
* key asset-keys.ts 西
*
*
* 1. **** KV KBDB KV
* 2. **** KBDB KV
* KBDB ****
* 3. **** KBDB** KV** KV
* KV
*
* idx:* / cron-idx:*
* KBDBKBDB ****
* rehydrateRecipeIndices / rehydrateCronIndex
*/
import { classifyAssetKey, classifyListPrefix, assetKvKey, type AssetRef, type AssetEntryType } from './asset-keys';
import { CRON_INDEX_KEY, cronEntryKey, type CronIndex } from './cron-index';
export interface KbdbEnv {
KBDB_BASE_URL?: string;
KBDB_INTERNAL_TOKEN?: string;
}
/** KBDB 位址與 token 的取法沿用既有慣例(lib/workflow-search.ts、routes/webhooks-named.ts 同款)。 */
function kbdbBase(env: KbdbEnv): string {
return (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
}
function kbdbHeaders(env: KbdbEnv): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.KBDB_INTERNAL_TOKEN) h['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return h;
}
/** KBDB 一列 entry 的回應形狀(只取本檔用得到的欄位)。 */
interface KbdbEntry {
id: string;
content?: string | null;
entry_type?: string | null;
owner_id?: string | null;
page_name?: string | null;
metadata_json?: string | null;
updated_at?: number;
}
/** 資產寫進 metadata_json 的信封。definition = 原值 parse 過的物件;非 JSON 的原字串走 definition_raw。 */
interface AssetEnvelope {
arcrun_asset: true;
kv_key: string;
definition?: unknown;
definition_raw?: string;
/** api_recipe 專用:本部署目前安裝的是不是這一版(重建 idx:installed:* 用,見 rehydrateRecipeIndices)。 */
installed?: boolean;
}
export class KbdbUnavailableError extends Error {
constructor(op: string, detail: string) {
super(
`資產無法寫入 KBDB${op}):${detail}` +
'本次操作已中止且未寫入任何一邊——這是刻意的:寧可讓你現在看到失敗,' +
'也不要寫進只會被下次更新換掉的 KV、事後才發現東西不見了(Leo/Arcrun#16、#17)。',
);
this.name = 'KbdbUnavailableError';
}
}
/** 從資產定義裡挑一句「給人看也給搜尋看」的描述,當 entries.content。 */
function assetContent(type: AssetEntryType, def: unknown, pageName: string): string {
const d = (def ?? {}) as Record<string, unknown>;
const pick = (...keys: string[]): string => {
for (const k of keys) {
const v = d[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
};
switch (type) {
case 'workflow_def':
return pick('description') || pageName;
case 'api_recipe':
return pick('description', 'display_name', 'canonical_id') || pageName;
case 'auth_recipe':
return pick('description', 'display_name', 'service') || pageName;
case 'prompt_recipe':
return pick('description', 'name') || pageName;
}
}
/** 把 KBDB 一列還原成原本的 KV 值(字串)。不是資產信封(或壞掉)→ null,誠實當作沒有。 */
function entryToKvValue(entry: KbdbEntry | null | undefined): string | null {
if (!entry?.metadata_json) return null;
try {
const env = JSON.parse(entry.metadata_json) as AssetEnvelope;
if (!env || env.arcrun_asset !== true) return null;
if (typeof env.definition_raw === 'string') return env.definition_raw;
if (env.definition === undefined) return null;
return JSON.stringify(env.definition);
} catch {
return null;
}
}
/**
* KV binding KVNamespace
* request rehydrate per-instance
*/
export class DurableKv {
private rehydratedRecipeIdx = false;
private rehydratedCronIdx = false;
constructor(
private readonly kv: KVNamespace,
private readonly env: KbdbEnv,
) {}
/**
* KV****routes/storage.ts
* KV KBDB
* list KBDB
*
*/
get rawKv(): KVNamespace {
return this.kv;
}
// ── KBDB 存取原語 ──────────────────────────────────────────────────────
private async kbdbGetEntry(entryId: string): Promise<KbdbEntry | null> {
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(entryId)}`, {
headers: kbdbHeaders(this.env),
});
if (res.status === 404) return null;
if (!res.ok) return null; // 讀不到就當沒有;快取仍可能有值,不炸讀取路徑
const json = (await res.json().catch(() => null)) as { entry?: KbdbEntry } | null;
return json?.entry ?? null;
}
private async kbdbListEntries(entryType: AssetEntryType, ownerId?: string): Promise<KbdbEntry[]> {
const params = new URLSearchParams({ entry_type: entryType, limit: '1000' });
if (ownerId) params.set('owner_id', ownerId);
const res = await fetch(`${kbdbBase(this.env)}/entries?${params.toString()}`, {
headers: kbdbHeaders(this.env),
});
if (!res.ok) throw new KbdbUnavailableError('list', `HTTP ${res.status}`);
const json = (await res.json().catch(() => null)) as { entries?: KbdbEntry[] } | null;
return json?.entries ?? [];
}
private async kbdbPutEntry(ref: AssetRef, envelope: AssetEnvelope): Promise<void> {
const content = assetContent(ref.entry_type, envelope.definition, ref.page_name);
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(ref.entry_id)}`, {
method: 'PUT',
headers: kbdbHeaders(this.env),
body: JSON.stringify({
entry_type: ref.entry_type,
owner_id: ref.owner_id,
page_name: ref.page_name,
content,
// 刻意**不**標 embed:true:工作流的語意搜尋走既有的 entry_type='workflow' 那一列
//workflow-discovery 方案 C 的雙寫),這裡標了會變成同一支工作流嵌兩份向量。
metadata_json: JSON.stringify(envelope),
}),
});
if (!res.ok) throw new KbdbUnavailableError('put', `HTTP ${res.status} @ ${ref.entry_id}`);
}
private async kbdbDeleteEntry(entryId: string): Promise<void> {
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(entryId)}`, {
method: 'DELETE',
headers: kbdbHeaders(this.env),
});
// 404 = 本來就沒有,對刪除而言是成功(冪等)。
if (!res.ok && res.status !== 404) throw new KbdbUnavailableError('delete', `HTTP ${res.status} @ ${entryId}`);
}
// ── 衍生索引重建(讀不到就重算,不進 KBDB)────────────────────────────
/**
* KBDB api_recipe recipe
* idx:{hash_id} canonical_id
* idx:canonical:{canonical} [uuid, ...]
* idx:installed:{canonical} uuid
*
* installed installed
* put() `idx:installed:` canonical
*退 updated_at
* installRecipeRecord
* **退**
*/
private async rehydrateRecipeIndices(): Promise<void> {
if (this.rehydratedRecipeIdx) return;
this.rehydratedRecipeIdx = true;
const entries = await this.kbdbListEntries('api_recipe');
const byCanonical = new Map<string, Array<{ uuid: string; installed: boolean; updated_at: number }>>();
const writes: Array<Promise<unknown>> = [];
for (const e of entries) {
const raw = entryToKvValue(e);
if (!raw) continue;
let def: { uuid?: string; canonical_id?: string; hash_id?: string };
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
if (!def.canonical_id) continue;
if (def.hash_id) writes.push(this.kv.put(`idx:${def.hash_id}`, def.canonical_id));
if (!def.uuid) continue;
let installed = false;
try {
installed = (JSON.parse(e.metadata_json ?? '{}') as AssetEnvelope).installed === true;
} catch { /* 壞信封 → 當作沒標記,走 updated_at 退路 */ }
const list = byCanonical.get(def.canonical_id) ?? [];
list.push({ uuid: def.uuid, installed, updated_at: e.updated_at ?? 0 });
byCanonical.set(def.canonical_id, list);
}
for (const [canonical, versions] of byCanonical) {
writes.push(this.kv.put(`idx:canonical:${canonical}`, JSON.stringify(versions.map((v) => v.uuid))));
const chosen =
versions.find((v) => v.installed) ??
versions.reduce((a, b) => (b.updated_at > a.updated_at ? b : a));
writes.push(this.kv.put(`idx:installed:${canonical}`, chosen.uuid));
}
await Promise.all(writes);
}
/** 從 KBDB 的 workflow_def 列重建 cron 索引(單一 key,見 lib/cron-index.ts)。 */
private async rehydrateCronIndex(): Promise<void> {
if (this.rehydratedCronIdx) return;
this.rehydratedCronIdx = true;
const entries = await this.kbdbListEntries('workflow_def');
const index: CronIndex = {};
for (const e of entries) {
const raw = entryToKvValue(e);
if (!raw) continue;
let def: { cron_expr?: string };
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
if (!def.cron_expr || !e.owner_id || !e.page_name) continue;
index[cronEntryKey(e.owner_id, e.page_name)] = def.cron_expr;
}
// 即使是空的也要寫回去:寫了之後 get 就命中,下一分鐘的 tick 不會再重算一次
//(不寫的話 scheduled() 每分鐘都會回源 KBDB 一趟,白花錢)。
await this.kv.put(CRON_INDEX_KEY, JSON.stringify(index));
}
// ── KVNamespace 介面 ───────────────────────────────────────────────────
async get(key: string, type?: 'text' | 'json' | 'arrayBuffer' | 'stream' | { type: string }): Promise<any> {
// 二進位/串流形態本 worker 沒有呼叫端在用(資產都是 JSON 文字)。原樣轉發,
// 不假裝支援——真有人開始用而拿不到 KBDB 回源,會在這裡被看見,不是靜默降級。
const t0 = typeof type === 'string' ? type : type?.type;
if (t0 === 'arrayBuffer' || t0 === 'stream') return this.kv.get(key, t0 as 'arrayBuffer');
const asText = (raw: string | null): unknown => {
if (raw === null) return null;
const t = typeof type === 'string' ? type : type?.type;
if (t === 'json') {
try { return JSON.parse(raw); } catch { return null; }
}
return raw;
};
const ref = classifyAssetKey(key);
if (!ref) {
// 衍生/暫存:原樣走 KV。讀不到而且是「算得出來」的索引 → 重算一次再讀。
const raw = await this.kv.get(key, 'text');
if (raw !== null) return asText(raw);
if (key.startsWith('idx:')) {
await this.rehydrateRecipeIndices().catch(() => {});
return asText(await this.kv.get(key, 'text'));
}
if (key === CRON_INDEX_KEY) {
await this.rehydrateCronIndex().catch(() => {});
return asText(await this.kv.get(key, 'text'));
}
return asText(raw);
}
// 資產:快取優先,miss 回源 KBDB 並補快取(被換掉的空 KV 就是這樣自己痊癒的)。
const cached = await this.kv.get(key, 'text');
if (cached !== null) return asText(cached);
const fromKbdb = entryToKvValue(await this.kbdbGetEntry(ref.entry_id));
if (fromKbdb === null) return asText(null);
await this.kv.put(key, fromKbdb).catch(() => {}); // 補快取失敗不影響這次讀取
return asText(fromKbdb);
}
async put(key: string, value: string | ArrayBuffer | ReadableStream, options?: KVNamespacePutOptions): Promise<void> {
// 帶 TTL=定義上就是暫存(daemon 回報、session…),不是資產,不進 KBDB。
if (options?.expirationTtl || options?.expiration || typeof value !== 'string') {
return this.kv.put(key, value as string, options);
}
// idx:installed:{canonical} 不是資產,但它記的是**使用者的選擇**(這個 canonical 目前
// 裝的是哪一版),純算不回來。所以把它記進對應那一版 recipe 資產的信封裡,
// KBDB 端不會多出一列「指標 entry」,重建時又還原得精確(見 rehydrateRecipeIndices)。
if (key.startsWith('idx:installed:')) {
await this.kv.put(key, value, options);
await this.markInstalledVersion(key.slice('idx:installed:'.length), value).catch(() => {
// 標記失敗不擋主流程:recipe 本體已經在 KBDB,最壞情況是重建時退回 updated_at 那條路。
});
return;
}
const ref = classifyAssetKey(key);
if (!ref) return this.kv.put(key, value, options);
let definition: unknown;
let definitionRaw: string | undefined;
try { definition = JSON.parse(value); } catch { definitionRaw = value; }
// 覆寫定義不該把「這版是目前安裝的那版」洗掉 → 只有 api_recipe 需要先讀回舊信封。
// 其他三型沒有這個欄位,省下這一次往返(每次 acr push 都會走到這裡)。
const previous = ref.entry_type === 'api_recipe' ? await this.readEnvelope(ref) : null;
// 先真相、後快取。KBDB 失敗就拋——不寫 KV、不回報成功(禁假綠,mindset §7)。
await this.kbdbPutEntry(ref, {
arcrun_asset: true,
kv_key: key,
...(definitionRaw !== undefined ? { definition_raw: definitionRaw } : { definition }),
...(previous?.installed ? { installed: true } : {}),
});
await this.kv.put(key, value, options);
}
async delete(key: string): Promise<void> {
const ref = classifyAssetKey(key);
if (ref) await this.kbdbDeleteEntry(ref.entry_id);
await this.kv.delete(key);
}
async list(options?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> {
const target = classifyListPrefix(options?.prefix ?? undefined);
if (!target) return this.kv.list(options) as Promise<KVNamespaceListResult<unknown, string>>;
// 資產列舉一律回源(見檔頭規則 3)。順手把每一筆補進快取——列表回應本來就帶了完整內容,
// 呼叫端接著一筆筆 get 時就會全部命中,一次回源換掉 N 次往返。
const entries = await this.kbdbListEntries(target.entry_type, target.owner_id);
const keys: Array<{ name: string }> = [];
const warm: Array<Promise<unknown>> = [];
for (const e of entries) {
if (!e.page_name) continue;
// workflow_def 的 KV key 由租戶+名字組成,缺租戶就組不出正確的 key——
// 與其回一個 `:wf:x` 這種對不到任何東西的名字,不如跳過(列不出來看得見,
// 組錯名字則會安靜地讀到 null,那更難查)。
if (target.entry_type === 'workflow_def' && !e.owner_id) continue;
const name = assetKvKey(target.entry_type, e.owner_id ?? null, e.page_name);
keys.push({ name });
const raw = entryToKvValue(e);
if (raw !== null) warm.push(this.kv.put(name, raw).catch(() => {}));
}
await Promise.all(warm);
return { keys, list_complete: true, cacheStatus: null } as unknown as KVNamespaceListResult<unknown, string>;
}
/** KVNamespace 介面補齊(本 worker 沒有呼叫端在用,原樣轉發,不做資產處理)。 */
getWithMetadata(key: string, type?: any): Promise<any> {
return (this.kv as unknown as { getWithMetadata: (k: string, t?: any) => Promise<any> }).getWithMetadata(key, type);
}
// ── 內部小工具 ─────────────────────────────────────────────────────────
private async readEnvelope(ref: AssetRef): Promise<AssetEnvelope | null> {
const entry = await this.kbdbGetEntry(ref.entry_id);
if (!entry?.metadata_json) return null;
try {
const env = JSON.parse(entry.metadata_json) as AssetEnvelope;
return env?.arcrun_asset === true ? env : null;
} catch {
return null;
}
}
/** 把「這個 canonical 目前裝的是哪一版」記進該版 recipe 的信封(同 canonical 的其他版清掉旗標)。 */
private async markInstalledVersion(canonicalId: string, uuid: string): Promise<void> {
const entries = await this.kbdbListEntries('api_recipe');
const jobs: Array<Promise<unknown>> = [];
for (const e of entries) {
const raw = entryToKvValue(e);
if (!raw) continue;
let def: { uuid?: string; canonical_id?: string };
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
if (def.canonical_id !== canonicalId || !def.uuid) continue;
const shouldBeInstalled = def.uuid === uuid;
let envelope: AssetEnvelope;
try { envelope = JSON.parse(e.metadata_json ?? '{}') as AssetEnvelope; } catch { continue; }
if ((envelope.installed === true) === shouldBeInstalled) continue; // 已經是對的,不白寫
envelope.installed = shouldBeInstalled;
jobs.push(
fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(e.id)}`, {
method: 'PATCH',
headers: kbdbHeaders(this.env),
body: JSON.stringify({ metadata_json: JSON.stringify(envelope) }),
}),
);
}
await Promise.all(jobs);
}
}
/**
* worker WEBHOOKS / RECIPES KBDB
*
* **使西**recipe
* KVEXEC_CONTEXT SESSIONS_KVANALYTICS_KVCREDENTIALS_KV
* credential CF Workers Secrets + D1
* .claude/rules/01-tech-stack.md****
*
* KBDB_BASE_URL kbdbBase()
* 退 KV
*/
export function withDurableStores<T extends { WEBHOOKS?: KVNamespace; RECIPES?: KVNamespace } & KbdbEnv>(env: T): T {
const wrapped = { ...env } as T;
if (env.WEBHOOKS) wrapped.WEBHOOKS = new DurableKv(env.WEBHOOKS, env) as unknown as KVNamespace;
if (env.RECIPES) wrapped.RECIPES = new DurableKv(env.RECIPES, env) as unknown as KVNamespace;
return wrapped;
}
/**
* KV DurableKv.rawKv
* grep routes/storage.ts
*/
export function unwrapKv(binding: KVNamespace): KVNamespace {
const maybe = binding as unknown as { rawKv?: KVNamespace };
return maybe.rawKv ?? binding;
}
+7 -1
View File
@@ -132,7 +132,13 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
// execution_log/execution_log_usageKV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
// KV 退休(#16/#17)新增四型:資產定義本體住進 KBDB 之後,它們是「系統的東西」而不是
// 使用者的知識卡——知識瀏覽/搜尋要跟 execution_log 一樣排除,否則 portal 會冒出
// 一堆 workflow_def / api_recipe 汙染結果。
const INTERNAL_ENTRY_TYPES = new Set([
'value', 'workflow', 'execution_log', 'execution_log_usage',
'workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe',
]);
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
entries: T[],
+199
View File
@@ -0,0 +1,199 @@
/**
* /storage KV KBDB
*
* KV 退Leo/Arcrun#16 + #17
* 西****
* ****
* GET /storage/audit
* POST /storage/migrate-to-kbdb
*
*
* 1. **** KV
* KV 退
* 2. ****KBDB entry idasset-keys.ts
* missing
* 3. ****
* mindset §7 after before
*
* flag AI ** cron**
*KV list 1000/ list
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { unwrapKv } from '../lib/durable-store';
import { classifyAssetKey, type AssetEntryType } from '../lib/asset-keys';
export const storageRouter = new Hono<{ Bindings: Bindings }>();
/** 本卷管的四類資產,以及它們住在哪顆 KV。 */
const ASSET_SOURCES: Array<{ binding: 'WEBHOOKS' | 'RECIPES' }> = [
{ binding: 'WEBHOOKS' },
{ binding: 'RECIPES' },
];
interface ScannedKey {
key: string;
binding: 'WEBHOOKS' | 'RECIPES';
entry_type: AssetEntryType;
entry_id: string;
}
/** 掃一顆 KV 的全部 key(跟著 cursor 走完,不只第一頁),挑出屬於資產的。 */
async function scanAssetKeys(kv: KVNamespace, binding: 'WEBHOOKS' | 'RECIPES'): Promise<ScannedKey[]> {
const found: ScannedKey[] = [];
let cursor: string | undefined;
do {
const page = await kv.list(cursor ? { cursor } : {});
for (const k of page.keys) {
const ref = classifyAssetKey(k.name);
if (ref) found.push({ key: k.name, binding, entry_type: ref.entry_type, entry_id: ref.entry_id });
}
cursor = page.list_complete ? undefined : page.cursor;
} while (cursor);
return found;
}
function kbdb(env: Bindings): { base: string; headers: Record<string, string> } {
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return { base, headers };
}
/** KBDB 端某型別現有的 entry id 集合(用來算「哪幾筆還沒搬過去」)。 */
async function kbdbExistingIds(env: Bindings, entryType: AssetEntryType): Promise<Set<string>> {
const { base, headers } = kbdb(env);
const res = await fetch(`${base}/entries?entry_type=${encodeURIComponent(entryType)}&limit=1000`, { headers });
if (!res.ok) throw new Error(`KBDB 讀取失敗(${entryType}):HTTP ${res.status}`);
const json = (await res.json().catch(() => null)) as { entries?: Array<{ id: string }> } | null;
return new Set((json?.entries ?? []).map((e) => e.id));
}
const ASSET_TYPES: AssetEntryType[] = ['workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe'];
interface Tally {
kv: Record<string, number>;
kbdb: Record<string, number>;
missing_in_kbdb: string[];
}
/** 兩邊各數一次,並列出「KV 有、KBDB 沒有」的那幾筆。 */
async function tally(env: Bindings): Promise<Tally> {
const scanned: ScannedKey[] = [];
for (const src of ASSET_SOURCES) {
const binding = env[src.binding];
if (!binding) continue;
scanned.push(...(await scanAssetKeys(unwrapKv(binding), src.binding)));
}
const kvCounts: Record<string, number> = {};
for (const t of ASSET_TYPES) kvCounts[t] = 0;
for (const s of scanned) kvCounts[s.entry_type] += 1;
const kbdbCounts: Record<string, number> = {};
const existing = new Map<AssetEntryType, Set<string>>();
for (const t of ASSET_TYPES) {
const ids = await kbdbExistingIds(env, t);
existing.set(t, ids);
kbdbCounts[t] = ids.size;
}
const missing = scanned
.filter((s) => !existing.get(s.entry_type)!.has(s.entry_id))
.map((s) => s.key);
return { kv: kvCounts, kbdb: kbdbCounts, missing_in_kbdb: missing };
}
// GET /storage/audit — 唯讀盤點。搬之前先看、搬之後再看,兩次數字自己會說話。
storageRouter.get('/storage/audit', async (c) => {
try {
const t = await tally(c.env);
return c.json({
success: true,
kv_asset_counts: t.kv,
kbdb_asset_counts: t.kbdb,
missing_in_kbdb: t.missing_in_kbdb,
missing_count: t.missing_in_kbdb.length,
verdict:
t.missing_in_kbdb.length === 0
? 'KV 裡的資產在 KBDB 都有一份——換掉 KV 不會弄丟東西。'
: `還有 ${t.missing_in_kbdb.length} 筆只存在於 KV。跑 POST /storage/migrate-to-kbdb 把它們搬過去。`,
});
} catch (e) {
// 誠實:盤點本身失敗就說失敗,不回一個看起來很乾淨的 0(那會被讀成「沒東西要搬」)。
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
}
});
// POST /storage/migrate-to-kbdb — 把 KV 裡的資產補進 KBDB。只增不刪、冪等、可重跑。
// body(都可省略):{ dry_run?: boolean }
storageRouter.post('/storage/migrate-to-kbdb', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { dry_run?: boolean };
const dryRun = body.dry_run === true;
let before: Tally;
try {
before = await tally(c.env);
} catch (e) {
return c.json({ success: false, error: `搬遷前盤點失敗,未動任何資料:${e instanceof Error ? e.message : String(e)}` }, 502);
}
if (dryRun) {
return c.json({
success: true,
dry_run: true,
before: { kv: before.kv, kbdb: before.kbdb },
would_migrate: before.missing_in_kbdb,
would_migrate_count: before.missing_in_kbdb.length,
});
}
const migrated: string[] = [];
const errors: Array<{ key: string; error: string }> = [];
for (const src of ASSET_SOURCES) {
const wrapped = c.env[src.binding];
if (!wrapped) continue;
const raw = unwrapKv(wrapped);
for (const s of await scanAssetKeys(raw, src.binding)) {
try {
// 從**真** KV 讀原值,再用包裝過的 binding 寫回去——寫入路徑就是平常那條
//(先 KBDB 後快取),所以搬遷用的是跟日常寫入完全同一段程式碼,不另開一條會漂移的路。
const value = await raw.get(s.key, 'text');
if (value === null) continue; // 掃到當下剛好被刪:不是錯,跳過
await wrapped.put(s.key, value);
migrated.push(s.key);
} catch (e) {
errors.push({ key: s.key, error: e instanceof Error ? e.message : String(e) });
}
}
}
let after: Tally | null = null;
let afterError: string | null = null;
try {
after = await tally(c.env);
} catch (e) {
afterError = e instanceof Error ? e.message : String(e);
}
const clean = errors.length === 0 && after !== null && after.missing_in_kbdb.length === 0;
return c.json(
{
success: clean,
before: { kv: before.kv, kbdb: before.kbdb, missing_in_kbdb: before.missing_in_kbdb.length },
migrated,
migrated_count: migrated.length,
errors,
after: after
? { kv: after.kv, kbdb: after.kbdb, missing_in_kbdb: after.missing_in_kbdb }
: { error: afterError },
verdict: clean
? '搬完了:KV 裡的每一筆資產在 KBDB 都有對應的一列(missing 歸零)。KV 原始資料原封不動保留。'
: '**沒有搬乾淨**——看 errors 與 after.missing_in_kbdb。修掉原因後可以直接重跑(冪等,不會產生重複)。',
},
clean ? 200 : 500,
);
});
+91
View File
@@ -0,0 +1,91 @@
/**
* asset-keys KV key 使
*
* KV 退Leo/Arcrun#16 + #17 KV / KBDB /
* 西****
*
*
* - 2026-08-12
* - KBDB
*/
import { describe, it, expect } from 'vitest';
import { classifyAssetKey, classifyListPrefix, assetKvKey } from '../src/lib/asset-keys';
import { CRON_INDEX_KEY } from '../src/lib/cron-index';
describe('classifyAssetKey — 資產', () => {
it('具名工作流 {api_key}:wf:{name} → workflow_def,租戶與名字都拆得出來', () => {
const ref = classifyAssetKey('leo:wf:rag_chat');
expect(ref).not.toBeNull();
expect(ref!.entry_type).toBe('workflow_def');
expect(ref!.owner_id).toBe('leo');
expect(ref!.page_name).toBe('rag_chat');
expect(ref!.kv_key).toBe('leo:wf:rag_chat');
});
it('api recipeuuid key 與 migration 前的 canonical key 都算資產)', () => {
expect(classifyAssetKey('recipe:8f3b-uuid')!.entry_type).toBe('api_recipe');
expect(classifyAssetKey('recipe:telegram_send')!.entry_type).toBe('api_recipe');
});
it('auth recipe / prompt recipe', () => {
expect(classifyAssetKey('auth_recipe:notion')!.entry_type).toBe('auth_recipe');
expect(classifyAssetKey('auth_recipe:notion')!.page_name).toBe('notion');
expect(classifyAssetKey('prompt_recipe:wiki_synthesis')!.entry_type).toBe('prompt_recipe');
});
it('同一個 key 永遠對到同一個 entry_id(冪等的根據——重跑遷移不會長出重複列)', () => {
expect(classifyAssetKey('leo:wf:a')!.entry_id).toBe(classifyAssetKey('leo:wf:a')!.entry_id);
expect(classifyAssetKey('leo:wf:a')!.entry_id).not.toBe(classifyAssetKey('evan:wf:a')!.entry_id);
});
});
describe('classifyAssetKey — 衍生資料不可誤收', () => {
it('recipe 反查索引 idx:* 全部不是資產(算得回來,見 rehydrateRecipeIndices', () => {
expect(classifyAssetKey('idx:rec_f7e2a1b3')).toBeNull();
expect(classifyAssetKey('idx:canonical:telegram_send')).toBeNull();
expect(classifyAssetKey('idx:installed:telegram_send')).toBeNull();
});
it('cron 索引不是資產', () => {
expect(classifyAssetKey(CRON_INDEX_KEY)).toBeNull();
expect(classifyAssetKey('cron-idx:leo:daily')).toBeNull();
});
it('匿名 webhook token / 其他暫存 key 不是資產(本卷不碰,非漏收)', () => {
expect(classifyAssetKey('a1b2c3d4e5f6')).toBeNull();
expect(classifyAssetKey('daemon-active:leo')).toBeNull();
});
it('殘缺的 key 不當資產(寧可退回原本的 KV 行為,也不要建出半截的列)', () => {
expect(classifyAssetKey('recipe:')).toBeNull();
expect(classifyAssetKey('auth_recipe:')).toBeNull();
expect(classifyAssetKey(':wf:orphan')).toBeNull();
expect(classifyAssetKey('leo:wf:')).toBeNull();
});
});
describe('assetKvKey — 從 KBDB 反推回 KV key(回填快取與 list 都靠它)', () => {
it('四型都能原路折返', () => {
for (const key of ['leo:wf:rag_chat', 'recipe:telegram_send', 'auth_recipe:notion', 'prompt_recipe:x']) {
const ref = classifyAssetKey(key)!;
expect(assetKvKey(ref.entry_type, ref.owner_id, ref.page_name)).toBe(key);
}
});
});
describe('classifyListPrefix — 列舉走哪一邊', () => {
it('租戶工作流列舉 → 走 KBDB(帶 owner', () => {
expect(classifyListPrefix('leo:wf:')).toEqual({ entry_type: 'workflow_def', owner_id: 'leo' });
});
it('recipe / auth_recipe 列舉 → 走 KBDB', () => {
expect(classifyListPrefix('recipe:')).toEqual({ entry_type: 'api_recipe' });
expect(classifyListPrefix('auth_recipe:')).toEqual({ entry_type: 'auth_recipe' });
});
it('索引與無 prefix 的全域列舉 → 維持原本的 KV 行為', () => {
expect(classifyListPrefix('idx:')).toBeNull();
expect(classifyListPrefix('cron-idx:')).toBeNull();
expect(classifyListPrefix(undefined)).toBeNull();
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* waitArcrun#101
*
* leo 2026-08-12 youlin stage input >> wait
* ms=3000 38.9s 503(1102) / ms=20000 34.0s / ms=30000 34.9s / 3000 34.8s
* ms N N CPUms=3000 3
*
*
* wait TinyGo WASMtime.Sleep WASI poll_oneoffcomponent worker
* WASI shim poll_oneoff ENOSYS TinyGo 退 clock_time_get
* Workers I/O
*
*
* A. workerd
* B. wait timer
* C. workflow wait
* D. wait step 1 arcrun-wait worker fetch
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { env } from 'cloudflare:test';
import { BUILTIN_COMPONENTS, WAIT_MAX_MS } from '../src/lib/constants';
import { createComponentLoader } from '../src/lib/component-loader';
import type { Bindings, ComponentRunner } from '../src/types';
const wait = BUILTIN_COMPONENTS.get('wait') as ComponentRunner;
afterEach(() => {
vi.unstubAllGlobals();
});
// ── A. 反向驗證:舊路徑為什麼不可能便宜地等 ──────────────────────────────────
//
// 直接跑那顆 component.wasm 沒辦法寫成安全的測試 —— 它會把 isolate 卡到 CPU 上限,
// 測試無從中止(那正是 bug 本身)。所以這裡驗的是「**沙箱裡根本沒有睡覺這個手段**」。
//
// 🔴 這裡本來有一條斷言「Workers 的時鐘在同步執行期間凍結,所以自旋迴圈的結束條件
// 永遠不成立」。**實跑打臉了**:在 vitest-pool-workers 的 workerd 裡,2553 圈之後
// Date.now() 就前進了。⇒ 那條斷言被刪掉,不是改鬆——它從一開始就不是證據。
//
// 保留下來的是**查證得動的那一半**WASI shim 把 poll_oneoff 實作成 ENOSYS(76)
// TinyGo 的 time.Sleep 只有這一條路可走 ⇒ 拿不到「睡到某個時刻」的手段,
// 只能退化成自旋。至於「自旋為什麼會拖到 35 秒才死」的完整機制**仍是推測**,
// 證據是 leo 在 youlin stage 的四次實測(見檔頭),不是本檔任何一條斷言。
//
// ⇒ 而修法不依賴那個推測:純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)
// 本來就沒有「不花 CPU 地等」這種東西,會等的只有宿主。無論卡死的細節是什麼,
// 等待都該搬回引擎。
// 「poll_oneoff 是 ENOSYS」這件事查原始碼即可(`wasi-shim.ts:319` 的
// `poll_oneoff: () => WASI_ENOSYS`,以及 13 個 `.component-builds/*/src/index.ts`
// 的 `poll_oneoff: () => 76`)。**沒有為它硬寫一條測試**——寫得出來的只會是
// 「把字串抓出來比對」,那驗的是抓字串,不是行為。事實放註解,斷言留給真的驗行為的 B/C/D。
describe('A. 反向驗證:WASI 沙箱裡沒有「睡覺」這個手段', () => {
it('對照組:await 一個 timer 之後時鐘才會前進(=為什麼修法必須在引擎側 await)', async () => {
const t0 = Date.now();
await new Promise<void>((r) => setTimeout(r, 20));
expect(Date.now()).toBeGreaterThan(t0);
});
});
// ── B. 修法本體:等待是 timer,不是佔用執行緒 ────────────────────────────────
describe('B. 引擎側的 wait 真的讓出執行緒(等 30 秒與等 3 秒同價)', () => {
it('5 個 300ms 的 wait 併發跑完 ≈ 300ms 而非 1500ms(會 blocking 的實作做不到這件事)', async () => {
const started = Date.now();
const results = await Promise.all(
Array.from({ length: 5 }, () => wait({ ms: 300 })),
);
const elapsed = Date.now() - started;
for (const r of results) {
expect(r).toEqual({ success: true, data: { waited_ms: 300 } });
}
// 序列化(blocking)會是 ~1500ms;讓出執行緒則 5 個計時器同時走完 ≈ 300ms。
// 抓 900ms 當門檻:離 300 夠鬆、離 1500 夠遠。
expect(elapsed).toBeLessThan(900);
expect(elapsed).toBeGreaterThanOrEqual(300);
});
it('等待期間 event loop 沒被佔住:同時排的 timer 照樣先到', async () => {
const order: string[] = [];
const waited = Promise.resolve(wait({ ms: 400 })).then(() => { order.push('wait-400'); });
const ticked = new Promise<void>((r) => setTimeout(r, 50)).then(() => { order.push('tick-50'); });
await Promise.all([waited, ticked]);
expect(order).toEqual(['tick-50', 'wait-400']);
});
});
// ── C. 契約沒變:既有 wait 節點定義不用改 ────────────────────────────────────
//
// 逐條對 registry/components/wait/component.contract.yaml 的 gherkin_tests。
describe('C. I/O 契約與 WASM 版一致(既有 workflow 不必改定義)', () => {
it('contract gherkin:等待 100ms → waited_ms:100', async () => {
expect(await wait({ ms: 100 })).toEqual({ success: true, data: { waited_ms: 100 } });
});
it('contract gherkinms 為 0 時失敗(不是靜靜跳過)', async () => {
expect(await wait({ ms: 0 })).toEqual({ success: false, error: 'ms 必須大於 0' });
});
it('ms 缺漏 / 負數 / 非數字,一律誠實回 success:false,不假裝等過', async () => {
for (const bad of [undefined, null, -1, 'abc', {}, []]) {
expect(await wait({ ms: bad })).toEqual({ success: false, error: 'ms 必須大於 0' });
}
});
it('contract gherkinms=99999 截斷為上限 30000(不是報錯、也不是真的等 99 秒)', async () => {
// 不真的等 30 秒:換掉 setTimeout,攔下引擎「要求等多久」再立刻放行。
const asked: number[] = [];
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
asked.push(Number(delay));
fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout);
expect(await wait({ ms: 99999 })).toEqual({ success: true, data: { waited_ms: WAIT_MAX_MS } });
expect(asked).toEqual([WAIT_MAX_MS]);
expect(WAIT_MAX_MS).toBe(30000); // 紅線:上限不准為了閃避資源限制被調小
});
it('ms=30000 一路走到底也只是「排一個 30 秒的 timer」,沒有任何同步佔用', async () => {
const asked: number[] = [];
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
asked.push(Number(delay));
fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout);
expect(await wait({ ms: 30000 })).toEqual({ success: true, data: { waited_ms: 30000 } });
expect(asked).toEqual([30000]);
});
it('context 照契約透傳,並補上 waited_ms', async () => {
const r = await wait({ ms: 5, context: { order_id: 'A-1', payload: { n: 2 } } });
expect(r).toEqual({
success: true,
data: { order_id: 'A-1', payload: { n: 2 }, waited_ms: 5 },
});
});
it('node.data 經 interpolateData 後 ms 會是字串 —— 收得下(WASM 版在這裡直接 unmarshal 失敗)', async () => {
expect(await wait({ ms: '250' })).toEqual({ success: true, data: { waited_ms: 250 } });
});
});
// ── D. 路由:不再打 arcrun-wait worker ───────────────────────────────────────
describe('D. component-loader 把 wait 解到內建 runnerstep 1),不發任何 fetch', () => {
it('loader("wait") 跑起來不會對外送出任何請求', async () => {
const fakeEnv = { ...env, WORKER_SUBDOMAIN: 'test-sub' } as unknown as Bindings;
const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
const runner = await createComponentLoader(fakeEnv)('wait');
const r = await runner({ ms: 10 });
expect(r).toEqual({ success: true, data: { waited_ms: 10 } });
// 修法前這裡會打 arcrun-wait.test-sub.workers.devSVC_WAIT 未綁時的 fallback),
// 那顆 worker 就是會燒到 1102 的那顆。
expect(fetchSpy).not.toHaveBeenCalled();
});
it('wait 仍在「執行期真的解析得動」的清單裡(/cypher/search 查得到)', async () => {
const { RUNTIME_NATIVE_COMPONENT_IDS } = await import('../src/lib/component-loader');
expect(RUNTIME_NATIVE_COMPONENT_IDS.has('wait')).toBe(true);
});
});
@@ -0,0 +1,45 @@
-- arcrun 資產型別 template seed — KV 退休(Leo/Arcrun#16 + #17
--
-- 為什麼有這一檔(leo 2026-08-12 原話):
-- 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
-- 「如果零件和工作流的 recipe 不見了,是很可怕的事情」
-- 同一天(2026-08-12)真的發作過:一次例行更新讓使用者的九支工作流在畫面上全部消失
-- (根因見 cli/src/lib/resource-resolver.ts 檔頭 Arcrun#97——舊 deploy 會照名字新建一顆空 KV
-- 再綁上去)。#97 修的是「不要再把 worker 綁到空的資源上」;本卷修的是更根本的一句:
-- **使用者的資產本來就不該只存在於一個會被換掉的暫存層裡。**
--
-- KBDB 鐵律(leo 2026-06-14D38):三張表打天下,永遠不加新 table,新資料類型一律用 template。
-- 本檔**零 schema 異動**——只 INSERT OR IGNORE 四列 template 定義,手法與同目錄
-- 0003_library_map.sql / 0004_execution_log_template.sql 完全相同。
--
-- 儲存精神比照 0004execution_log)與 recipe-stattemplate 只負責「schema 文件化 +
-- GET /templates 可發現」,實際一筆資產是 entries 表的**一列**——
-- entry_type = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe'
-- owner_id = 租戶(workflow 才有;recipe 是整台實例共用的庫,故為 NULL)
-- page_name = 該型別的自然鍵(workflow 名 / recipe uuid / service 名)
-- content = 給人看也給語意搜尋看的一句描述
-- metadata_json = 定義本體(graph / endpoint / inject … 原樣 JSON
-- ——不走 entry_values 全展開的多列 record:一支 workflow 的 graph 是一整包巢狀 JSON
-- 拆成 slot 多列既不會變得比較好查,反而讓「一筆資產=一列」這件事不再成立
-- recipe_stat 與 execution_log 早已示範「template 存在 + entries 直接存」這個模式合法)。
--
-- 讀寫一律走 HTTP API/entries、/entries/:id),呼叫端是 cypher-executor 的
-- src/lib/durable-store.ts。牆外沒有任何一行 SQL。
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
VALUES
('tpl-workflow-def', 'workflow_def',
'工作流定義本體(KV 退休 #17)。一支工作流=entries 一列;graph/config/cron_expr 打包進 metadata_jsonWEBHOOKS KV 降為可丟棄的快取',
'["name","description","graph","config","cron_expr","created_at"]', 'system'),
('tpl-api-recipe', 'api_recipe',
'API recipe 定義本體(KV 退休 #16)。一份 recipeentries 一列;endpoint/headers/body/auth 等打包進 metadata_jsonRECIPES KV 降為快取。idx:* 反查索引屬衍生資料,不進 KBDB,由 durable-store 從本型別重建',
'["uuid","canonical_id","hash_id","author","endpoint","method","auth_service","installed"]', 'system'),
('tpl-auth-recipe', 'auth_recipe',
'Auth recipe 定義本體(KV 退休 #16)。一個服務一列;primitive/base_url/required_secrets/inject 打包進 metadata_json。只存「怎麼認證」,不存任何密文(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md',
'["service","primitive","base_url","version","required_secrets","inject"]', 'system'),
('tpl-prompt-recipe', 'prompt_recipe',
'Prompt recipe 定義本體(KV 退休 #16)。一份 prompt recipeentries 一列,定義打包進 metadata_json',
'["name","definition"]', 'system');
+45
View File
@@ -134,6 +134,51 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
/**
* idKV 退 #16/#17
*
* base `createEntry` id
* GET POST PATCH
*
*
* base entry_type
* arcrun workflow_def / api_recipe / auth_recipe
* ** schema ** entries
*
* PATCH
*
* created_at updated_at
*/
export async function upsertEntry(db: D1Database, id: string, input: Omit<CreateEntryInput, 'id'>): Promise<Entry> {
const existing = await getEntry(db, id);
if (!existing) return createEntry(db, { ...input, id });
await db
.prepare(
`UPDATE entries
SET content = ?, entry_type = ?, owner_id = ?, parent_id = ?, page_name = ?,
refs_json = ?, tags_json = ?, task_status = ?, confidence = ?, metadata_json = ?,
updated_at = unixepoch()
WHERE id = ?`,
)
.bind(
input.content ?? null,
input.entry_type,
input.owner_id ?? null,
input.parent_id ?? null,
input.page_name ?? null,
input.refs_json ?? '[]',
input.tags_json ?? '[]',
input.task_status ?? null,
input.confidence ?? null,
input.metadata_json ?? null,
id,
)
.run();
const row = await getEntry(db, id);
if (!row) throw new Error('upsertEntry: update succeeded but row not found');
return row;
}
/**
* owner entries deprecatedt135 by-name
* 沿 deprecated metadata_json.status='deprecated'
+16
View File
@@ -9,6 +9,7 @@ import {
getEntry,
listEntries,
updateEntry,
upsertEntry,
deleteEntry,
searchEntries,
isDeprecatedEntry,
@@ -426,6 +427,21 @@ entryRoutes.get('/backfill-library/status', async (c) => {
return c.json({ success: true, ...status });
});
// PUT /entries/:id — 以呼叫端指定的 id 整列覆寫(不存在就新建)。KV 退休 #16/#17。
//
// 與 POST / 的差別:POST 的 id 是隨機的,同一份資產每存一次多一列;PUT 讓「同一份資產永遠
// 是同一列」,所以重跑遷移、重複部署同一支工作流都不會長出重複資料(冪等)。
// 與 PATCH /:id 的差別:PATCH 是部分更新(沒帶的欄位留著),PUT 是整列取代
// ——呼叫端手上是完整定義時要的是後者,否則「刪掉一個欄位」永遠做不到。
entryRoutes.put('/:id', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
const entry = await upsertEntry(c.env.DB, c.req.param('id'), body);
// 與 POST / 同款:標了 embed:true 的才進 Vectorizefire-and-forget、失敗不致命。
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
return c.json({ success: true, entry });
});
// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
+19 -1
View File
@@ -1,5 +1,23 @@
// wait — 等待指定毫秒數後繼續(最多 30 秒)
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
//
// ⚠️ 已由引擎接手,這份 WASM 在 Cloudflare Workers 上跑不動(Arcrun#1012026-08-12)。
// 現行實作在 cypher-executor/src/lib/constants.ts 的 BUILTIN_COMPONENTS['wait']
// component-loader step 1 先命中,這顆 wasm 不會再被工作流呼叫到。
//
// 為什麼跑不動(不是「比較慢」,是「永遠不會結束」):
// 下面的 time.Sleep 在 TinyGo 走 WASI poll_oneoff,而 component worker 的 WASI shim
// 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get 自旋;
// Workers 的時鐘在無 I/O 的同步執行期間是凍結的 ⇒ 結束條件永遠不成立 ⇒ 一路燒到
// CPU 上限被砍(error 1102)。leo 實測 ms=3000/20000/30000 全在 ~35 秒後 503
// 死法與 ms 無關 —— 這正是「迴圈沒結束」而非「等待很貴」的證據。
//
// 原本的舊註解寫「改用 busy-wait 模擬」是錯的:這個檔從來沒有 busy-wait,
// 一直是 time.Sleep。那句話誤導了後來每一個讀這個檔的人。
//
// 本次刻意不改行為、只改註解:手邊沒有 TinyGo 工具鏈,改了 main.go 卻沒重編,
// 會讓 repo 內已 commit 的 .component-builds/wait/component.wasm 與原始碼漂移
// rule 05「WASM 來源」:那份 wasm 是 self-host 用戶的部署來源)。
// 要退役這顆零件(刪目錄/下架 wait.arcrun.dev)是另一個決定,需人拍板。
package main
import (
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
#
# verify-kv-retirement.sh — 把「換掉 KV,資產還在」真的做一次
#
# KV 退休(Leo/Arcrun#16 + #17)。交辦的驗收條件逐字是:
# 「證明『換掉/重建那個暫存層,資產還在』——不是說明它會在,是**真的弄一次給我看**」
# 「既有的東西要能搬過去,而且搬的過程不能弄丟任何一筆(搬之前先數,搬之後再數)」
# 這支腳本就是那一次。它做的事,照順序:
#
# 1. 開一台**全新的空**本機實例(local D1 + local KV,跑真的 migrations
# 2. 用平常那條路(POST /webhooks/named、POST /recipes、POST /auth-recipes
# 放進 9 支工作流 + 3 份 recipe——9 是照 2026-08-12 那天真的消失的數量
# 3. 數一次(KV 幾筆、KBDB 幾筆)
# 4. **把整個 KV 層砍掉重建**(rm -rf 那顆 KV 的本機儲存 → 重開 worker)
# =模擬 Arcrun#97 那天發生的事:worker 被綁到一顆全新的空 KV
# 5. 再數一次,並且**真的觸發一支工作流**確認它還跑得動
#
# 通過的定義(不通就 exit 1,不留模稜兩可):
# 砍掉 KV 之後,列出來仍然是 9 支、recipe 仍在、工作流仍然跑得出結果。
#
# ⚠️ 全程只碰本機(--local + --persist-to 到暫存目錄),**不碰任何線上實例**。
# 腳本裡沒有任何 --remote、沒有任何真實帳號憑證。
#
# 用法: bash scripts/verify-kv-retirement.sh
# 需要: node 22+、pnpm、可執行 npx wrangler / curl 的 shell
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
KBDB_PORT=8801
CYPHER_PORT=8802
TOKEN="e2e-local-token"
TENANT="leo-e2e"
WF_COUNT=9
KBDB="http://127.0.0.1:${KBDB_PORT}"
CYPHER="http://127.0.0.1:${CYPHER_PORT}"
kbdb_pid=""; cypher_pid=""
cleanup() {
[ -n "$kbdb_pid" ] && kill "$kbdb_pid" 2>/dev/null || true
[ -n "$cypher_pid" ] && kill "$cypher_pid" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup EXIT
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
fail() { printf '\n\033[31m❌ %s\033[0m\n' "$*"; exit 1; }
wait_for() { # wait_for <url> <label>
for _ in $(seq 1 60); do
if curl -sf -m 2 "$1" >/dev/null 2>&1; then return 0; fi
sleep 1
done
fail "$2 沒有起來($1"
}
start_cypher() {
( cd "$REPO_ROOT/cypher-executor" && \
npx wrangler dev --local --port "$CYPHER_PORT" \
--config wrangler.test.toml \
--persist-to "$WORK/cypher-state" \
--var "KBDB_BASE_URL:$KBDB" \
--var "KBDB_INTERNAL_TOKEN:$TOKEN" \
--show-interactive-dev-session=false >"$WORK/cypher.log" 2>&1 ) &
cypher_pid=$!
wait_for "$CYPHER/health" "cypher-executor"
}
# ── 1. 空實例:真的跑 migrations ───────────────────────────────────────────────
say "1. 開一台全新的空實例(local D1 + local KV,跑真的 migrations"
( cd "$REPO_ROOT/kbdb" && npx wrangler d1 migrations apply DB --local --persist-to "$WORK/kbdb-state" )
( cd "$REPO_ROOT/kbdb" && \
npx wrangler dev --local --port "$KBDB_PORT" \
--persist-to "$WORK/kbdb-state" \
--var "KBDB_INTERNAL_TOKEN:$TOKEN" \
--show-interactive-dev-session=false >"$WORK/kbdb.log" 2>&1 ) &
kbdb_pid=$!
wait_for "$KBDB/health" "kbdb"
start_cypher
# ── 2. 用平常那條路放資產進去 ─────────────────────────────────────────────────
say "2. 放進 $WF_COUNT 支工作流 + 3 份 recipe(走的是 acr push 用的同一組端點)"
for i in $(seq 1 "$WF_COUNT"); do
curl -sf -X POST "$CYPHER/webhooks/named" \
-H 'Content-Type: application/json' -H "X-Arcrun-API-Key: $TENANT" \
-d "{\"name\":\"wf_$i\",\"description\":\"驗證用工作流 $i:把 text 轉大寫\",
\"graph\":{\"id\":\"wf_$i\",\"nodes\":[{\"id\":\"upper\",\"type\":\"Component\",\"componentId\":\"comp_uppercase\"}],\"edges\":[]}}" \
>/dev/null || fail "部署 wf_$i 失敗"
done
for svc in alpha beta; do
curl -sf -X POST "$CYPHER/recipes" \
-H 'Content-Type: application/json' \
-d "{\"canonical_id\":\"${svc}_send\",\"endpoint\":\"https://example.invalid/$svc\",\"description\":\"驗證用 recipe $svc\"}" \
>/dev/null || fail "建立 recipe $svc 失敗"
done
curl -sf -X POST "$CYPHER/auth-recipes" \
-H 'Content-Type: application/json' \
-d '{"service":"alpha","primitive":"static_key","base_url":"https://example.invalid",
"required_secrets":[{"key":"alpha_token","label":"Token","help_url":"https://example.invalid/docs"}],
"inject":{"header":{"Authorization":"Bearer {{secret.alpha_token}}"}}}' \
>/dev/null || fail "建立 auth recipe 失敗"
# ── 3. 搬之前先數 ─────────────────────────────────────────────────────────────
say "3. 數一次(/storage/auditKV 幾筆、KBDB 幾筆、差幾筆)"
curl -s "$CYPHER/storage/audit" | tee "$WORK/audit-before.json"; echo
before_wf="$(curl -s "$CYPHER/webhooks/named" -H "X-Arcrun-API-Key: $TENANT" | grep -o '"total":[0-9]*' | cut -d: -f2)"
echo "部署後列出來的工作流數:$before_wf"
[ "$before_wf" = "$WF_COUNT" ] || fail "還沒開始拆就對不上:期望 $WF_COUNT,實得 $before_wf"
# ── 4. 把 KV 層砍掉重建(模擬 2026-08-12 那天) ────────────────────────────────
say "4. 砍掉整個 KV 層並重建 —— 模擬 Arcrun#97 那天『worker 被綁到一顆全新的空 KV』"
kill "$cypher_pid" 2>/dev/null || true; wait "$cypher_pid" 2>/dev/null || true; cypher_pid=""
rm -rf "$WORK/cypher-state" # ← 這一行就是「那個暫存層被換掉」
echo "已刪除:$WORK/cypher-statecypher 的整顆本機 KV"
start_cypher
# ── 5. 再數一次,而且真的跑一支 ───────────────────────────────────────────────
say "5. KV 全空之後,再數一次"
after_json="$(curl -s "$CYPHER/webhooks/named" -H "X-Arcrun-API-Key: $TENANT")"
echo "$after_json" | head -c 400; echo
after_wf="$(echo "$after_json" | grep -o '"total":[0-9]*' | cut -d: -f2)"
echo "KV 砍掉重建後列出來的工作流數:$after_wf"
[ "$after_wf" = "$WF_COUNT" ] || fail "工作流少了:期望 $WF_COUNT,實得 $after_wf —— 資產沒有被保住"
recipes_after="$(curl -s "$CYPHER/recipes" | grep -o '"count":[0-9]*' | cut -d: -f2)"
echo "KV 砍掉重建後的 recipe 數:$recipes_after"
[ "${recipes_after:-0}" -ge 2 ] || fail "recipe 少了:期望 >=2,實得 ${recipes_after:-0}"
auth_after="$(curl -s "$CYPHER/auth-recipes/alpha" | grep -c '"success":true' || true)"
[ "$auth_after" = "1" ] || fail "auth recipe 不見了"
echo "auth recipe alpha:仍在"
say "5b. 不只是列得出來——真的觸發一支工作流"
run="$(curl -s -X POST "$CYPHER/webhooks/named/wf_3/trigger" \
-H 'Content-Type: application/json' -H "X-Arcrun-API-Key: $TENANT" \
-d '{"text":"still here"}')"
echo "$run" | head -c 400; echo
echo "$run" | grep -q 'STILL HERE' || fail "工作流列得出來卻跑不動——那不算資產還在"
say "結論"
printf '\033[32m✅ 通:整個 KV 層被砍掉重建之後,%s 支工作流、recipe、auth recipe 全部還在,且工作流真的跑得出結果。\033[0m\n' "$WF_COUNT"
echo " 資產的家=KBDBD1,一份資產一列 entry);KV 只是快取,砍掉會自己長回來。"
@@ -8,6 +8,75 @@
## 待裁決
### P-KVKV 退休:工作流與 recipe 的家搬到 KBDBLeo/Arcrun#16 + #17)— 2026-08-12
**觸發**leo 2026-08-12 原話——
> 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
> 「因為對 KV 的使用有禁令,但卻會把資產放在這裡,這不是違法嗎?」
> 「現在是我幫他寫工作流,未來是他的 AI 自己寫工作流,
> **如果零件和工作流的 recipe 不見了,是很可怕的事情**
同日實害:一次例行更新讓使用者的九支工作流在畫面上全部消失(Arcrun#97)。
#97 已修掉直接原因(`cli/src/lib/resource-resolver.ts`:不再照名字猜使用者的資源、
不再擅自新建一顆空的綁上去)。**本案修的是更下面那一句**:使用者的資產本來就不該
只存在於一個會被換掉的暫存層裡——#97 修的是「別再換錯」,這裡修的是「換了也不會怎樣」。
**為什麼要走規格層(D35**`.claude/rules/01-tech-stack.md`「資料儲存」那張表把
workflow 定義寫在 `WEBHOOKS` KV、recipe 寫在 `RECIPES` KV。改掉真相來源=改規格。
現行 active SDD 是 `workflow-discovery`,本案不在它的 tasks 內,故依 D35 第 3 條
寫 proposal 停下等 leo confirm。**#16 已被多份 SDD 引用為前提**
`arcrun/artifact-sharing/` design K2「KBDB 是唯一公庫後端」、requirements Out of Scope、
tasks 1.55.2 都寫明「遷移本體由 #16 負責」),所以這不是新方向,是那些卷等的那一塊落地。
**提議的規格(三句)**
1. **資產的真相來源=KBDB**D1 `entries` 一列一份資產)。KV 降級為可丟棄的快取。
2. **零 SQL、永不加表**D38):新增四個 `entry_type`
`workflow_def` / `api_recipe` / `auth_recipe` / `prompt_recipe`),
各在 `templates` 表 seed 一列定義(`kbdb/migrations/0005_arcrun_asset_templates.sql`
手法同 0003/0004)。定義本體打包進 `metadata_json`,比照 `execution_log``recipe_stat` 既有先例。
3. **衍生資料不進 KBDB**`idx:*`recipe 反查)、`cron-idx:_all` 算得回來,
留在 KV,讀不到就從 KBDB 重算(不讓 KBDB 長出垃圾列)。
**實作形狀(已寫在 `feat/kv-retire-recipes-16-17` 分支,未合併)**
- 換 binding 而不是改呼叫端:`src/index.ts` 入口把 `WEBHOOKS``RECIPES`
換成 KBDB 撐腰的包裝(`src/lib/durable-store.ts`),四十幾處呼叫端一行不動。
理由:逐處改寫一定會漏,**漏掉的那一處就是下一次「東西不見了」的入口**。
- 「哪些 key 是資產」集中成一張表(`src/lib/asset-keys.ts`),是唯一需要人看懂的東西。
- 讀=KV 先行、miss 回源 KBDB 並補快取;寫=先 KBDB 再 KV,KBDB 失敗就拋錯(禁假綠);
**列舉一律走 KBDB**——空 KV 列出來是「零筆」而不是「查不到」,那正是消失的形狀。
- 搬遷與盤點:`GET /storage/audit``POST /storage/migrate-to-kbdb`(只增不刪、冪等、逐筆回報)。
- KBDB 端只加一個通用原語:`PUT /entries/:id`(指定 id 的整列 upsert),零 schema 異動。
**影響分析**
- 現行 active SDD `workflow-discovery`**不受影響**。它的 `entry_type='workflow'`
搜尋 entry 照舊雙寫,本案刻意用另一個型別 `workflow_def` 存定義本體、且不標 `embed`
以免同一支工作流嵌兩份向量。search/backfill 兩支端點一行未動。
- `artifact-sharing`:本案就是它 K2 等的 #16。落地後可拆 tasks 1.5 的 KV 過渡轉接(5.2)。
- credential**不碰**。憑證走 CF Workers Secrets D1 目錄(rule 01),不在本案範圍。
- 匿名 webhook`webhooks.ts``put(token, record)`):**目前沒搬**,仍是 KV-only。
它也是使用者建出來的東西,但不在 #16/#17 的字面範圍內——在此列出,請 leo 裁要不要納入。
- 效能:資產讀取多一層快取判斷;快取命中時與現況相同,miss 時多一次 KBDB 往返。
`list` 一律回源,但會順手把整批補進快取,所以「列出來再逐筆讀」總共只多一次往返。
**尚未完成/誠實限制(決定要不要 confirm 前請先看這段)**
- **端到端證據沒跑**。實作環境(雲端工人沙箱)不放行執行測試與 HTTP
`vitest``node``curl` 皆被權限閘擋下),所以「砍掉 KV、資產還在」這一次
**我沒有真的做出來給你看**。已跑到的只有:5 份 migration 在本機 D1 全部套用成功、
兩顆 worker 都能以改動後的程式碼在本機開起來、`tsc` 錯誤數與改動前一致(7 個既有錯,未新增)。
- 那一次驗證已經寫成可執行的腳本 `scripts/verify-kv-retirement.sh`
(開空實例 → 放 9 支工作流+3 份 recipe → 數一次 → **砍掉整個 KV 層重建** → 再數一次
→ 真的觸發一支確認跑得動),**在能執行的機器上跑一次就是那個證據**。
- 因此本案的狀態是 **◐ 半通**:程式碼與遷移路徑齊備,證據缺一份。
**建議 confirm 的順序是「先跑那支腳本、綠了再合併」**,不要因為程式碼看起來完整就先併——
這件事的整個重點就是不要再有「看起來好好的,其實東西不見了」。
**⏸ 停在這裡等 leo 裁**
① 方向 confirm 嗎(資產真相來源改 KBDB、KV 降快取)?
② 匿名 webhook 要不要一起納入?
③ KV 舊資料要不要清(本案只增不刪,清是另一個決定)?
---
### P2|fan-out 並行執行(一個節點的多條出邊目前是循序跑)— 2026-08-03
**觸發**:leo 08-03 原話——「這是在測試中的計畫,**希望體驗很好**,我發現用 gemma4 的反應非常慢。」