Compare commits

..

6 Commits

Author SHA1 Message Date
uncle6me-web 614fe44812 fix(kbdb): 藏書地圖加 entry_count——triplet_count=0 不再被誤讀成「沒有知識」(Arcrun#87 三次收尾)
leo21c 實測:kbdb_get_map() 9 庫裡 8 庫 triplet_count:0/top_entities:[](kb 以外全部),
一個全新 session 讀到這份地圖會合理但錯誤地判定「這些庫沒有知識」而放棄查詢——但
kbdb_search(mode=keyword) 找得到 42 筆真內容(來源 gitea:Leo/Arcrun@…/gitea:Leo/mira@…等),
其中 15 筆 entries 的 metadata_json.library 已正確標成 arcrun/mira/arcrun-harness/arcrun-rag。

查證(唯讀,未動 leo21c 任何寫入):
- entries(原始 ingest 內容)與 triplet(從 entries 萃取出的三元組)是兩個不同存放處。
- kb 以外 7 庫:entries 有、library 標記正確;triplet 一筆都沒有——不是「三元組沒貼標」,
  是「三元組從沒被萃取」(另一條偵察線在查斷在哪一段,跨 repo,本 PR 不處理)。
- general 庫(未標 library 的三元組兜底分類)即時計數也是 0 ⇒ 沒有任何「有三元組但沒標庫」
  的候選 ⇒ Leo/Arcrun#87 先前那條批次補標通道(PR #114)在目前資料現況下會補到 0 筆,
  它解的是另一個問題,不是這次「地圖看起來是空的」的真因。

本次修法(純讀端加欄位,不碰任何寫入/部署/線上資源):
- kbdb/src/actions/library-map.ts:新增 liveEntryCountsByLibrary(),依 entries 自己的
  metadata_json.$.library 分組即時計數(排除 entry_type='value' 儲存碎片與地圖自己的歷史
  摘要 block,避免自我膨脹)。listLibraryMaps/getLibraryMapDetail/recomputeLibraryMap
  的回傳都加上 entry_count,與 triplet_count 並排、互不覆蓋。
- mcp/src/lib/library-map.ts:renderLibraryMapLines(MCP 連線開場注入的那份地圖原文)
  triplet_count=0 但 entry_count>0 時改印「0 triplets/N 筆原始內容(尚未萃取關係,
  kbdb_search 查得到)」,不再只印「0 triplets」。
- mcp/src/tools/kbdb_map.ts:kbdb_get_map 工具的全館/單庫回應都帶 entry_count,並在符合
  條件時附加提示,明講「triplet_count=0 不代表沒有知識」。
- console-ui/public/console/index.html:藏書地圖看板卡片同步顯示,人類看的畫面同一件事。

順手修掉一個真 SQL bug:entry_count 排除條件原寫
`NOT (entry_type='block' AND json_extract(...)='library_map')`,SQL 三值邏輯下
metadata_json 沒有 $.kind 欄位時 json_extract 回 NULL、`NULL = 'library_map'` 為 NULL
(非 false),整條 WHERE 判定 NULL 而把所有列濾掉——改用 COALESCE(...,'') 修正
(新增測試以 sqlite 實跑驗證抓到並鎖住這個修法)。

測試:kbdb 21/21(新增 2 案,全套 215/215);mcp kbdb-map 33/33(新增 7 案,全套 120/120)。
cypher-executor 既有 map/portal-data 相關測試(library-map-scope-108/kbdb-map-proxy/
portal-data)103/104 綠,唯一失敗(/portal HTML 殼 404)在未動過的 main checkout 上同樣
失敗,環境既有問題、與本次改動無關。

CP:◐ 半通。程式碼在此分支,測試綠燈,未併 main、未部署 leo21c——entry_count 要讓
leo21c 的真實使用者看到,需部署 kbdb+mcp(cypher-executor 未改動,portal-data.ts
是透明轉發不需重部署)。三元組萃取斷在哪一段(真因)與 23/42 entries 未 embed
(語意搜尋覆蓋率)兩件不在本 PR 範圍,分別交給另一條偵察線與 embed reconcile 管線。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:36:03 +08:00
uncle6me-web 9ff933ba98 chore(builds): 重編成品——把 #108 的租戶修法放進執行檔
cypher source 10d150a → b223a698(#108 併入)。
重編時新的租戶來源閘第一行就報:
  ✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts

⇒ 那道閘現在長在建置線上:以後有人再寫 env.XXX || 'leo',成品編不出來。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:25:46 +08:00
Leo 2fcae722e7 Merge PR #112: 藏書地圖看得到自己的知識——租戶字串改從寫入端來(Arcrun#108)
總管複驗(不聽自述,自己重跑並與 base 逐條比對):
  cypher  14 failed / 441 passed(base 14 / 400)⇒ 多 41 條新測試全過
          失敗清單 diff 無輸出=逐字元相同、零回歸
  閘實測  照 #105/#108 的原句形狀種一個違規(env.CONSOLE_TENANT || leo)
          → 掃描器當場抓到並指名道姓;移除後恢復綠 ⇒ 不是假綠

這道閘的價值超過本票:它擋的是「身分來自環境變數」整族,
#105 那句話今天再寫一次也會被擋。符合 leo 08-12 立的
「做平台要減少 hotfix」——修掉 bug 不算完成,要留下下次再犯會被擋住的機制。

agent 誠實標的殘項(不擋併):
  · kbdb_get_map 回 1851、Portal 總圖畫面、部分授權帳號實測——都要部署 leo21c,紅線沒碰
  · 想把閘也接進 .claude/hooks/pre-write-guard.sh(寫入前就擋),該檔受保護改不動;
    檢查器已備妥 --stdin 模式,總管代接
2026-08-12 16:24:45 +00:00
uncle6me-web fad5da0e17 rules/07 §3.6:自舉例外——能力只實作一次,但不一定要是 HTTP API
代補 PR #111 的 agent 想加但改不動的段落(該檔受保護)。

立這條的原因不是理論:#97 的修法一開始寫在 cli/src/lib/resource-resolver.ts
(能力住在介面層,違反 §0)⇒ 安裝器拿不到它 ⇒ 同一個 bug 只修了一半,
走 acr 的人有保護、走 install.arcrun.dev 的人沒有——而所有真實用戶走後者。

leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

記三件,都是為了不讓下一個人「修正」回去:
① 為什麼不放 cypher API(自舉/輸入是使用者自己的帳號狀態/它是純函式)
② §0 的正確讀法是「只准有一份、不准住在單一介面裡」,放 API 只是常見手段
③ npm 打包例外:cli/ 下的逐位元組副本由 sync --check 機械擋漂移,不是第二份實作

📍 repo:matrix/arcrun(shared/resource-rule/、cli/、.claude/rules/07)
   + products/arcrun-rag(安裝器待接上,已派工)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:50:53 +08:00
Leo 13155a1d7b Merge PR #111: 把「該用哪些資源」搬出 CLI——一份實作,兩條路吃同一條規則
leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

總管複驗(不聽自述,自己重跑):
  兩份 rule.mjs 逐位元組相同;--check 綠
  故意加一行製造漂移 → --check 當場擋下;還原後恢復綠 ⇒ 閘是真的,不是假綠
  cli 測試 58 pass / 0 fail(main 上是 49 ⇒ 多 9 條全過)
  邊界乾淨:沒動 cypher/kbdb/mcp;resource-resolver.ts 減 461 行變薄殼

兩條新測試比我寫的收工標準更嚴:
  two-paths-agree 斷言「一邊停手、一邊照做 = 最危險的分歧」與「停手的理由也要一樣」
  single-implementation 帶 assert.ok(files.length > 0) 防「檢查 0 個卻通過」
  ——後者正是總管當晚剛撞過的假綠形狀

殘項(agent 誠實標的,不擋併):
  · youlin 上走安裝器那條路的實測沒跑成——安裝器在 arcrun-rag,不在它的工作副本裡,
    且沙箱無 CF 憑證。它依紅線「不確定時寧可中止」沒有猜著跑。
  · 安裝器那一行 import 尚未接上(規格在 shared/resource-rule/README.md §4)
  · rules/07 想補「自舉例外」正例,該檔受保護,內容留在 README §2 待總管代補
2026-08-12 15:44:50 +00:00
uncle6me-web bb548b6fdf refactor(shared): 「該用哪些資源」搬出 CLI——一份實作,acr 與安裝器吃同一條規則
leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

「這個實例該用哪些資源」換到安裝器就要重寫一次 ⇒ 依 rules/07-thin-shell.md 的判準
它是**能力**,而它原本住在 cli/src/lib/resource-resolver.ts ⇒ 那本身就是違規。
後果已經真的發生:acr 那條有 Arcrun#97 的修法、安裝器那條沒有,於是安裝器照名字
找、找不到就建一顆空的綁上去 ⇒「我按了更新,工作流和登入全不見了」。

規則搬到 shared/resource-rule/(零依賴 ESM,Node 與 Workers runtime 都直接跑):

  · rule.mjs           規則本體+把 CF 回應讀成事實的 normalizeLive*
  · cf-resource-api.mjs ResourceApi 的 CF REST 實作——**眼睛也共用**:
                        兩條路各自解讀 CF 回應,只要一邊看不到既有綁定就會去新建,
                        #97 不需要規則寫錯就能重演
  · installer-entry.mjs 安裝器唯一該碰的入口 resolveInstanceResources()

不是做成 cypher 端點的理由(自舉):這條規則要在「決定怎麼裝」的當下就用得到,
而那時 cypher 可能還不存在(安裝器的工作正是把它生出來);且輸入是使用者自己帳號的
綁定狀態,不該送去平台換答案。它是純函式,用不著變成服務。

只有一份,機械看守:
  · 安裝器直接 import repo archive 裡的原稿,**不需要副本**
  · acr 因為 npm pack 打不進套件目錄外的檔案,帶一份逐位元組鏡射
    (scripts/sync-resource-rule.mjs 產生;build/test 先跑 --check,差一位元組就紅)
    ——同 cli/harness/ 產生物+世代閘的既有慣例
  · cli/tests/single-implementation.test.ts 掃全 repo:7 支規則函式的實作只有一處

CLI 淨 -496 行(邏輯是搬走,不是複製)。cf-api.ts 的 CfAccountClient 保留公開介面,
ResourceApi 那七個方法全部委派共用 client。

驗證:cli 58/58 綠(含新增的兩條路一致性 fixture + 三種情境),tsc --noEmit 乾淨。
2026-08-12 23:37:01 +08:00
29 changed files with 3028 additions and 685 deletions
+32
View File
@@ -99,6 +99,38 @@ CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」
--- ---
## 3.6 自舉例外:能力該「只實作一次」,但不一定要是 HTTP API2026-08-12 立)
> 立這條的原因:`Arcrun#97`(更新把使用者的工作流與登入弄不見)的修法一開始寫在
> `cli/src/lib/resource-resolver.ts` ——**能力住在介面層,違反 §0**。
> 後果不是理論:**安裝器(arcrun-rag)拿不到它,於是同一個 bug 只修了一半**,
> 走 `acr` 的人有保護、走 `install.arcrun.dev` 的人沒有——**而所有真實用戶走後者**。
> leo 2026-08-12:「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
修法(PR #111)把它搬到 **`shared/resource-rule/`:一份零依賴 ESM**
`acr` 與安裝器共用。**它刻意不是 cypher 的 API 端點**,三個理由:
| 為什麼不放 API | 說明 |
|---|---|
| **自舉** | 這條規則要在「決定怎麼裝」的當下用得到,而安裝器的工作正是把 cypher 生出來。放進 cypher = 要先有雞才能有蛋。 |
| **輸入是使用者自己的帳號狀態** | 判斷依據是使用者 CF 帳號上的綁定。送去平台託管的 worker 換答案 ⇒ ①「能不能安裝」綁在平台是否活著 ②使用者的帳號拓撲交給第三方。 |
| **它根本不需要是服務** | 這是**純函式**,唯一的 IO 由呼叫端注入。**§0 要求「能力只實作一次」,不是「能力一定要是 HTTP」。** |
🔴 **所以本檔 §0 的正確讀法是**:能力**只准有一份**,且**不准住在任何單一介面裡**。
「放 API」是達成它的**常見手段**,不是唯一手段。
**判準仍然是那句口訣**:「這段邏輯換一個介面要不要重寫?」要 → 它是能力。
📌 **給下一個人**:看到 `shared/` 底下的純函式**不要「修正」成 API 端點**——
先讀 `shared/resource-rule/README.md §2`,那裡記著評估過並否決的其他形態
(共用 npm 套件=自舉問題換位置;做成零件=要用 TinyGo 重寫一次,那才是第二份實作)。
📌 **打包例外**`acr` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒
`cli/` 下必須有一份**逐位元組副本**。那不是第二份實作——
`scripts/sync-resource-rule.mjs --check` 一有漂移就 exit 1,且 `build``test` 都會先跑它
(同 `cli/harness/` 的既有慣例)。**手改副本 = build 紅 publish 擋下。**
---
## 4. 統一帳號來源(薄殼共用同一身份) ## 4. 統一帳號來源(薄殼共用同一身份)
所有薄殼讀**同一份**身份設定: 所有薄殼讀**同一份**身份設定:
+18 -14
View File
@@ -1,7 +1,12 @@
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames; var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() { var __esm = (fn, res, err2) => function __init() {
if (err2) throw err2[0];
try {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
} catch (e) {
throw err2 = [e], e;
}
}; };
var __export = (target, all) => { var __export = (target, all) => {
for (var name in all) for (var name in all)
@@ -102,7 +107,7 @@ function applyBaseRuntimeOptions(runtime, options) {
function applyModuleEvalRuntimeOptions(runtime, options) { function applyModuleEvalRuntimeOptions(runtime, options) {
options.moduleLoader && runtime.setModuleLoader(options.moduleLoader), options.shouldInterrupt && runtime.setInterruptHandler(options.shouldInterrupt), options.memoryLimitBytes !== void 0 && runtime.setMemoryLimit(options.memoryLimitBytes), options.maxStackSizeBytes !== void 0 && runtime.setMaxStackSize(options.maxStackSizeBytes); options.moduleLoader && runtime.setModuleLoader(options.moduleLoader), options.shouldInterrupt && runtime.setInterruptHandler(options.shouldInterrupt), options.memoryLimitBytes !== void 0 && runtime.setMemoryLimit(options.memoryLimitBytes), options.maxStackSizeBytes !== void 0 && runtime.setMaxStackSize(options.maxStackSizeBytes);
} }
var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, UnstableSymbol, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule; var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule;
var init_chunk_JTKJZQYV = __esm({ var init_chunk_JTKJZQYV = __esm({
"registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() { "registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() {
init_dist(); init_dist();
@@ -190,7 +195,7 @@ var init_chunk_JTKJZQYV = __esm({
return this.dispose(); return this.dispose();
} }
}; };
SymbolDispose = Symbol.dispose ?? Symbol.for("Symbol.dispose"); SymbolDispose = Symbol.dispose ?? /* @__PURE__ */ Symbol.for("Symbol.dispose");
prototypeAsAny = UsingDisposable.prototype; prototypeAsAny = UsingDisposable.prototype;
prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() { prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() {
return this.dispose(); return this.dispose();
@@ -409,7 +414,6 @@ Lifetime used`) : new QuickJSUseAfterFree("Lifetime not alive");
return this.module._free(ptr), str; return this.module._free(ptr), str;
} }
}; };
UnstableSymbol = Symbol("Unstable");
DefaultIntrinsics = Object.freeze({ BaseObjects: true, Date: true, Eval: true, StringNormalize: true, RegExp: true, JSON: true, Proxy: true, MapSet: true, TypedArrays: true, Promise: true }); DefaultIntrinsics = Object.freeze({ BaseObjects: true, Date: true, Eval: true, StringNormalize: true, RegExp: true, JSON: true, Proxy: true, MapSet: true, TypedArrays: true, Promise: true });
QuickJSIterator = class extends UsingDisposable { QuickJSIterator = class extends UsingDisposable {
constructor(handle, context) { constructor(handle, context) {
@@ -773,7 +777,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
} }
return result.value; return result.value;
} }
[Symbol.for("nodejs.util.inspect.custom")]() { [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
return this.alive ? `${this.constructor.name} { ctx: ${this.ctx.value} rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`; return this.alive ? `${this.constructor.name} { ctx: ${this.ctx.value} rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
} }
getFunction(fn_id) { getFunction(fn_id) {
@@ -909,7 +913,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
debugLog(...msg) { debugLog(...msg) {
this._debugMode && console.log("quickjs-emscripten:", ...msg); this._debugMode && console.log("quickjs-emscripten:", ...msg);
} }
[Symbol.for("nodejs.util.inspect.custom")]() { [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
return this.alive ? `${this.constructor.name} { rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`; return this.alive ? `${this.constructor.name} { rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
} }
getSystemContext() { getSystemContext() {
@@ -1339,10 +1343,10 @@ async function QuickJSRaw(moduleArg = {}) {
x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m]; x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m];
} }
b = a(...g); b = a(...g);
return b = function(k) { return b = (function(k) {
0 !== h && sa(h); 0 !== h && sa(h);
return "string" === d ? R(k) : "boolean" === d ? !!k : k; return "string" === d ? R(k) : "boolean" === d ? !!k : k;
}(b); })(b);
}; };
c.wasmMemory ? r = c.wasmMemory : r = new WebAssembly.Memory({ initial: (c.INITIAL_MEMORY || 16777216) / 65536, maximum: 32768 }); c.wasmMemory ? r = c.wasmMemory : r = new WebAssembly.Memory({ initial: (c.INITIAL_MEMORY || 16777216) / 65536, maximum: 32768 });
K(); K();
@@ -1464,7 +1468,7 @@ async function QuickJSRaw(moduleArg = {}) {
}, t: function(a, d) { }, t: function(a, d) {
c.callbacks.freeHostRef(void 0, a, d); c.callbacks.freeHostRef(void 0, a, d);
} }, Z; } }, Z;
Z = await async function() { Z = await (async function() {
function a(b) { function a(b) {
b = Z = b.exports; b = Z = b.exports;
c._malloc = b.v; c._malloc = b.v;
@@ -1551,7 +1555,7 @@ async function QuickJSRaw(moduleArg = {}) {
}); });
M ??= c.locateFile ? c.locateFile ? c.locateFile("emscripten-module.wasm", u) : u + "emscripten-module.wasm" : new URL("emscripten-module.wasm", import.meta.url).href; M ??= c.locateFile ? c.locateFile ? c.locateFile("emscripten-module.wasm", u) : u + "emscripten-module.wasm" : new URL("emscripten-module.wasm", import.meta.url).href;
return a((await ea(d)).instance); return a((await ea(d)).instance);
}(); })();
(function() { (function() {
function a() { function a() {
c.calledRun = true; c.calledRun = true;
@@ -3034,7 +3038,7 @@ var Hono = class _Hono {
var emptyParam = []; var emptyParam = [];
function match(method, path) { function match(method, path) {
const matchers = this.buildAllMatchers(); const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => { const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2]; const staticMatch = matcher[2][path2];
if (staticMatch) { if (staticMatch) {
@@ -3046,7 +3050,7 @@ function match(method, path) {
} }
const index = match3.indexOf("", 1); const index = match3.indexOf("", 1);
return [matcher[1][index], match3]; return [matcher[1][index], match3];
}; });
this.match = match2; this.match = match2;
return match2(method, path); return match2(method, path);
} }
@@ -4008,7 +4012,7 @@ app.post("/", async (c) => {
); );
} }
}); });
var code_default = app; var index_default = app;
export { export {
code_default as default index_default as default
}; };
@@ -1,7 +1,12 @@
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames; var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() { var __esm = (fn, res, err) => function __init() {
if (err) throw err[0];
try {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
} catch (e) {
throw err = [e], e;
}
}; };
var __export = (target, all) => { var __export = (target, all) => {
for (var name in all) for (var name in all)
@@ -1501,7 +1506,7 @@ var init_hono_base = __esm({
// cypher-executor/node_modules/.pnpm/hono@4.12.10/node_modules/hono/dist/router/reg-exp-router/matcher.js // cypher-executor/node_modules/.pnpm/hono@4.12.10/node_modules/hono/dist/router/reg-exp-router/matcher.js
function match(method, path) { function match(method, path) {
const matchers = this.buildAllMatchers(); const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => { const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2]; const staticMatch = matcher[2][path2];
if (staticMatch) { if (staticMatch) {
@@ -1513,7 +1518,7 @@ function match(method, path) {
} }
const index = match3.indexOf("", 1); const index = match3.indexOf("", 1);
return [matcher[1][index], match3]; return [matcher[1][index], match3];
}; });
this.match = match2; this.match = match2;
return match2(method, path); return match2(method, path);
} }
@@ -7433,7 +7438,7 @@ var init_lib = __esm({
...processCreateParams(params) ...processCreateParams(params)
}); });
}; };
BRAND = Symbol("zod_brand"); BRAND = /* @__PURE__ */ Symbol("zod_brand");
ZodBranded = class extends ZodType { ZodBranded = class extends ZodType {
_parse(input) { _parse(input) {
const { ctx } = this._processInputParams(input); const { ctx } = this._processInputParams(input);
@@ -7607,14 +7612,14 @@ var init_lib = __esm({
onumber = () => numberType().optional(); onumber = () => numberType().optional();
oboolean = () => booleanType().optional(); oboolean = () => booleanType().optional();
coerce = { coerce = {
string: (arg) => ZodString.create({ ...arg, coerce: true }), string: ((arg) => ZodString.create({ ...arg, coerce: true })),
number: (arg) => ZodNumber.create({ ...arg, coerce: true }), number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
boolean: (arg) => ZodBoolean.create({ boolean: ((arg) => ZodBoolean.create({
...arg, ...arg,
coerce: true coerce: true
}), })),
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }), bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
date: (arg) => ZodDate.create({ ...arg, coerce: true }) date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
}; };
NEVER = INVALID; NEVER = INVALID;
z = /* @__PURE__ */ Object.freeze({ z = /* @__PURE__ */ Object.freeze({
@@ -7835,6 +7840,7 @@ var init_recipe_loader = __esm({
super(message); super(message);
this.recipe = recipe; this.recipe = recipe;
} }
recipe;
}; };
} }
}); });
@@ -8841,7 +8847,7 @@ function recordRecipeStats(env, recipeKeys, ok, at, ctx) {
) )
).then(() => void 0); ).then(() => void 0);
if (ctx?.waitUntil) ctx.waitUntil(promise); if (ctx?.waitUntil) ctx.waitUntil(promise);
else ; else void promise;
} }
function generateToken() { function generateToken() {
const tokenBytes = crypto.getRandomValues(new Uint8Array(16)); const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
@@ -8883,7 +8889,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
result.trace result.trace
); );
if (ctx?.waitUntil) ctx.waitUntil(statsPromise); if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else ; else void statsPromise;
} }
return { success: true, data: result.data, duration_ms }; return { success: true, data: result.data, duration_ms };
} catch (err) { } catch (err) {
@@ -8907,7 +8913,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
err.trace err.trace
); );
if (ctx?.waitUntil) ctx.waitUntil(statsPromise); if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else ; else void statsPromise;
} }
if (err instanceof ExecutionError) { if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map((s) => ({ const traceFormatted = err.trace.map((s) => ({
@@ -9374,6 +9380,7 @@ function extractTarget(input) {
return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2); return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2);
} }
async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) { async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) {
void nodes;
try { try {
const { base, headers } = kbdbBase(env); const { base, headers } = kbdbBase(env);
await fetch(`${base}/execution-log/record`, { await fetch(`${base}/execution-log/record`, {
@@ -15654,10 +15661,10 @@ app.route("/", consoleAuthRouter);
app.route("/", consoleDashboardRouter); app.route("/", consoleDashboardRouter);
app.route("/", portalRouter); app.route("/", portalRouter);
app.route("/", portalDataRouter); app.route("/", portalDataRouter);
var src_default = { var index_default = {
fetch: app.fetch, fetch: app.fetch,
scheduled: handleScheduled scheduled: handleScheduled
}; };
export { export {
src_default as default index_default as default
}; };
@@ -1427,7 +1427,7 @@ var Hono = class _Hono {
var emptyParam = []; var emptyParam = [];
function match(method, path) { function match(method, path) {
const matchers = this.buildAllMatchers(); const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => { const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2]; const staticMatch = matcher[2][path2];
if (staticMatch) { if (staticMatch) {
@@ -1439,7 +1439,7 @@ function match(method, path) {
} }
const index = match3.indexOf("", 1); const index = match3.indexOf("", 1);
return [matcher[1][index], match3]; return [matcher[1][index], match3];
}; });
this.match = match2; this.match = match2;
return match2(method, path); return match2(method, path);
} }
@@ -2522,7 +2522,7 @@ app.post("/", async (c) => {
); );
} }
}); });
var src_default = app; var index_default = app;
async function runWasm(input) { async function runWasm(input) {
const hostFunctions = { const hostFunctions = {
http_request: async (url, method, headersJson, body) => { http_request: async (url, method, headersJson, body) => {
@@ -2568,5 +2568,5 @@ async function runWasm(input) {
return JSON.parse(stdout); return JSON.parse(stdout);
} }
export { export {
src_default as default index_default as default
}; };
+4 -4
View File
@@ -1444,7 +1444,7 @@ var Hono = class _Hono {
var emptyParam = []; var emptyParam = [];
function match(method, path) { function match(method, path) {
const matchers = this.buildAllMatchers(); const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => { const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2]; const staticMatch = matcher[2][path2];
if (staticMatch) { if (staticMatch) {
@@ -1456,7 +1456,7 @@ function match(method, path) {
} }
const index = match3.indexOf("", 1); const index = match3.indexOf("", 1);
return [matcher[1][index], match3]; return [matcher[1][index], match3];
}; });
this.match = match2; this.match = match2;
return match2(method, path); return match2(method, path);
} }
@@ -4157,7 +4157,7 @@ app.route("/recipe-stats", recipeStatRoutes);
app.route("/execution-log", executionLogRoutes); app.route("/execution-log", executionLogRoutes);
app.route("/embed", embedRoutes); app.route("/embed", embedRoutes);
app.route("/map", mapRoutes); app.route("/map", mapRoutes);
var src_default = app; var index_default = app;
export { export {
src_default as default index_default as default
}; };
+36 -33
View File
@@ -5,7 +5,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf; var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty; var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() { var __commonJS = (cb, mod) => function __require() {
try {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
} catch (e) {
throw mod = 0, e;
}
}; };
var __export = (target, all) => { var __export = (target, all) => {
for (var name in all) for (var name in all)
@@ -8223,7 +8227,7 @@ var Hono = class _Hono {
var emptyParam = []; var emptyParam = [];
function match(method, path) { function match(method, path) {
const matchers = this.buildAllMatchers(); const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => { const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2]; const staticMatch = matcher[2][path2];
if (staticMatch) { if (staticMatch) {
@@ -8235,7 +8239,7 @@ function match(method, path) {
} }
const index = match3.indexOf("", 1); const index = match3.indexOf("", 1);
return [matcher[1][index], match3]; return [matcher[1][index], match3];
}; });
this.match = match2; this.match = match2;
return match2(method, path); return match2(method, path);
} }
@@ -12959,7 +12963,7 @@ ZodNaN.create = (params) => {
...processCreateParams(params) ...processCreateParams(params)
}); });
}; };
var BRAND = Symbol("zod_brand"); var BRAND = /* @__PURE__ */ Symbol("zod_brand");
var ZodBranded = class extends ZodType { var ZodBranded = class extends ZodType {
_parse(input) { _parse(input) {
const { ctx } = this._processInputParams(input); const { ctx } = this._processInputParams(input);
@@ -13161,14 +13165,14 @@ var ostring = () => stringType().optional();
var onumber = () => numberType().optional(); var onumber = () => numberType().optional();
var oboolean = () => booleanType().optional(); var oboolean = () => booleanType().optional();
var coerce = { var coerce = {
string: (arg) => ZodString.create({ ...arg, coerce: true }), string: ((arg) => ZodString.create({ ...arg, coerce: true })),
number: (arg) => ZodNumber.create({ ...arg, coerce: true }), number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
boolean: (arg) => ZodBoolean.create({ boolean: ((arg) => ZodBoolean.create({
...arg, ...arg,
coerce: true coerce: true
}), })),
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }), bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
date: (arg) => ZodDate.create({ ...arg, coerce: true }) date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
}; };
var NEVER = INVALID; var NEVER = INVALID;
@@ -13219,7 +13223,6 @@ function $constructor(name, initializer3, params) {
Object.defineProperty(_, "name", { value: name }); Object.defineProperty(_, "name", { value: name });
return _; return _;
} }
var $brand = Symbol("zod_brand");
var $ZodAsyncError = class extends Error { var $ZodAsyncError = class extends Error {
constructor() { constructor() {
super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`); super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`);
@@ -15723,8 +15726,6 @@ function en_default2() {
} }
// mcp/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v4/core/registries.js // mcp/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v4/core/registries.js
var $output = Symbol("ZodOutput");
var $input = Symbol("ZodInput");
var $ZodRegistry = class { var $ZodRegistry = class {
constructor() { constructor() {
this._map = /* @__PURE__ */ new Map(); this._map = /* @__PURE__ */ new Map();
@@ -17002,10 +17003,10 @@ var ZodMiniType = /* @__PURE__ */ $constructor("ZodMiniType", (inst, def) => {
}; };
inst.clone = (_def, params) => clone(inst, _def, params); inst.clone = (_def, params) => clone(inst, _def, params);
inst.brand = () => inst; inst.brand = () => inst;
inst.register = (reg, meta) => { inst.register = ((reg, meta) => {
reg.add(inst, meta); reg.add(inst, meta);
return inst; return inst;
}; });
}); });
var ZodMiniObject = /* @__PURE__ */ $constructor("ZodMiniObject", (inst, def) => { var ZodMiniObject = /* @__PURE__ */ $constructor("ZodMiniObject", (inst, def) => {
$ZodObject.init(inst, def); $ZodObject.init(inst, def);
@@ -17268,10 +17269,10 @@ var ZodType2 = /* @__PURE__ */ $constructor("ZodType", (inst, def) => {
}; };
inst.clone = (def2, params) => clone(inst, def2, params); inst.clone = (def2, params) => clone(inst, def2, params);
inst.brand = () => inst; inst.brand = () => inst;
inst.register = (reg, meta) => { inst.register = ((reg, meta) => {
reg.add(inst, meta); reg.add(inst, meta);
return inst; return inst;
}; });
inst.parse = (data, params) => parse2(inst, data, params, { callee: inst.parse }); inst.parse = (data, params) => parse2(inst, data, params, { callee: inst.parse });
inst.safeParse = (data, params) => safeParse3(inst, data, params); inst.safeParse = (data, params) => safeParse3(inst, data, params);
inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync }); inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync });
@@ -19243,11 +19244,13 @@ function assertCompleteRequestPrompt(request) {
if (request.params.ref.type !== "ref/prompt") { if (request.params.ref.type !== "ref/prompt") {
throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`); throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`);
} }
void request;
} }
function assertCompleteRequestResourceTemplate(request) { function assertCompleteRequestResourceTemplate(request) {
if (request.params.ref.type !== "ref/resource") { if (request.params.ref.type !== "ref/resource") {
throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`); throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`);
} }
void request;
} }
var CompleteResultSchema = ResultSchema.extend({ var CompleteResultSchema = ResultSchema.extend({
completion: looseObject({ completion: looseObject({
@@ -19400,7 +19403,7 @@ function isTerminal(status) {
} }
// mcp/node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/Options.js // mcp/node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/Options.js
var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use"); var ignoreOverride = /* @__PURE__ */ Symbol("Let zodToJsonSchema decide on which parser to use");
var defaultOptions = { var defaultOptions = {
name: void 0, name: void 0,
$refStrategy: "root", $refStrategy: "root",
@@ -22376,7 +22379,7 @@ var Server = class extends Protocol {
}; };
// mcp/node_modules/.pnpm/@modelcontextprotocol+sdk@1.29.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/completable.js // mcp/node_modules/.pnpm/@modelcontextprotocol+sdk@1.29.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/completable.js
var COMPLETABLE_SYMBOL = Symbol.for("mcp.completable"); var COMPLETABLE_SYMBOL = /* @__PURE__ */ Symbol.for("mcp.completable");
function isCompletable(schema4) { function isCompletable(schema4) {
return !!schema4 && typeof schema4 === "object" && COMPLETABLE_SYMBOL in schema4; return !!schema4 && typeof schema4 === "object" && COMPLETABLE_SYMBOL in schema4;
} }
@@ -24691,13 +24694,13 @@ function registerAllIntrospectionTools(server, env) {
} }
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/nodes/identity.js // mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/nodes/identity.js
var ALIAS = Symbol.for("yaml.alias"); var ALIAS = /* @__PURE__ */ Symbol.for("yaml.alias");
var DOC = Symbol.for("yaml.document"); var DOC = /* @__PURE__ */ Symbol.for("yaml.document");
var MAP = Symbol.for("yaml.map"); var MAP = /* @__PURE__ */ Symbol.for("yaml.map");
var PAIR = Symbol.for("yaml.pair"); var PAIR = /* @__PURE__ */ Symbol.for("yaml.pair");
var SCALAR = Symbol.for("yaml.scalar"); var SCALAR = /* @__PURE__ */ Symbol.for("yaml.scalar");
var SEQ = Symbol.for("yaml.seq"); var SEQ = /* @__PURE__ */ Symbol.for("yaml.seq");
var NODE_TYPE = Symbol.for("yaml.node.type"); var NODE_TYPE = /* @__PURE__ */ Symbol.for("yaml.node.type");
var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS; var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC; var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP; var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
@@ -24727,9 +24730,9 @@ function isNode(node) {
var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor; var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor;
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/visit.js // mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/visit.js
var BREAK = Symbol("break visit"); var BREAK = /* @__PURE__ */ Symbol("break visit");
var SKIP = Symbol("skip children"); var SKIP = /* @__PURE__ */ Symbol("skip children");
var REMOVE = Symbol("remove node"); var REMOVE = /* @__PURE__ */ Symbol("remove node");
function visit(node, visitor) { function visit(node, visitor) {
const visitor_ = initVisitor(visitor); const visitor_ = initVisitor(visitor);
if (isDocument(node)) { if (isDocument(node)) {
@@ -29322,9 +29325,9 @@ ${end.comment}` : end.comment;
}; };
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/parse/cst-visit.js // mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/parse/cst-visit.js
var BREAK2 = Symbol("break visit"); var BREAK2 = /* @__PURE__ */ Symbol("break visit");
var SKIP2 = Symbol("skip children"); var SKIP2 = /* @__PURE__ */ Symbol("skip children");
var REMOVE2 = Symbol("remove item"); var REMOVE2 = /* @__PURE__ */ Symbol("remove item");
function visit2(cst, visitor) { function visit2(cst, visitor) {
if ("type" in cst && cst.type === "document") if ("type" in cst && cst.type === "document")
cst = { start: cst.start, value: cst.value }; cst = { start: cst.start, value: cst.value };
@@ -33596,7 +33599,7 @@ app.post("/", partnerAuthMiddleware, async (c) => {
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal")); const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity); return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
}); });
var src_default = _app; var index_default = _app;
export { export {
src_default as default index_default as default
}; };
+12 -12
View File
@@ -1,8 +1,8 @@
{ {
"schema": 1, "schema": 1,
"built_for": "arcrun-tier2-worker-artifacts", "built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T16:16:33.433Z", "generated_at": "2026-08-12T16:25:19.210Z",
"repo_head": "b223a698844be289c1b01f99eb34a8e2ac85bb74", "repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
"repo_dirty": false, "repo_dirty": false,
"workers": [ "workers": [
{ {
@@ -11,8 +11,8 @@
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74", "source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
"main_module": "worker.mjs", "main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 588397, "js_bytes": 588587,
"content_sha256": "e0026a23792f8b6b02e35c91081604b9a761c608a2cae0e6ee8ab0f34a484501", "content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
"modules": [], "modules": [],
"compat_date": "2025-02-19", "compat_date": "2025-02-19",
"compat_flags": [ "compat_flags": [
@@ -61,8 +61,8 @@
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b", "source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
"main_module": "worker.mjs", "main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs", "main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 149791, "js_bytes": 149797,
"content_sha256": "9c6d41895d78cbf3048fb539690591c86071b7d6ebf7fb0b8b51607c6b1f8ee9", "content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
"modules": [], "modules": [],
"compat_date": "2025-02-19", "compat_date": "2025-02-19",
"compat_flags": [ "compat_flags": [
@@ -90,8 +90,8 @@
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3", "source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
"main_module": "worker.mjs", "main_module": "worker.mjs",
"main_file": "arcrun-http-request/worker.mjs", "main_file": "arcrun-http-request/worker.mjs",
"js_bytes": 80073, "js_bytes": 80079,
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15", "content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
"modules": [ "modules": [
{ {
"name": "component.wasm", "name": "component.wasm",
@@ -122,8 +122,8 @@
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe", "source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"main_module": "worker.mjs", "main_module": "worker.mjs",
"main_file": "arcrun-code/worker.mjs", "main_file": "arcrun-code/worker.mjs",
"js_bytes": 153671, "js_bytes": 153758,
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5", "content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
"modules": [ "modules": [
{ {
"name": "quickjs.wasm", "name": "quickjs.wasm",
@@ -151,8 +151,8 @@
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9", "source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
"main_module": "worker.mjs", "main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs", "main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1179229, "js_bytes": 1179487,
"content_sha256": "3ebc0d441bc04ae56a1205507da701f93bed9efa9b1e53c1777c04cbef5bdb67", "content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
"modules": [], "modules": [],
"compat_date": "2024-11-27", "compat_date": "2024-11-27",
"compat_flags": [ "compat_flags": [
+3 -2
View File
@@ -8,11 +8,12 @@
"main": "./dist/index.js", "main": "./dist/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "npm run build:harness && npm run check:harness && tsc", "build": "npm run build:harness && npm run check:harness && npm run check:rule && tsc",
"build:harness": "node scripts/build-harness-skill.mjs", "build:harness": "node scripts/build-harness-skill.mjs",
"check:harness": "node scripts/check-harness-generation.mjs", "check:harness": "node scripts/check-harness-generation.mjs",
"check:rule": "node ../scripts/sync-resource-rule.mjs --check",
"dev": "tsc --watch", "dev": "tsc --watch",
"test": "node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"", "test": "npm run check:rule && node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
"prepublishOnly": "npm run build && chmod +x dist/index.js" "prepublishOnly": "npm run build && chmod +x dist/index.js"
}, },
"dependencies": { "dependencies": {
+52 -159
View File
@@ -3,7 +3,8 @@
* 使 CF REST API KV namespace Wrangler CLI * 使 CF REST API KV namespace Wrangler CLI
*/ */
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js'; import { createCloudflareResourceApi } from './resource-rule/cf-resource-api.mjs';
import type { ResourceApi, ScriptBindings } from './resource-resolver.js';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
@@ -86,189 +87,81 @@ export class CfKvClient {
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2 * SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
*/ */
export class CfAccountClient implements ResourceApi { export class CfAccountClient implements ResourceApi {
private accountBase: string; /**
private headers: Record<string, string>; * `ResourceApi` **** client
* `shared/resource-rule/cf-resource-api.mjs`
*
* 🔴 **西**
* CF client 404 per_page
*
* Arcrun#97
*/
private readonly rule: ReturnType<typeof createCloudflareResourceApi>;
constructor(accountId: string, apiToken: string) { constructor(accountId: string, apiToken: string) {
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`; this.rule = createCloudflareResourceApi({ accountId, apiToken });
this.headers = {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
} }
private async cf<T>(path: string, init?: RequestInit): Promise<T> { private async cf<T>(path: string, init?: RequestInit): Promise<T> {
const { ok, status, result, error } = await this.cfRaw<T>(path, init); const { ok, status, result, error } = await this.rule.cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`); if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result as T; 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 ?? {}) },
});
const data = await res.json().catch(() => null) as
| { success: boolean; result: T; errors?: Array<{ message: string }> }
| null;
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
}
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/ /** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
async verifyAccess(): Promise<void> { async verifyAccess(): Promise<void> {
// GET /accounts/{id} 能通 = token 有此 account 的基本讀權限 // GET /accounts/{id} 能通 = token 有此 account 的基本讀權限
await this.cf<{ id: string; name: string }>(''); await this.cf<{ id: string; name: string }>('');
} }
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
async listKvNamespaces(): Promise<Map<string, string>> {
const result = await this.cf<Array<{ id: string; title: string }>>(
'/storage/kv/namespaces?per_page=100',
);
const map = new Map<string, string>();
for (const ns of result) map.set(ns.title, ns.id);
return map;
}
/**
* 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 }) },
);
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: [], vars: {} };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
const raw = res.result?.bindings ?? [];
// #106:同一份回應裡也帶著 plain_text var(實測 CF `/settings` 會回 `text` 值)。
// 舊版只挑資源類、把 var 整批丟掉 → 重部署等於把它們洗掉。
return { deployed: true, bindings: normalizeBindings(raw), vars: normalizeVars(raw) };
}
/** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/ /** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> { async getWorkersSubdomain(): Promise<string> {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain'); const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
return result.subdomain; return result.subdomain;
} }
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified). // ── 以下七支=`ResourceApi`,一律委派共用規則,**這個檔案不得自己實作** ────────────
async listD1Databases(): Promise<Map<string, string>> { // `shared/resource-rule/cf-resource-api.mjs`;委派而非複製的理由見本 class 開頭)
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
const map = new Map<string, string>(); /** 讀一顆已部署 worker 現在綁著哪些資源——使用者那側的事實(Arcrun#97 的唯一真相源)。 */
for (const db of result) map.set(db.name, db.uuid); getScriptBindings(script: string): Promise<ScriptBindings> {
return map; return this.rule.getScriptBindings(script);
}
/** 帳號上現有的 KV namespacetitle → id)。判斷「綁著的那顆還在不在」用。 */
listKvNamespaces(): Promise<Map<string, string>> {
return this.rule.listKvNamespaces();
}
/** 帳號上現有的 D1name → uuid)。 */
listD1Databases(): Promise<Map<string, string>> {
return this.rule.listD1Databases();
}
/** 帳號上現有的 Vectorize index 名單。 */
listVectorizeIndexes(): Promise<string[]> {
return this.rule.listVectorizeIndexes();
}
/**
* KV namespace
*
* 🔴 Arcrun#97**** ensure
* 使
* planResources worker
* binding create
*/
createKvNamespace(title: string): Promise<string> {
return this.rule.createKvNamespace(title);
} }
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */ /** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */
async createD1Database(name: string): Promise<string> { createD1Database(name: string): Promise<string> {
const result = await this.cf<{ uuid: string; name: string }>( return this.rule.createD1Database(name);
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
} }
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */ /** 新建 KBDB embed 用的 Vectorize index。沒有 ensure 版本,理由同上(Arcrun#97)。 */
async listVectorizeIndexes(): Promise<string[]> { createVectorizeIndex(name: string): Promise<string> {
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes'); return this.rule.createVectorizeIndex(name);
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;
/** `plain_text` 綁定的值(#106secret_text 不會回值,本來就讀不到,也不該讀)。 */
text?: string;
}
/**
* worker `plain_text` var#106
*
* `plain_text`**`secret_text` **CF CLI
* wrangler deploy secret
*/
function normalizeVars(raw: RawWorkerBinding[]): Record<string, string> {
const out: Record<string, string> = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}
/** 把 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;
}
+35 -424
View File
@@ -1,431 +1,42 @@
/** /**
* resource-resolver.ts worker * resource-resolver.ts **** CLI import
* *
* 🔴 Arcrun#972026-08-12 leo * `shared/resource-rule/`repo
* ensure`acr update` **binding **`WEBHOOKS` Cloudflare * ****`./resource-rule/`
* ****** worker ** * `scripts/sync-resource-rule.mjs` `npm run build` / `npm test` `--check`
* `arcrun-rag-<instance>-kv-webhooks` * `arcrun` npm `npm pack`
* 9 KV1 D1使****
* worker 使西
* *
* KV **使** * CLIleo 2026-08-12
* **使** * CLI
* * `acr` Arcrun#97
* **** * 使
* import 稿****
* *
* * 🔴 `shared/resource-rule/rule.mjs`
* 1. ** worker ** 沿
* 2. **** binding
* 3. **** binding
* worker ****
*
* plan / apply
* `planResources()` ****沿
* `applyResourcePlan()` blocker
* ****
* early return#97
*/ */
/** R2/Queue/Hyperdrive export {
* ensure */ planResources,
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize'; applyResourcePlan,
parseWranglerRequirements,
normalizeLiveBindings,
normalizeLiveVars,
bindingKey,
ResourcePlanBlocked,
KIND_LABEL,
TABLE_KIND,
} from './resource-rule/rule.mjs';
/** 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。 */ export type {
export interface LiveBinding { ResourceKind,
kind: ResourceKind; LiveBinding,
binding: string; ScriptBindings,
value: string; ResourceApi,
} BindingRequirement,
PlannedAdopt,
export interface ScriptBindings { PlannedCreate,
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */ ResourcePlan,
deployed: boolean; ResolvedResource,
bindings: LiveBinding[]; WranglerRequirements,
/** RawWorkerBinding,
* worker `plain_text` var } from './resource-rule/rule.mjs';
*
* 🔴 Arcrun#106#97 沿KV/D1/Vectorize
* plain_text var repo toml
* `ARCRUN_BUNDLE_VERSION`
* Portal
* ****
*/
vars?: Record<string, string>;
}
/** 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[];
/**
* **** worker plain_text varscript /
*
* Arcrun#106 `bindings[]` var
* ** API**
* blockers
*/
liveVars: Map<string, Record<string, 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[]>();
const liveVars = new Map<string, Record<string, string>>();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) {
live.set(script, res.bindings);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} 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, liveVars };
}
/**
* 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 };
}
@@ -0,0 +1,202 @@
// @ts-check
/**
* cf-resource-api.mjs 規則的**眼睛與手** Cloudflare 帳號的那七個動作也只有一份
*
* `rule.mjs` 是純判斷IO 由呼叫端注入`ResourceApi`本檔就是那個注入物的正貨
* CF REST API 實作 `ResourceApi`零依賴只用 global `fetch`
* Node 18+ Cloudflare Workers runtime 都能直接跑
*
* 為什麼連這層也要共用
* 判斷一致還不夠**看到的東西**也要一致
* 已部署的 worker 綁著什麼是從 `GET /workers/scripts/{script}/settings` 讀來的
* 如果兩條路各自寫一份 client隨便一個差異打錯端點 404 當錯誤漏了 per_page
* 少認一種欄位名都會讓其中一條路看不到既有綁定而看不到既有綁定的下一步
* 依規則就是**新建**Arcrun#97 的災情不需要規則寫錯只要眼睛不一樣就會重演
*
* 這裡**故意只有 `ResourceApi` 那七個方法**verifyAccess / subdomain / KV 讀寫
* 這些跟該用哪些資源無關的帳號操作留在各自的呼叫端不往共用層堆
*
* 🔴 除了同目錄的 `./rule.mjs`這支不准 import 任何東西共用層的價值在於
* 整個目錄複製到哪個 runtime 都能直接跑多一個外部依賴就少一條路吃得到
*/
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
*/
/**
* @typedef {object} CfResourceApiOptions
* @property {string} accountId
* @property {string} apiToken
* @property {typeof globalThis.fetch} [fetch]
* 注入用離線測試餵假帳號或宿主要用自己的 fetch預設 global fetch
*/
/**
* 建一個打真實 Cloudflare `ResourceApi`
*
* @param {CfResourceApiOptions} options
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
*/
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
const doFetch = fetchImpl ?? globalThis.fetch;
if (typeof doFetch !== 'function') {
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
}
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
const headers = {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
/**
* HTTP status 交回呼叫端自己判斷要區分404 不存在其他錯誤時用
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error:
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
}
/**
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<any>}
*/
async function cf(path, init) {
const { ok, status, result, error } = await cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result;
}
return {
cfRaw,
/**
* 讀一顆已部署 worker 現在綁著哪些資源**使用者那側的事實**Arcrun#97 的唯一真相源
*
* - script 不存在404 `{ deployed: false }`這是還沒部署不是錯誤
* - 其他任何失敗 throw呼叫端必須把它當我不知道**不是**它沒有
* 把查不到當成不存在就是 #97 的根因
*
* @param {string} script
* @returns {Promise<ScriptBindings>}
*/
async getScriptBindings(script) {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await cfRaw(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
/** @type {RawWorkerBinding[]} */
const raw = res.result?.bindings ?? [];
return {
deployed: true,
bindings: normalizeLiveBindings(raw),
vars: normalizeLiveVars(raw),
};
},
/** @returns {Promise<Map<string, string>>} title → id */
async listKvNamespaces() {
/** @type {Array<{id: string, title: string}>} */
const result = await cf('/storage/kv/namespaces?per_page=100');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
},
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
},
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
},
/**
* 無條件新建一顆 KV namespace
*
* 🔴 Arcrun#97這裡**故意沒有**找不到同名就順手建一顆 ensure 版本
* 照名字找 找不到 新建 綁上去正是把使用者實例洗成空的那條路
* 安裝器取的名字跟 binding 名不一樣永遠對不上 每次更新都新建
* 要不要建一律先過 `planResources`
*
* @param {string} title
* @returns {Promise<string>}
*/
async createKvNamespace(title) {
const result = await cf('/storage/kv/namespaces', {
method: 'POST',
body: JSON.stringify({ title }),
});
return result.id;
},
/**
* 無條件新建 D1沒有 ensure 版本理由同 createKvNamespaceArcrun#97
* @param {string} name
* @returns {Promise<string>}
*/
async createD1Database(name) {
const result = await cf('/d1/database', {
method: 'POST',
body: JSON.stringify({ name }),
});
return result.uuid;
},
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 / cosine**
* 已存在409 / already exists視為成功並行或重跑不該炸
* 沒有 ensure 版本要不要建 planResources 判斷這裡只負責建Arcrun#97
*
* @param {string} name
* @returns {Promise<string>}
*/
async createVectorizeIndex(name) {
const res = await cfRaw('/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}`);
},
};
}
@@ -0,0 +1,100 @@
// @ts-check
/**
* installer-entry.mjs 安裝器那條路的**唯一入口**
*
* 安裝器arcrun-rag `installer/oauth-prototype/worker.js`不必也不准自己判斷
* 該建哪些資源它只要呼叫這一支拿回每個 binding 該用哪顆資源
*
* ```js
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
*
* const r = await resolveInstanceResources({
* accountId, apiToken,
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
* mode: isUpdate ? 'update' : 'init',
* });
* if (r.blocked) {
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
* return showAndStop(r.blockers);
* }
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
* ```
*
* 為什麼安裝器不需要副本安裝器本來就會下載本 repo archive 當部署來源
* `.claude/rules/05-deploy-convention.md`WASM 來源
* `shared/resource-rule/` 就在那份 archive 直接 import 即可
* **不必再編一次不必貼一份也就不會有第二種答案**
*/
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
/**
* @typedef {object} ResolveOptions
* @property {string} accountId
* @property {string} apiToken
* @property {string[]} wranglerTomls worker wrangler.toml **內容**不是路徑
* @property {'update' | 'init'} mode 這台照定義裝過了沒
* @property {typeof globalThis.fetch} [fetch] 注入用測試宿主自帶 fetch
*/
/**
* @typedef {object} ResolveResult
* @property {boolean} blocked true = 什麼都沒建什麼都不該部署
* @property {string[]} blockers blocked 時的原因原文要原樣轉給使用者
* @property {Record<string, string>} bindings `${kind}:${binding}` 資源 idindex
* @property {Record<string, 'adopted'|'created'>} origin 同上 key 這顆是沿用還是新建
* @property {Record<string, Record<string, string>>} liveVars script 現有 plain_text var#106
*/
/**
* 決定這台實例每個 binding 該用哪顆資源照規則沿用既有只在確定沒人綁過時才新建
*
* @param {ResolveOptions} options
* @returns {Promise<ResolveResult>}
*/
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
/** @type {import('./rule.mjs').BindingRequirement[]} */
const requirements = [];
for (const toml of wranglerTomls) {
const parsed = parseWranglerRequirements(toml);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
/** @param {string[]} blockers @returns {ResolveResult} */
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
if (requirements.length === 0) {
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
}
let plan;
try {
plan = await planResources(api, requirements, mode);
} catch (e) {
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
}
if (plan.blockers.length > 0) return stop(plan.blockers);
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
let resolved;
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
}
/** @type {Record<string, string>} */
const bindings = {};
/** @type {Record<string, 'adopted'|'created'>} */
const origin = {};
for (const [key, r] of resolved) {
bindings[key] = r.value;
origin[key] = r.origin;
}
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
}
+570
View File
@@ -0,0 +1,570 @@
// @ts-check
/**
* rule.mjs 這個實例該用哪些資源**唯一一份**規則
*
*
* 這份檔案為什麼在這裡`shared/`不在 `cli/`
*
* leo 2026-08-12根本就不應該在 CLI我要的是一個大家都可以用到的規則
*
* `.claude/rules/07-thin-shell.md` 的判準口訣
* 這段邏輯換一個介面要不要重寫要重寫 它是能力該在共用層
*
* 該沿用哪幾顆資源換到安裝器就得重寫一次 它是**能力**不是薄殼的事
* 而它原本住在 `cli/src/lib/resource-resolver.ts` 那本身就是違規
* 後果也真的發生了`acr` 那條有這條規則安裝器那條沒有於是安裝器照名字找
* 找不到就建新的空的 Arcrun#97我按了更新工作流和登入全不見了
*
* 為什麼不是 cypher-executor API 端點薄殼原則的標準答案
* **自舉**這條規則要在決定怎麼裝怎麼更新的當下就用得到而那個當下
* cypher 可能還不存在安裝器的工作正是把它生出來或正要被覆蓋
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**
* 把它送去一顆平台託管的 worker 換一個答案等於①讓能不能安裝綁在平台是否活著
* ②把使用者的帳號拓撲交給第三方兩件都不該為了形式上的漂亮而做
*
* 薄殼原則要求的是能力只實作一次不是能力一定要是 HTTP
* 這條規則是**純函式**唯一的 IO 由呼叫端注入 `ResourceApi`
* 所以它用不著變成服務一份零依賴的 ESM 就能讓每條路吃到同一份判斷
*
* 怎麼讓兩條路吃到同一份而不是各留一份
* 本檔是**唯一被人手維護的實作**零依賴不吃任何 node 內建Workers runtime 可直接跑
* · `acr``cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**
* `scripts/sync-resource-rule.mjs` 產生CLI 要能單獨 npm publish
* 套件目錄外的檔案打不進 tarball故必須有這一份
* `npm run build` / `npm test` 都會跑 `--check`內容一漂就紅
* `cli/harness/`產生物世代閘的既有慣例
* · 安裝器 / 任何 Worker安裝器本來就會下載本 repo archive部署來源
* `.claude/rules/05-deploy-convention.md`WASM 來源
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可**不需要再編一次也不留副本**
* 用法見同目錄 README.md
*
*
* 規則本身leo 的兩句話
*
* 如果你沒有裝就是新的如果你已經有原來叫什麼名字就繼續用下去
*
* 判準是**這顆 worker 現在綁著誰**不是有沒有叫這個名字的資源
* 1. **已部署的 worker 上綁著什麼那就是事實** 原封不動沿用不管那顆資源叫什麼名字
* 2. **只有確定沒有任何人綁過它才准新建**新版本新增的 binding或真的全新帳號
* 3. **只要有一點說不準就整趟停手**讀不到綁定綁著的資源不見了同一個 binding 指向兩顆
* 該更新的 worker 一顆都不在**什麼都不建什麼都不部署**把話說清楚讓人來判斷
*
* 為什麼拆成 plan / apply 兩段
* `planResources()` **完全不寫入**只回一份要沿用什麼要新建什麼有什麼不敢動的
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行
* 被擋下的時候一顆資源都不會被建出來**結構上的保證**
* 不是靠某個人記得在對的地方寫 early return#97 正是死在先動手後判斷
*
* 🔴 這份檔案沒有 import也不准有任何依賴都會讓某一條路吃不到它
*/
/**
* 這支負責的資源種類要加新種類R2/Queue/Hyperdrive就加在這裡
* 一律走同一道門不准任何呼叫端自己照名字 ensure繞過去
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
*/
/**
* 從已部署 worker 上讀回來的一條綁定`value`KV/D1 是資源 idVectorize index
* @typedef {object} LiveBinding
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
*/
/**
* @typedef {object} ScriptBindings
* @property {boolean} deployed
* false = 這顆 worker 在帳號上還不存在全新部署不是讀取失敗讀取失敗要 throw
* @property {LiveBinding[]} bindings
* @property {Record<string, string>} [vars]
* 這顆 worker 現在掛著的 `plain_text` var
*
* 🔴 Arcrun#106#97 只把資源類綁定當成事實沿用KV/D1/Vectorize
* plain_text var 整批沒人管 重部署把它們洗成 repo toml 的預設值
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`安裝器注入的版本標籤
* 更新完就消失Portal 設定頁變成無法讀取目前版本
* **保留了櫃子沒保留櫃子上的標籤**這個欄位就是那些標籤
*/
/**
* 規則需要的 CF 能力收窄成介面方便離線測試餵假帳號也讓安裝器用自己的 fetch 實作
* @typedef {object} ResourceApi
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
* @property {() => Promise<Map<string, string>>} listKvNamespaces title id
* @property {() => Promise<Map<string, string>>} listD1Databases name uuid
* @property {() => Promise<string[]>} listVectorizeIndexes
* @property {(title: string) => Promise<string>} createKvNamespace
* @property {(name: string) => Promise<string>} createD1Database
* @property {(name: string) => Promise<string>} createVectorizeIndex
*/
/**
* 這顆 worker 需要這個 bindingcreateName 只在**真的要新建**時才會被拿來當名字用
* @typedef {object} BindingRequirement
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} worker 需要它的 worker script = wrangler.toml `name`
* @property {string} createName
*/
/**
* @typedef {object} PlannedAdopt
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
*/
/**
* @typedef {object} PlannedCreate
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} createName
* @property {string[]} wantedBy
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding shareSameResource建一顆大家共用
*/
/**
* @typedef {object} ResourcePlan
* @property {PlannedAdopt[]} adopt
* @property {PlannedCreate[]} create
* @property {string[]} blockers 非空 = 整趟停手applyResourcePlan 會拒絕執行
* @property {Map<string, Record<string, string>>} liveVars
* 每顆**已部署** worker 現在掛著的 plain_text varscript /未部署的不在裡面
*
* Arcrun#106讀綁定的時候本來就把整份 `bindings[]` 拿回來了var 就在同一份回應裡
* 順手帶出來**不另外打一次 API**也不新增一種查不到的失敗模式
* 讀不到綁定這件事已經在上面 blockers 那一關擋掉了
*/
/**
* @typedef {object} ResolvedResource
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* @property {string} [from]
*/
/**
* @typedef {object} WranglerRequirements
* @property {string} script worker script toml 頂層 `name`空字串 = 這份 toml 沒宣告 name不該發生
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
*/
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
/** @param {string[]} blockers */
constructor(blockers) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
/** @type {string[]} */
this.blockers = blockers;
}
}
/**
* @param {ResourceKind} kind
* @param {string} binding
* @returns {string}
*/
export function bindingKey(kind, binding) {
return `${kind}:${binding}`;
}
/** @type {Record<ResourceKind, string>} */
export const KIND_LABEL = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
/**
* @param {unknown} e
* @returns {string}
*/
function msg(e) {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源要不要新建**不寫入任何東西**
*
* @param {ResourceApi} api
* @param {readonly BindingRequirement[]} requirements
* @param {'update' | 'init'} mode
* 'update' = 這台照定義已經裝過了見下方一顆都不在規則'init' = 全新安裝允許從零建
* @returns {Promise<ResourcePlan>}
*/
export async function planResources(api, requirements, mode) {
/** @type {string[]} */
const blockers = [];
/** @type {PlannedAdopt[]} */
const adopt = [];
/** @type {PlannedCreate[]} */
const create = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
/** @type {Map<string, LiveBinding[]>} */
const live = new Map();
/** @type {Map<string, Record<string, string>>} */
const liveVars = new Map();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) {
live.set(script, res.bindings);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} 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 決定:沿用 / 新建 / 停手 ─────────────────────────
/** @type {Map<string, BindingRequirement[]>} */
const byKey = new Map();
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]);
}
/** @type {Map<ResourceKind, Set<string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = async (kind) => {
const hit = existingCache.get(kind);
if (hit) return hit;
/** @type {Set<string>} */
let set;
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];
/** @type {Array<{value: string, script: string}>} */
const found = [];
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];
/** @type {Set<string>} */
let existing;
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, liveVars };
}
/**
* 收斂不同 binding 其實是同一顆資源的情況
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`不是使用者那側的資源名
* cypher `CREDENTIALS_DB` kbdb `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告這兩個綁定指向同一顆庫 #97 那種拿名字去猜使用者的資源是兩回事
*
* 沒有這一步會出兩種錯
* 全新安裝時建出兩顆同名 D1KBDB 的資料與 credential 目錄從此分家
* 一邊已部署沿用既有另一邊沒有新建一顆空的 半套資料比全壞更難查
*
* @param {PlannedAdopt[]} adopt
* @param {PlannedCreate[]} create
* @param {Map<string, BindingRequirement[]>} byKey
* @returns {PlannedCreate[]}
*/
function shareSameResource(adopt, create, byKey) {
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
const declaredName = (kind, binding) =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
/** @type {PlannedCreate[]} */
const out = [];
/** @type {Map<string, PlannedCreate>} */
const groups = new Map();
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**一顆都不建**
*
* @param {ResourceApi} api
* @param {ResourcePlan} plan
* @returns {Promise<Map<string, ResolvedResource>>}
*/
export async function applyResourcePlan(api, plan) {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
/** @type {Map<string, ResolvedResource>} */
const out = new Map();
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,
});
}
/** @type {string[]} */
const madeSoFar = [];
for (const c of plan.create) {
/** @type {string} */
let value;
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 → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
/**
* wrangler.toml table 資源種類需求解析與注入共用同一張表兩邊才不會對不上
* @type {Record<string, ResourceKind>}
*/
export const TABLE_KIND = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* wrangler.toml 抽出這顆 worker 需要哪些資源綁定
*
* 刻意寫成行掃描而不引 TOML parser注入端injectWranglerConfig本來就是純文字操作
* 兩邊用同一種視角看這份檔案才不會對不上註解掉的區塊**不算需求**
* kbdb `[[vectorize]]` 預設是註解狀態要開語義查詢時才會被取消註解 那時才成為需求
*
* 也是零依賴的一部分不引 TOML parser 安裝器 import 這支不必多裝任何東西
*
* @param {string} toml
* @returns {WranglerRequirements}
*/
export function parseWranglerRequirements(toml) {
let script = '';
let seenTable = false;
/** @type {WranglerRequirements['bindings']} */
const bindings = [];
/** @type {ResourceKind | null} */
let kind = null;
let binding = '';
let createName = '';
const flush = () => {
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 };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
// ─────────────────────────────────────────────────────────────────────────────
/**
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
* 同一種資源在不同 API 版本欄位名不一故全都收
*
* @typedef {object} RawWorkerBinding
* @property {string} [type]
* @property {string} [name]
* @property {string} [namespace_id]
* @property {string} [id]
* @property {string} [database_id]
* @property {string} [index_name]
* @property {string} [text] `plain_text` 綁定的值#106secret_text 不會回值本來就讀不到也不該讀
*/
/**
* CF binding 陣列收斂成規則認得的三種資源不認得的型別直接略過
*
* 🔴 這支**刻意放在規則裡**不留在各自的 CF client
* 什麼才算這顆 worker 綁著某顆資源是規則的一部分
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來例如一邊認 `namespace_id`
* 另一邊只認 `id`於是一邊看得到綁定另一邊看不到 後者又去新建了
*
* @param {RawWorkerBinding[]} raw
* @returns {LiveBinding[]}
*/
export function normalizeLiveBindings(raw) {
/** @type {LiveBinding[]} */
const out = [];
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;
}
/**
* 抽出已部署 worker 上的 `plain_text` var#106
*
* 只收 `plain_text`**`secret_text` 一律不碰**CF 本來就不回值也不該被搬來搬去
* wrangler deploy 不會動 secret它們自己會留著
*
* @param {RawWorkerBinding[]} raw
* @returns {Record<string, string>}
*/
export function normalizeLiveVars(raw) {
/** @type {Record<string, string>} */
const out = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}
+111
View File
@@ -0,0 +1,111 @@
/**
*
*
* leo `grep` ****
*
*
* grep 西
* repo `shared/resource-rule/`
* `cli/src/lib/resource-rule/` sync
* 稿sync --check `npm test`
*
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
const MIRROR_DIR = join(REPO, 'cli/src/lib/resource-rule');
/** 規則的實作特徵:這些**宣告**只准出現在原稿目錄(與它的鏡射)裡。 */
const RULE_DECLARATIONS = [
'function planResources',
'function applyResourcePlan',
'function shareSameResource',
'function parseWranglerRequirements',
'function normalizeLiveBindings',
'function normalizeLiveVars',
'function createCloudflareResourceApi',
];
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', '.wrangler', '.worker-builds', '.component-builds',
'.github-public', 'coverage',
]);
/** 只掃「人會寫程式的地方」;產生物與二進位不掃。 */
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const abs = join(dir, name);
const st = statSync(abs);
if (st.isDirectory()) walk(abs, out);
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) out.push(abs);
}
return out;
}
const sha256 = (b: Buffer): string => createHash('sha256').update(b).digest('hex');
test('① 規則的實作全 repo 只有一份(原稿目錄 + 它的鏡射,沒有第三處)', () => {
const files = walk(REPO);
const offenders: string[] = [];
for (const abs of files) {
const rel = relative(REPO, abs);
// 原稿與鏡射本來就該有;測試檔在講規則、不是實作規則
if (rel.startsWith('shared/resource-rule/')) continue;
if (rel.startsWith('cli/src/lib/resource-rule/')) continue;
if (rel.startsWith('cli/tests/')) continue;
if (rel === 'scripts/sync-resource-rule.mjs') continue;
const src = readFileSync(abs, 'utf8');
for (const decl of RULE_DECLARATIONS) {
if (src.includes(decl)) offenders.push(`${rel}${decl}`);
}
}
assert.deepEqual(offenders, [],
'「決定用哪些資源」的實作出現在共用層之外——這正是本票要消滅的東西:\n' +
offenders.map((o) => `${o}`).join('\n') +
'\n要改規則就改 shared/resource-rule/,呼叫端只准 import。');
console.log(`\n ① 掃過 ${files.length} 個原始碼檔,${RULE_DECLARATIONS.length} 支規則函式的實作` +
' 全部只出現在 shared/resource-rule/(+機械鏡射)');
});
test('② CLI 帶的那份與原稿逐位元組相同(漂移=第二份實作偷偷長出來)', () => {
const files = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
assert.ok(files.length > 0, 'shared/resource-rule/ 裡沒有任何 .mjs 原稿');
const mirrored = readdirSync(MIRROR_DIR).filter((f) => f.endsWith('.mjs')).sort();
assert.deepEqual(mirrored, files, '鏡射目錄的檔案清單與原稿不一致');
for (const f of files) {
const a = sha256(readFileSync(join(SOURCE_DIR, f)));
const b = sha256(readFileSync(join(MIRROR_DIR, f)));
assert.equal(b, a, `cli/src/lib/resource-rule/${f} 與原稿不一致——不要手改產生物,` +
'改 shared/resource-rule/ 後跑 node scripts/sync-resource-rule.mjs');
console.log(`${f.padEnd(24)} sha256 ${a.slice(0, 16)} 原稿 = 鏡射`);
}
});
test('③ 共用層零外部依賴(只准 import 同目錄的兄弟檔)', () => {
for (const f of readdirSync(SOURCE_DIR).filter((x) => x.endsWith('.mjs'))) {
const src = readFileSync(join(SOURCE_DIR, f), 'utf8');
const imports = [...src.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1]);
for (const spec of imports) {
assert.ok(spec.startsWith('./'),
`shared/resource-rule/${f} import 了 "${spec}"——共用層一旦有外部依賴,` +
'就會有某條路(Workers runtime/安裝器)吃不到它。');
}
assert.doesNotMatch(src, /require\(|from\s+['"]node:/,
`shared/resource-rule/${f} 用到 node 專屬 API——Cloudflare Workers 上跑不起來。`);
console.log(`${f.padEnd(24)} import: ${imports.length ? imports.join(', ') : '(無)'}`);
}
});
+159
View File
@@ -0,0 +1,159 @@
/**
*
*
* leo 2026-08-12 CLI
*
* `acr` Arcrun#97
* 使
*
* ****
* A. `acr` `CfAccountClient` + `resource-resolver`CLI import
* B. import `shared/resource-rule/`
* **resource id **
*
* `fetch` `ResourceApi` HTTP
* CF #97
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
// ── A:acr 那條(CLI 真正用的東西)
import { CfAccountClient } from '../src/lib/cf-api.ts';
import { planResources, applyResourcePlan, bindingKey } from '../src/lib/resource-resolver.ts';
import type { BindingRequirement } from '../src/lib/resource-resolver.ts';
// ── B:安裝器那條(只碰 shared/)
import { resolveInstanceResources } from '../../shared/resource-rule/installer-entry.mjs';
// ── 共用 fixture
import {
makeAccount,
requirements,
SCENARIOS,
WORKER_NEEDS,
type Scenario,
} from '../../shared/resource-rule/tests/fixture-account.mjs';
const ACCOUNT = 'acct-fixture';
const TOKEN = 'tok-fixture';
/** 把 fixture 的需求組成安裝器吃的 wrangler.toml 文字(它的入口是從 toml 讀需求的)。 */
function tomlsFor(): string[] {
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
for (const d of need.d1) {
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
}
return t;
});
}
/** A:跑 acr 那條。CfAccountClient 走 global fetch,所以這裡把它換成 fixture。 */
async function runAcrPath(scenario: Scenario, mode: 'update' | 'init') {
const account = makeAccount(scenario);
const realFetch = globalThis.fetch;
globalThis.fetch = account.fetch;
try {
const api = new CfAccountClient(ACCOUNT, TOKEN);
const plan = await planResources(api, requirements() as BindingRequirement[], mode);
if (plan.blockers.length > 0) {
return { blocked: true, blockers: plan.blockers, bindings: {} as Record<string, string>, account };
}
const resolved = await applyResourcePlan(api, plan);
const bindings: Record<string, string> = {};
for (const [k, r] of resolved) bindings[k] = r.value;
return { blocked: false, blockers: [] as string[], bindings, account };
} finally {
globalThis.fetch = realFetch;
}
}
/** B:跑安裝器那條。只用 shared/ 的入口,fetch 直接注入。 */
async function runInstallerPath(scenario: Scenario, mode: 'update' | 'init') {
const account = makeAccount(scenario);
const r = await resolveInstanceResources({
accountId: ACCOUNT,
apiToken: TOKEN,
wranglerTomls: tomlsFor(),
mode,
fetch: account.fetch,
});
return { blocked: r.blocked, blockers: r.blockers, bindings: r.bindings, account };
}
/** 把兩邊的決定印出來——PR 要貼的就是這張對照表。 */
function report(scenario: Scenario, a: Record<string, string>, b: Record<string, string>): void {
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
console.log(`\n ── ${scenario}${SCENARIOS[scenario].label}`);
console.log(` ${'binding'.padEnd(34)} ${'acr 選的'.padEnd(24)} 安裝器選的 一致?`);
for (const k of keys) {
const same = a[k] === b[k] ? '✓' : '✗';
console.log(` ${k.padEnd(34)} ${(a[k] ?? '—').padEnd(24)} ${(b[k] ?? '—').padEnd(18)} ${same}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
for (const scenario of ['fresh', 'installed', 'renamed'] as const) {
const mode = scenario === 'fresh' ? 'init' : 'update';
test(`兩條路一致 — ${scenario}${SCENARIOS[scenario].label}`, async () => {
const a = await runAcrPath(scenario, mode);
const b = await runInstallerPath(scenario, mode);
assert.equal(a.blocked, b.blocked, '一邊停手、一邊照做 = 最危險的分歧');
assert.deepEqual(a.blockers, b.blockers, '停手的理由也要一樣');
report(scenario, a.bindings, b.bindings);
assert.deepEqual(
a.bindings,
b.bindings,
`${scenario}:兩條路選出的 resource id 不同——這就是 Arcrun#97 的形狀`,
);
// 建立行為也要一致(一邊沿用、一邊新建 = 使用者的東西在其中一條路上會消失)
assert.deepEqual(a.account.created, b.account.created, '兩條路「建了什麼」必須一樣');
});
}
// ── 三種情境各自該有的行為(不只是「兩邊一樣」,還要「一樣地對」)─────────────
test('情境① 沒裝過 → 正常建新的(不能為了沿用而變成永遠不建)', async () => {
const { blocked, bindings, account } = await runInstallerPath('fresh', 'init');
assert.equal(blocked, false, '全新帳號要裝得起來');
assert.equal(account.created.kv.length, 9, `應新建 9 顆 KV,實際 ${account.created.kv.length}`);
assert.equal(account.created.d1.length, 1, `應新建 1 顆 D1,實際 ${account.created.d1.length}`);
// cypher 的 CREDENTIALS_DB 與 kbdb 的 DB 宣告同一個 database_name → 只該建一顆,兩邊共用
assert.equal(bindings['d1:CREDENTIALS_DB'], bindings['d1:DB'], '同一顆 D1 不該被建成兩顆');
console.log(`\n ① 新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length}` +
`D1 共用:CREDENTIALS_DB = DB = ${bindings['d1:DB']}`);
});
test('情境② 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在', async () => {
const { blocked, bindings, account } = await runInstallerPath('installed', 'update');
assert.equal(blocked, false);
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '更新不該建出任何新資源');
// 使用者的東西掛在資源 id 上:綁定還指向原本那顆 = 東西還在
assert.equal(bindings['kv_namespace:WEBHOOKS'], account.kvIdFor('WEBHOOKS'));
assert.equal(bindings['kv_namespace:SESSIONS_KV'], account.kvIdFor('SESSIONS_KV'));
assert.equal(bindings['d1:DB'], account.d1Id);
console.log(`\n ② 沿用:WEBHOOKS → ${bindings['kv_namespace:WEBHOOKS']}` +
`(工作流 ${account.userData.workflows.length} 支還在)|` +
`SESSIONS_KV → ${bindings['kv_namespace:SESSIONS_KV']}(登入 session 還在)|` +
`DB → ${bindings['d1:DB']}(子庫 ${account.userData.libraries.length} 個還在)|新建 0 顆`);
});
test('情境③ 資源在但名字與預期完全不同 → 仍然沿用(#97 的病根,專門驗)', async () => {
const { blocked, bindings, account } = await runInstallerPath('renamed', 'update');
assert.equal(blocked, false);
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] },
'名字對不上就新建 = 正是 #97:一次更新生出 9 顆空 KV,使用者的東西從畫面上消失');
for (const b of ['WEBHOOKS', 'SESSIONS_KV', 'RECIPES', 'USERS_KV']) {
assert.equal(bindings[bindingKey('kv_namespace', b)], account.kvIdFor(b),
`${b} 沒有沿用到原本那顆`);
}
console.log(`\n ③ 名字全不同(例:WEBHOOKS 那顆實際叫 "${SCENARIOS.renamed.titleFor('WEBHOOKS')}"` +
` → 仍沿用 ${bindings['kv_namespace:WEBHOOKS']},新建 0 顆`);
});
+5
View File
@@ -6,6 +6,11 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"strict": true, "strict": true,
// resource-rule.mjs JS + JSDoc
// allowJs tsc dist npm MODULE_NOT_FOUND
// checkJs JSDoc any
"allowJs": true,
"checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"declaration": true, "declaration": true,
+6 -1
View File
@@ -840,7 +840,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' + '<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' + '<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') + (ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' + // Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、
// 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板
// 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組' +
(!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0
? '<span class="dim">・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)</span>' : '') + '</span>' +
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>'; '<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
}).join(''); }).join('');
} }
+60 -6
View File
@@ -38,6 +38,13 @@ export interface LibraryMapRow {
narrative: string | null; narrative: string | null;
top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用) top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用)
triplet_count: number; triplet_count: number;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」——
// entriesingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AIMCP 渲染層)分得清
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
// (見本檔 liveEntryCountsByLibrary 的計數口徑)。
entry_count: number;
updated_at: number; updated_at: number;
} }
@@ -50,6 +57,7 @@ export interface LibraryMapDetail {
relation_profile: RelationStat[]; relation_profile: RelationStat[];
bridges: Bridge[]; bridges: Bridge[];
triplet_count: number; triplet_count: number;
entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解)
commit_hash: string | null; commit_hash: string | null;
status: string; status: string;
updated_at: number; updated_at: number;
@@ -285,6 +293,10 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
superseded.push(row.rid); superseded.push(row.rid);
} }
// entry_countArcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的
// 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
return { return {
map: { map: {
record_id: blockEntry.id, record_id: blockEntry.id,
@@ -295,6 +307,7 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
relation_profile: relationProfile, relation_profile: relationProfile,
bridges, bridges,
triplet_count: tripletCount, triplet_count: tripletCount,
entry_count: entryCount,
commit_hash: input.commit_hash ?? null, commit_hash: input.commit_hash ?? null,
status: 'active', status: 'active',
updated_at: blockEntry.created_at, updated_at: blockEntry.created_at,
@@ -367,6 +380,34 @@ async function liveTripletCountsByLibrary(
return m; return m;
} }
// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數
//Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳:
// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣
// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。
//
// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」):
// - entry_type='value'record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record
// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。
// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史
// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。
async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [owner_id] : [];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(*) AS n
FROM entries
WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value'
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.libraryt52 慣例)或 // 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.libraryt52 慣例)或
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的 // 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
// 「這庫是空的」(回 200triplet_count:0vs「查無此庫」(回 404)。kbdb base 對 portal_library // 「這庫是空的」(回 200triplet_count:0vs「查無此庫」(回 404)。kbdb base 對 portal_library
@@ -453,20 +494,26 @@ interface MapPivotRow {
ts: number; ts: number;
} }
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count),MCP instructions // 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_countentry_count),MCP
// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。 // instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→
// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容
// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary
// 註解),讀端不該把其中一個的 0 當成另一個的答案。
export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> { export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME); const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return []; if (!tpl) return [];
const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id]; const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id];
const res = await db const [res, entryCounts] = await Promise.all([
.prepare( db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
`WITH m AS (${mapPivotSql(!!owner_id)}) `WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`, ORDER BY m.ts DESC`,
) )
.bind(...params) .bind(...params)
.all<MapPivotRow>(); .all<MapPivotRow>(),
liveEntryCountsByLibrary(db, owner_id),
]);
// 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。 // 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
const byLib = new Map<string, LibraryMapRow>(); const byLib = new Map<string, LibraryMapRow>();
for (const r of res.results ?? []) { for (const r of res.results ?? []) {
@@ -476,6 +523,7 @@ export async function listLibraryMaps(db: D1Database, owner_id?: string): Promis
narrative: r.narrative || null, narrative: r.narrative || null,
top_entities: parseJsonArray<TopEntity>(r.top_entities).slice(0, 3).map((t) => t.name), top_entities: parseJsonArray<TopEntity>(r.top_entities).slice(0, 3).map((t) => t.name),
triplet_count: Number(r.triplet_count ?? 0) || 0, triplet_count: Number(r.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(r.library) ?? 0,
updated_at: r.ts, updated_at: r.ts,
}); });
} }
@@ -501,7 +549,12 @@ export async function getLibraryMapDetail(
.first<MapPivotRow>(); .first<MapPivotRow>();
if (!row) return null; if (!row) return null;
// record_idmap block entry idrecompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。 // record_idmap block entry idrecompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。
const blockEntry = await getEntry(db, row.rid); // entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖
// 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。
const [blockEntry, entryCounts] = await Promise.all([
getEntry(db, row.rid),
liveEntryCountsByLibrary(db, owner_id),
]);
return { return {
record_id: row.rid, record_id: row.rid,
library, library,
@@ -511,6 +564,7 @@ export async function getLibraryMapDetail(
relation_profile: parseJsonArray<RelationStat>(row.relation_profile), relation_profile: parseJsonArray<RelationStat>(row.relation_profile),
bridges: parseJsonArray<Bridge>(row.bridges), bridges: parseJsonArray<Bridge>(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0, triplet_count: Number(row.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(library) ?? 0,
commit_hash: row.commit_hash || null, commit_hash: row.commit_hash || null,
status: row.status ?? 'active', status: row.status ?? 'active',
updated_at: row.ts, updated_at: row.ts,
+57
View File
@@ -361,6 +361,63 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動
expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404 expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404
}); });
it('entry_count 讓「有原始內容但三元組從沒萃取過」與「真的什麼都沒有」分得清(Arcrun#87 三次收尾)', async () => {
// 情境沿用上一案的 'hr' 庫(entries 蓋過章、triplet_count:0),這正是 leo21c 實測到的真實
// 現況(kb 以外 7 庫皆此況):地圖過去只回 triplet_count,AI 讀到 0 就誤判「這庫沒有知識」,
// 但實際上 kbdb_search 找得到內容——因為 entries 一直都在,只是沒被萃取成三元組。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await createEntry(db, {
content: '人資資料 A',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
await createEntry(db, {
content: '人資資料 B',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
const { app, env } = makeApp(db);
// 單庫詳圖:triplet_count 仍是 0(沒騙這件事),但 entry_count 誠實回 2。
const detailRes = await app.request('/map/hr?owner_id=leo', {}, env);
const detailBody = (await detailRes.json()) as { map: { triplet_count: number; entry_count: number } };
expect(detailBody.map.triplet_count).toBe(0);
expect(detailBody.map.entry_count).toBe(2);
// 全館視圖:同一個庫、同一組數字,兩個 consumerGET /map、GET /map/:library)不能對不上。
const listRes = await app.request('/map?owner_id=leo', {}, env);
const listBody = (await listRes.json()) as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
const hr = listBody.libraries.find((l) => l.library === 'hr');
expect(hr).toBeDefined();
expect(hr!.triplet_count).toBe(0);
expect(hr!.entry_count).toBe(2);
});
it('entry_count 的計數口徑排除 value 型 entries 與地圖自己的歷史摘要 block(不自我膨脹)', async () => {
// record-crud 建 triplet record 時,每個 slot 值都會落一筆 entry_type='value' 的 entry
// (儲存實作細節,不是「一筆知識」);地圖 recompute 也會建 entry_type='block' 且
// metadata.kind='library_map' 的摘要 entry——兩者都不該被算進 entry_count,否則地圖會把
// 自己的內部管線雜訊當成「使用者知識」回報,數字沒有意義。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// 建一條 kb 三元組 → 連帶產生數筆 entry_type='value' 的 entries(不該被算進 entry_count)。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
// 觸發一次 recompute → 產生一筆 entry_type='block'/metadata.kind='library_map' 的摘要 entry
// (同樣不該被算進 entry_count)。
await recomputeLibraryMap(db, { library: 'kb' });
const detail = await getLibraryMapDetail(db, 'kb');
// 這個情境下 kb 沒有任何「真的 ingest 進來的原始內容」entry(只有 value 碎片與地圖自己的摘要),
// entry_count 應誠實回 0——不能因為底層 entries 表其實有好幾筆就報出一個誤導的非零數字。
expect(detail!.entry_count).toBe(0);
expect(detail!.triplet_count).toBe(1); // 對照:triplet_count 不受這個排除規則影響,維持原樣。
});
it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => { it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => {
const db = makeSqliteD1(); const db = makeSqliteD1();
await seedTripletTemplate(db); await seedTripletTemplate(db);
+22 -3
View File
@@ -27,6 +27,10 @@ export interface LibraryMapRow {
narrative: string | null; narrative: string | null;
top_entities: unknown; // 正常是 string[];防禦:舊部署可能回 JSON 字串形 top_entities: unknown; // 正常是 string[];防禦:舊部署可能回 JSON 字串形
triplet_count: number | string; triplet_count: number | string;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於這庫沒有知識——
// 可能只是三元組萃取從沒對它跑過,但 entries(原始 ingest 內容)還在,kbdb_search 找得到。
// 選填是因為舊部署(未帶這次修法)的 kbdb base 回應裡不會有這個欄位,容錯當 0。
entry_count?: number | string;
updated_at?: number; updated_at?: number;
} }
@@ -65,7 +69,15 @@ export function entityNames(raw: unknown, limit: number): string[] {
const MAX_LIBRARY_LINES = 30; const MAX_LIBRARY_LINES = 30;
const MAX_NARRATIVE_CHARS = 60; const MAX_NARRATIVE_CHARS = 60;
/** 把 GET /map 的 libraries[] 渲染成緊湊文字(每庫一行,design §4 指定格式)。空清單回 null。 */ /**
* GET /map libraries[] design §4 null
*
* Arcrun#87 2026-08-13 session MCP
* triplet_countkb 0 triplets
* session 7 entries ingest
* kbdb_search triplet_count0
* entry_count0
*/
export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null { export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null {
const rows = libraries.filter((l) => l && typeof l.library === "string" && l.library); const rows = libraries.filter((l) => l && typeof l.library === "string" && l.library);
if (rows.length === 0) return null; if (rows.length === 0) return null;
@@ -74,8 +86,15 @@ export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null
const clipped = const clipped =
narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}` : narrative; narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}` : narrative;
const core = entityNames(l.top_entities, 3); const core = entityNames(l.top_entities, 3);
const count = Number(l.triplet_count ?? 0) || 0; const tripletCount = Number(l.triplet_count ?? 0) || 0;
return `- ${l.library}${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}${count} triplets`; const entryCount = Number(l.entry_count ?? 0) || 0;
const countPart =
tripletCount > 0
? `${tripletCount} triplets`
: entryCount > 0
? `0 triplets${entryCount} 筆原始內容(尚未萃取關係,kbdb_search 查得到)`
: `0 triplets0 內容`;
return `- ${l.library}${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}${countPart}`;
}); });
const omitted = rows.length > MAX_LIBRARY_LINES ? `\n(其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略,kbdb_get_map 可看全部)` : ""; const omitted = rows.length > MAX_LIBRARY_LINES ? `\n(其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略,kbdb_get_map 可看全部)` : "";
return lines.join("\n") + omitted; return lines.join("\n") + omitted;
+37 -3
View File
@@ -5,13 +5,20 @@
* MCP KBDB service bindingkbdbFetch GET /map/map/:library * MCP KBDB service bindingkbdbFetch GET /map/map/:library
* D1 binding #68 kbdb_graph_neighbors D17 KBDB MCP kbdb_* * D1 binding #68 kbdb_graph_neighbors D17 KBDB MCP kbdb_*
* *
* kbdb/src/routes/map.tsM2 merge * kbdb/src/routes/map.tsM2 mergeentry_count Arcrun#87
* GET /map { success, libraries:[{library, narrative, top_entities( top3), * GET /map { success, libraries:[{library, narrative, top_entities( top3),
* triplet_count, updated_at}], count } * triplet_count, entry_count, updated_at}], count }
* GET /map/:library { success, map:{record_id, library, narrative, content, top_entities, * GET /map/:library { success, map:{record_id, library, narrative, content, top_entities,
* relation_profile, bridges, triplet_count, commit_hash, status, updated_at} } * relation_profile, bridges, triplet_count, entry_count, commit_hash,
* status, updated_at} }
* 404 { success:false, error:'not found' } recompute * 404 { success:false, error:'not found' } recompute
* *
* entry_count vs triplet_count
* triplet_countentry_countingest /skill/block
* 0 0 triplet_count0
* entry_count0 kbdb_search triplet_count0
*
*
* slot top_entities/relation_profile/bridges JSON base parse * slot top_entities/relation_profile/bridges JSON base parse
* live parse crash * live parse crash
*/ */
@@ -59,11 +66,23 @@ interface LibraryMapDetail {
relation_profile?: unknown; relation_profile?: unknown;
bridges?: unknown; bridges?: unknown;
triplet_count?: number | string; triplet_count?: number | string;
// Arcrun#87 三次收尾:同 lib/library-map.ts LibraryMapRow.entry_count 的欄位(見該檔註解)。
entry_count?: number | string;
commit_hash?: string | null; commit_hash?: string | null;
status?: string; status?: string;
updated_at?: number; updated_at?: number;
} }
/** triplet_count0 但 entry_count0 時給的提示(別讓「0 triplets」被讀成「這庫沒有知識」)。 */
function entryOnlyHint(tripletCount: number, entryCount: number, library?: string): string[] {
if (tripletCount > 0 || entryCount <= 0) return [];
const lib = library ? `${library}` : "這個庫";
return [
`${lib} triplet_count0,但有 ${entryCount} 筆原始內容(entries)——三元組萃取還沒對它跑過,` +
"不代表沒有知識。用 kbdb_search(關鍵字或語義)直接查得到內容。",
];
}
/** /**
* kbdb_get_map library * kbdb_get_map library
* design §6 retrieval get_map(library) graph/search * design §6 retrieval get_map(library) graph/search
@@ -113,6 +132,7 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。 // 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
top_entities: entityNames(l.top_entities, 3), top_entities: entityNames(l.top_entities, 3),
triplet_count: Number(l.triplet_count ?? 0) || 0, triplet_count: Number(l.triplet_count ?? 0) || 0,
entry_count: Number(l.entry_count ?? 0) || 0,
})); }));
if (libraries.length === 0) { if (libraries.length === 0) {
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS // 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
@@ -128,9 +148,21 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
...RECOMPUTE_HINTS, ...RECOMPUTE_HINTS,
]); ]);
} }
// Arcrun#87 三次收尾:某些庫 triplet_count0 但 entry_count0entries 有、三元組
// 萃取沒跑過)——這件事只在「這種庫真的存在」時才提一次,不是每個庫都印一行(避免洗版),
// 讓讀這份地圖的 session 知道「觸目所及的 0」不能直接當成「沒有知識」。
const entryOnlyLibs = libraries.filter(
(l) => (l.triplet_count ?? 0) === 0 && (l.entry_count ?? 0) > 0,
);
return successResponse({ libraries, count: libraries.length }, [ return successResponse({ libraries, count: libraries.length }, [
"要看某庫細節:kbdb_get_map(library='庫名')", "要看某庫細節:kbdb_get_map(library='庫名')",
"進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors", "進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors",
...(entryOnlyLibs.length > 0
? [
`${entryOnlyLibs.map((l) => l.library).join("、")} 這幾庫 triplet_count0 但 entry_count0` +
"有原始內容,只是還沒萃取出三元組關係——別把 0 triplets 讀成「沒有知識」,直接 kbdb_search 進去查。",
]
: []),
]); ]);
} }
@@ -167,10 +199,12 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
relation_profile: parseSlotArray<{ predicate: string; count: number }>(raw.relation_profile), relation_profile: parseSlotArray<{ predicate: string; count: number }>(raw.relation_profile),
bridges: parseSlotArray<{ entity: string; libraries: string[] }>(raw.bridges), bridges: parseSlotArray<{ entity: string; libraries: string[] }>(raw.bridges),
triplet_count: Number(raw.triplet_count ?? 0) || 0, triplet_count: Number(raw.triplet_count ?? 0) || 0,
entry_count: Number(raw.entry_count ?? 0) || 0,
}; };
return successResponse({ map }, [ return successResponse({ map }, [
"bridges=此庫 entity 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)", "bridges=此庫 entity 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)",
"沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)", "沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)",
...entryOnlyHint(map.triplet_count, map.entry_count, library),
]); ]);
} catch (e) { } catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]); return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
+96
View File
@@ -121,6 +121,46 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo"); expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
}); });
it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 的庫附上「別當成沒有知識」的提示", async () => {
// leo21c 實測現況:kb 以外幾乎每庫都長這樣——entries 有(且 library 標對),三元組萃取沒跑過。
const emptyTripletRow = {
library: "arcrun",
narrative: null,
top_entities: [],
triplet_count: 0,
entry_count: 15,
updated_at: 1786330534,
};
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [emptyTripletRow], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const data = body.data as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
expect(data.libraries[0].entry_count).toBe(15);
expect(data.libraries[0].triplet_count).toBe(0);
// 提示要點名該庫、講清楚「有內容只是沒萃取」,別讓 AI 讀到 0 triplets 就跳過這個庫。
const hints = (body.hints as string[]).join(" ");
expect(hints).toContain("arcrun");
expect(hints).toContain("entry_count");
expect(hints).toContain("kbdb_search");
});
it("entry_count 缺席(舊部署未帶此欄位)→ 容錯當 0,不 crash、不誤發提示", async () => {
const legacyRow = { ...KB_ROW }; // KB_ROW 本身沒有 entry_count 欄位(模擬舊部署回應)
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [legacyRow], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const data = body.data as { libraries: { entry_count: number }[] };
expect(data.libraries[0].entry_count).toBe(0);
});
it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => { it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => {
const { server, tools } = makeServer(); const { server, tools } = makeServer();
const row = { const row = {
@@ -213,6 +253,39 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]); expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]);
}); });
it("Arcrun#87 三次收尾:單庫詳圖 triplet_count=0、entry_count>0 → 附「別當空庫」提示", async () => {
const emptyTripletDetail = {
...DETAIL,
library: "arcrun",
triplet_count: 0,
entry_count: 15,
top_entities: [],
relation_profile: [],
bridges: [],
};
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: emptyTripletDetail })));
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "arcrun" });
const body = parseResult(res);
const map = (body.data as { map: { entry_count: number; triplet_count: number } }).map;
expect(map.triplet_count).toBe(0);
expect(map.entry_count).toBe(15);
const hints = (body.hints as string[]).join(" ");
expect(hints).toContain("arcrun");
expect(hints).toContain("kbdb_search");
});
it("triplet_count>0 的庫不附「別當空庫」提示(不需要時別洗版)", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: DETAIL }))); // DETAIL.triplet_count=111
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
const body = parseResult(res);
const hints = (body.hints as string[]).join(" ");
expect(hints).not.toContain("entry_count");
});
it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => { it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => {
const { server, tools } = makeServer(); const { server, tools } = makeServer();
const raw = { const raw = {
@@ -343,6 +416,29 @@ describe("renderLibraryMapLines", () => {
it("empty input → null", () => { it("empty input → null", () => {
expect(renderLibraryMapLines([])).toBeNull(); expect(renderLibraryMapLines([])).toBeNull();
}); });
it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 → 開場那行不再讀成「這庫沒有知識」", () => {
// 這是 leo21c 的實際現況(kb 以外 7 庫皆此況)——這段渲染出的文字是全新 session 連上 MCP
// 第一眼看到的地圖,過去只印「0 triplets」,讀起來像空庫,session 因此跳過不查。
const text = renderLibraryMapLines([
{ library: "arcrun", narrative: null, top_entities: [], triplet_count: 0, entry_count: 15 },
]);
expect(text).toBe("- arcrun:(narrative 待補)|核心:(尚無)|0 triplets/15 筆原始內容(尚未萃取關係,kbdb_search 查得到)");
});
it("triplet_count=0 且 entry_count=0(真的沒有任何內容)→ 誠實講兩個都是 0", () => {
const text = renderLibraryMapLines([
{ library: "empty-lib", narrative: null, top_entities: [], triplet_count: 0, entry_count: 0 },
]);
expect(text).toBe("- empty-lib:(narrative 待補)|核心:(尚無)|0 triplets0 內容");
});
it("entry_count 缺席(舊部署未帶欄位)→ 容錯當 0,維持既有 triplet-only 措辭不 crash", () => {
const text = renderLibraryMapLines([
{ library: "kb", narrative: "摘要", top_entities: ["A"], triplet_count: 5 },
]);
expect(text).toBe("- kb:摘要|核心:A5 triplets");
});
}); });
// ── 2026-08-12:地圖也要跟著登入者的權限走 ──────────────────────────────────── // ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* sync-resource-rule.mjs 該用哪些資源這條規則的**唯一原稿**同步給需要打包的呼叫端
*
* 為什麼需要這支
* 規則的原稿在 `shared/resource-rule/rule.mjs`理由見該檔開頭
* 兩條路取用它的方式不同
*
* · **安裝器 / 任何 Worker**本來就會下載這個 repo archive 當部署來源
* 直接 import `shared/resource-rule/rule.mjs`**不需要副本本支不管它**
*
* · **`acr` CLI**`arcrun` 是獨立 npm 套件`npm pack` 打不進套件目錄外的檔案
* 套件裡必須有一份這支就是產生那一份的地方
*
* 這算不算第二份實作
* 不算而且是機械保證的產生物是**逐位元組副本**`--check` 一有差就 exit 1
* `npm run build` `npm test` 都會先跑 `--check`
* 也就是說有人手改了 CLI 那一份= build publish 擋下
* `cli/harness/`產生物進 repo `check:harness` 世代閘的既有慣例
* 不是為本票新發明的做法
*
* 用法
* node scripts/sync-resource-rule.mjs 產生更新副本
* node scripts/sync-resource-rule.mjs --check 只檢查有漂移就 exit 1不寫檔
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
// REPO 一律由本檔位置推導,不吃 cwd(比照 scripts/build-worker-artifacts.mjs)。
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
/**
* 需要套件內自帶一份的呼叫端**整個目錄原樣鏡射**不是挑檔案
* 檔名與相對位置保持一致`cf-resource-api.mjs` 裡的 `./rule.mjs` 才不用改寫
* 安裝器不在此列它直接讀 repo archive 裡的原稿連副本都不需要
*/
const MIRRORS = ['cli/src/lib/resource-rule'];
const CHECK_ONLY = process.argv.includes('--check');
/** @param {string|Buffer} b */
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
if (!existsSync(SOURCE_DIR)) {
console.error(`❌ 找不到規則原稿目錄:${SOURCE_DIR}`);
process.exit(1);
}
/** 原稿目錄裡所有 .mjs(README / 測試不進副本)。 */
const FILES = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
if (FILES.length === 0) {
console.error(`${SOURCE_DIR} 裡沒有任何 .mjs 原稿`);
process.exit(1);
}
let drifted = 0;
for (const mirror of MIRRORS) {
for (const file of FILES) {
const src = readFileSync(join(SOURCE_DIR, file));
const srcHash = sha256(src);
const rel = `${mirror}/${file}`;
const abs = join(REPO, mirror, file);
const had = existsSync(abs) ? readFileSync(abs) : null;
if (had !== null && sha256(had) === srcHash) {
console.log(`${rel} = 原稿(sha256 ${srcHash.slice(0, 12)}`);
continue;
}
if (CHECK_ONLY) {
drifted++;
console.error(
had === null
? `${rel} 不存在——跑 \`node scripts/sync-resource-rule.mjs\` 產生。`
: `${rel} 與原稿不一致(副本 ${sha256(had).slice(0, 12)} ≠ 原稿 ${srcHash.slice(0, 12)})。\n` +
` 這一份是**產生物**,不要手改:規則要改就改 shared/resource-rule/${file}` +
`然後跑 \`node scripts/sync-resource-rule.mjs\``,
);
continue;
}
mkdirSync(join(REPO, mirror), { recursive: true });
writeFileSync(abs, src);
console.log(`${rel} ← shared/resource-rule/${file}sha256 ${srcHash.slice(0, 12)}`);
}
// 副本目錄裡多出來的 .mjs = 有人在產生物旁邊自己加了一支(第二份實作的常見長法)。
const mirrorAbs = join(REPO, mirror);
const extra = existsSync(mirrorAbs)
? readdirSync(mirrorAbs).filter((f) => f.endsWith('.mjs') && !FILES.includes(f))
: [];
for (const f of extra) {
drifted++;
console.error(`${mirror}/${f} 在原稿目錄裡不存在——副本目錄不是放自己東西的地方。`);
}
}
if (drifted > 0) {
console.error(
`\n${drifted} 項與規則原稿脫節。` +
`\n「該用哪些資源」只能有一份實作(.claude/rules/07-thin-shell.md)——` +
`副本漂移就是第二份實作偷偷長出來的樣子。`,
);
process.exit(1);
}
+138
View File
@@ -0,0 +1,138 @@
# `shared/resource-rule` — 「這個實例該用哪些資源」的唯一一份規則
> leo 2026-08-12
> ①「如果你沒有裝,就是新的;**如果你已經有,原來叫什麼名字就繼續用下去**。」
> ②「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
① 是規則本身,② 是它該住哪裡。這個目錄就是 ②。
---
## 1. 規則(三句話)
判準是「**這顆 worker 現在綁著誰**」,**不是**「有沒有叫這個名字的資源」。
1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
3. **只要有一點說不準就整趟停手**——讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
該更新的 worker 一顆都不在 ⇒ **什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段,
所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。
---
## 2. 為什麼在這裡,不在 cypher-executor 的 API
`.claude/rules/07-thin-shell.md` 的標準答案是「能力放 API」。這一條**不走那條路**,理由是自舉:
| 問題 | 說明 |
|---|---|
| **cypher 可能還不存在** | 這條規則要在「決定怎麼裝」的當下就用得到,而安裝器的工作正是把 cypher 生出來。把規則放進 cypher = 要先有雞才能有蛋。 |
| **輸入是使用者自己的帳號狀態** | 判斷的依據是使用者 Cloudflare 帳號上的綁定。送去平台託管的 worker 換一個答案 ⇒ ①「能不能安裝」綁在平台是否活著,②使用者的帳號拓撲交給第三方。 |
| **它根本不需要是服務** | 這是**純函式**:唯一的 IO 由呼叫端注入(`ResourceApi`)。薄殼原則要求「能力只實作一次」,不是「能力一定要是 HTTP」。 |
所以形態是**一份零依賴的 ESM**——Node 18+ 與 Cloudflare Workers runtime 都能直接 import
不必編譯、不必連網、不必先有任何 arcrun 元件活著。
其他評估過的形態:**共用 npm 套件** → 要多發一個 package + token,且安裝器得先 `npm i` 才能判斷,
自舉問題只是換個位置;**做成一顆零件** → 得用 TinyGo/AssemblyScript 重寫一次,那正是「第二份實作」。
---
## 3. 檔案
| 檔案 | 內容 |
|---|---|
| `rule.mjs` | 規則本體:`planResources` / `applyResourcePlan` / `parseWranglerRequirements` 把 CF 回應讀成事實的 `normalizeLiveBindings` / `normalizeLiveVars` |
| `cf-resource-api.mjs` | `ResourceApi` 的 CF REST 實作(只用 global `fetch`)。**眼睛也要共用**——見下 §5 |
| `installer-entry.mjs` | 安裝器唯一該碰的入口:`resolveInstanceResources()` |
| `tests/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+三種情境 |
| `tests/demo.mjs` | `node shared/resource-rule/tests/demo.mjs`——零依賴、零建置就能跑的示範 |
🔴 **零依賴是硬規則**:只准 import 同目錄的兄弟檔,不准碰 `node:*`
有外部依賴就會有某條路吃不到它。`cli/tests/single-implementation.test.ts` ③ 會擋。
---
## 4. 兩條路怎麼取用
### 安裝器 / 任何 Worker(不需要副本)
安裝器本來就會下載本 repo 的 archive 當部署來源(`.claude/rules/05-deploy-convention.md`
「WASM 來源」),`shared/resource-rule/` 就在那份 archive 裡:
```js
import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
const r = await resolveInstanceResources({
accountId, apiToken,
wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // toml 的「內容」,不是路徑
mode: isUpdate ? 'update' : 'init',
});
if (r.blocked) {
// 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
return showAndStop(r.blockers);
}
// r.bindings : { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
// r.origin : { 'kv_namespace:WEBHOOKS': 'adopted' | 'created', … }
// r.liveVars : { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } } ← #106
```
**安裝器不准自己判斷要不要建資源**,也不准自己解讀 CF 的 binding 回應。只呼叫這一支。
### `acr` CLI(需要一份鏡射)
`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒ 套件裡必須自帶一份。
`cli/src/lib/resource-rule/` 就是本目錄的**逐位元組鏡射**,由
`node scripts/sync-resource-rule.mjs` 產生。
**要改規則就改這個目錄,然後重跑 sync。** 手改鏡射會被擋下:
`npm run build``npm test` 都先跑 `sync-resource-rule.mjs --check`
差一個位元組就 exit 1(同 `cli/harness/` 的產生物+世代閘慣例)。
---
## 5. 為什麼連 CF client 也共用
判斷一致還不夠,**看到的東西**也要一致。
「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的。
兩條路各自寫一份 client,只要有一邊把 404 當錯誤、漏了 `per_page`、少認一種欄位名
`namespace_id` vs `id`),那一邊就會「看不到既有綁定」——
而看不到既有綁定的下一步,依規則就是**新建**。
**Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。**
所以 `cli/src/lib/cf-api.ts``CfAccountClient``ResourceApi` 那七個方法**全部委派**
`cf-resource-api.mjs`,自己不留實作。
---
## 6. 驗收
```bash
cd cli && npm test # 58 項,含下列三組
node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑
```
| 測試 | 證的事 |
|---|---|
| `cli/tests/two-paths-agree.test.ts` | 同一個帳號狀態餵給 `acr` 那條與安裝器那條,**選出的 resource id 相同**、建的東西相同、停手的理由相同 |
| `cli/tests/single-implementation.test.ts` | ①規則的 7 支函式全 repo 只有這裡有實作 ②鏡射逐位元組相同 ③共用層零依賴 |
| `cli/tests/resource-adoption.test.ts` | #97 本身的迴歸(沿用/不多建/四種停手情境),改共用層後照樣全過 |
三種情境(`tests/fixture-account.mjs``SCENARIOS`):
- `fresh` — 沒裝過 → **正常建新的**(不能為了沿用而變成永遠不建)
- `installed` — 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在
- `renamed`**資源在但名字與預期完全不同** → 仍然沿用(#97 的病根,專門驗)
---
## 7. 相關
- `Arcrun#97` — 「我按了更新,工作流和登入全不見了」:CLI 那條已修,本目錄是把同一條規則交給所有路徑
- `Arcrun#106` — 重部署把 `plain_text` var(含版本標籤)洗掉:`liveVars` 就是那些標籤
- `Arcrun#80` / `arcrun-rag#39` — 同一個「重複做 Arcrun 的工作」家族;Arcrun 是唯一編譯點的既有慣例
- `.claude/rules/07-thin-shell.md` — 本目錄存在的依據
+202
View File
@@ -0,0 +1,202 @@
// @ts-check
/**
* cf-resource-api.mjs 規則的**眼睛與手** Cloudflare 帳號的那七個動作也只有一份
*
* `rule.mjs` 是純判斷IO 由呼叫端注入`ResourceApi`本檔就是那個注入物的正貨
* CF REST API 實作 `ResourceApi`零依賴只用 global `fetch`
* Node 18+ Cloudflare Workers runtime 都能直接跑
*
* 為什麼連這層也要共用
* 判斷一致還不夠**看到的東西**也要一致
* 已部署的 worker 綁著什麼是從 `GET /workers/scripts/{script}/settings` 讀來的
* 如果兩條路各自寫一份 client隨便一個差異打錯端點 404 當錯誤漏了 per_page
* 少認一種欄位名都會讓其中一條路看不到既有綁定而看不到既有綁定的下一步
* 依規則就是**新建**Arcrun#97 的災情不需要規則寫錯只要眼睛不一樣就會重演
*
* 這裡**故意只有 `ResourceApi` 那七個方法**verifyAccess / subdomain / KV 讀寫
* 這些跟該用哪些資源無關的帳號操作留在各自的呼叫端不往共用層堆
*
* 🔴 除了同目錄的 `./rule.mjs`這支不准 import 任何東西共用層的價值在於
* 整個目錄複製到哪個 runtime 都能直接跑多一個外部依賴就少一條路吃得到
*/
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
*/
/**
* @typedef {object} CfResourceApiOptions
* @property {string} accountId
* @property {string} apiToken
* @property {typeof globalThis.fetch} [fetch]
* 注入用離線測試餵假帳號或宿主要用自己的 fetch預設 global fetch
*/
/**
* 建一個打真實 Cloudflare `ResourceApi`
*
* @param {CfResourceApiOptions} options
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
*/
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
const doFetch = fetchImpl ?? globalThis.fetch;
if (typeof doFetch !== 'function') {
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
}
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
const headers = {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
/**
* HTTP status 交回呼叫端自己判斷要區分404 不存在其他錯誤時用
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error:
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
}
/**
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<any>}
*/
async function cf(path, init) {
const { ok, status, result, error } = await cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result;
}
return {
cfRaw,
/**
* 讀一顆已部署 worker 現在綁著哪些資源**使用者那側的事實**Arcrun#97 的唯一真相源
*
* - script 不存在404 `{ deployed: false }`這是還沒部署不是錯誤
* - 其他任何失敗 throw呼叫端必須把它當我不知道**不是**它沒有
* 把查不到當成不存在就是 #97 的根因
*
* @param {string} script
* @returns {Promise<ScriptBindings>}
*/
async getScriptBindings(script) {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await cfRaw(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
/** @type {RawWorkerBinding[]} */
const raw = res.result?.bindings ?? [];
return {
deployed: true,
bindings: normalizeLiveBindings(raw),
vars: normalizeLiveVars(raw),
};
},
/** @returns {Promise<Map<string, string>>} title → id */
async listKvNamespaces() {
/** @type {Array<{id: string, title: string}>} */
const result = await cf('/storage/kv/namespaces?per_page=100');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
},
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
},
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
},
/**
* 無條件新建一顆 KV namespace
*
* 🔴 Arcrun#97這裡**故意沒有**找不到同名就順手建一顆 ensure 版本
* 照名字找 找不到 新建 綁上去正是把使用者實例洗成空的那條路
* 安裝器取的名字跟 binding 名不一樣永遠對不上 每次更新都新建
* 要不要建一律先過 `planResources`
*
* @param {string} title
* @returns {Promise<string>}
*/
async createKvNamespace(title) {
const result = await cf('/storage/kv/namespaces', {
method: 'POST',
body: JSON.stringify({ title }),
});
return result.id;
},
/**
* 無條件新建 D1沒有 ensure 版本理由同 createKvNamespaceArcrun#97
* @param {string} name
* @returns {Promise<string>}
*/
async createD1Database(name) {
const result = await cf('/d1/database', {
method: 'POST',
body: JSON.stringify({ name }),
});
return result.uuid;
},
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 / cosine**
* 已存在409 / already exists視為成功並行或重跑不該炸
* 沒有 ensure 版本要不要建 planResources 判斷這裡只負責建Arcrun#97
*
* @param {string} name
* @returns {Promise<string>}
*/
async createVectorizeIndex(name) {
const res = await cfRaw('/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}`);
},
};
}
+100
View File
@@ -0,0 +1,100 @@
// @ts-check
/**
* installer-entry.mjs 安裝器那條路的**唯一入口**
*
* 安裝器arcrun-rag `installer/oauth-prototype/worker.js`不必也不准自己判斷
* 該建哪些資源它只要呼叫這一支拿回每個 binding 該用哪顆資源
*
* ```js
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
*
* const r = await resolveInstanceResources({
* accountId, apiToken,
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
* mode: isUpdate ? 'update' : 'init',
* });
* if (r.blocked) {
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
* return showAndStop(r.blockers);
* }
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
* ```
*
* 為什麼安裝器不需要副本安裝器本來就會下載本 repo archive 當部署來源
* `.claude/rules/05-deploy-convention.md`WASM 來源
* `shared/resource-rule/` 就在那份 archive 直接 import 即可
* **不必再編一次不必貼一份也就不會有第二種答案**
*/
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
/**
* @typedef {object} ResolveOptions
* @property {string} accountId
* @property {string} apiToken
* @property {string[]} wranglerTomls worker wrangler.toml **內容**不是路徑
* @property {'update' | 'init'} mode 這台照定義裝過了沒
* @property {typeof globalThis.fetch} [fetch] 注入用測試宿主自帶 fetch
*/
/**
* @typedef {object} ResolveResult
* @property {boolean} blocked true = 什麼都沒建什麼都不該部署
* @property {string[]} blockers blocked 時的原因原文要原樣轉給使用者
* @property {Record<string, string>} bindings `${kind}:${binding}` 資源 idindex
* @property {Record<string, 'adopted'|'created'>} origin 同上 key 這顆是沿用還是新建
* @property {Record<string, Record<string, string>>} liveVars script 現有 plain_text var#106
*/
/**
* 決定這台實例每個 binding 該用哪顆資源照規則沿用既有只在確定沒人綁過時才新建
*
* @param {ResolveOptions} options
* @returns {Promise<ResolveResult>}
*/
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
/** @type {import('./rule.mjs').BindingRequirement[]} */
const requirements = [];
for (const toml of wranglerTomls) {
const parsed = parseWranglerRequirements(toml);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
/** @param {string[]} blockers @returns {ResolveResult} */
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
if (requirements.length === 0) {
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
}
let plan;
try {
plan = await planResources(api, requirements, mode);
} catch (e) {
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
}
if (plan.blockers.length > 0) return stop(plan.blockers);
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
let resolved;
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
}
/** @type {Record<string, string>} */
const bindings = {};
/** @type {Record<string, 'adopted'|'created'>} */
const origin = {};
for (const [key, r] of resolved) {
bindings[key] = r.value;
origin[key] = r.origin;
}
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
}
+570
View File
@@ -0,0 +1,570 @@
// @ts-check
/**
* rule.mjs 這個實例該用哪些資源**唯一一份**規則
*
*
* 這份檔案為什麼在這裡`shared/`不在 `cli/`
*
* leo 2026-08-12根本就不應該在 CLI我要的是一個大家都可以用到的規則
*
* `.claude/rules/07-thin-shell.md` 的判準口訣
* 這段邏輯換一個介面要不要重寫要重寫 它是能力該在共用層
*
* 該沿用哪幾顆資源換到安裝器就得重寫一次 它是**能力**不是薄殼的事
* 而它原本住在 `cli/src/lib/resource-resolver.ts` 那本身就是違規
* 後果也真的發生了`acr` 那條有這條規則安裝器那條沒有於是安裝器照名字找
* 找不到就建新的空的 Arcrun#97我按了更新工作流和登入全不見了
*
* 為什麼不是 cypher-executor API 端點薄殼原則的標準答案
* **自舉**這條規則要在決定怎麼裝怎麼更新的當下就用得到而那個當下
* cypher 可能還不存在安裝器的工作正是把它生出來或正要被覆蓋
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**
* 把它送去一顆平台託管的 worker 換一個答案等於①讓能不能安裝綁在平台是否活著
* ②把使用者的帳號拓撲交給第三方兩件都不該為了形式上的漂亮而做
*
* 薄殼原則要求的是能力只實作一次不是能力一定要是 HTTP
* 這條規則是**純函式**唯一的 IO 由呼叫端注入 `ResourceApi`
* 所以它用不著變成服務一份零依賴的 ESM 就能讓每條路吃到同一份判斷
*
* 怎麼讓兩條路吃到同一份而不是各留一份
* 本檔是**唯一被人手維護的實作**零依賴不吃任何 node 內建Workers runtime 可直接跑
* · `acr``cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**
* `scripts/sync-resource-rule.mjs` 產生CLI 要能單獨 npm publish
* 套件目錄外的檔案打不進 tarball故必須有這一份
* `npm run build` / `npm test` 都會跑 `--check`內容一漂就紅
* `cli/harness/`產生物世代閘的既有慣例
* · 安裝器 / 任何 Worker安裝器本來就會下載本 repo archive部署來源
* `.claude/rules/05-deploy-convention.md`WASM 來源
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可**不需要再編一次也不留副本**
* 用法見同目錄 README.md
*
*
* 規則本身leo 的兩句話
*
* 如果你沒有裝就是新的如果你已經有原來叫什麼名字就繼續用下去
*
* 判準是**這顆 worker 現在綁著誰**不是有沒有叫這個名字的資源
* 1. **已部署的 worker 上綁著什麼那就是事實** 原封不動沿用不管那顆資源叫什麼名字
* 2. **只有確定沒有任何人綁過它才准新建**新版本新增的 binding或真的全新帳號
* 3. **只要有一點說不準就整趟停手**讀不到綁定綁著的資源不見了同一個 binding 指向兩顆
* 該更新的 worker 一顆都不在**什麼都不建什麼都不部署**把話說清楚讓人來判斷
*
* 為什麼拆成 plan / apply 兩段
* `planResources()` **完全不寫入**只回一份要沿用什麼要新建什麼有什麼不敢動的
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行
* 被擋下的時候一顆資源都不會被建出來**結構上的保證**
* 不是靠某個人記得在對的地方寫 early return#97 正是死在先動手後判斷
*
* 🔴 這份檔案沒有 import也不准有任何依賴都會讓某一條路吃不到它
*/
/**
* 這支負責的資源種類要加新種類R2/Queue/Hyperdrive就加在這裡
* 一律走同一道門不准任何呼叫端自己照名字 ensure繞過去
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
*/
/**
* 從已部署 worker 上讀回來的一條綁定`value`KV/D1 是資源 idVectorize index
* @typedef {object} LiveBinding
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
*/
/**
* @typedef {object} ScriptBindings
* @property {boolean} deployed
* false = 這顆 worker 在帳號上還不存在全新部署不是讀取失敗讀取失敗要 throw
* @property {LiveBinding[]} bindings
* @property {Record<string, string>} [vars]
* 這顆 worker 現在掛著的 `plain_text` var
*
* 🔴 Arcrun#106#97 只把資源類綁定當成事實沿用KV/D1/Vectorize
* plain_text var 整批沒人管 重部署把它們洗成 repo toml 的預設值
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`安裝器注入的版本標籤
* 更新完就消失Portal 設定頁變成無法讀取目前版本
* **保留了櫃子沒保留櫃子上的標籤**這個欄位就是那些標籤
*/
/**
* 規則需要的 CF 能力收窄成介面方便離線測試餵假帳號也讓安裝器用自己的 fetch 實作
* @typedef {object} ResourceApi
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
* @property {() => Promise<Map<string, string>>} listKvNamespaces title id
* @property {() => Promise<Map<string, string>>} listD1Databases name uuid
* @property {() => Promise<string[]>} listVectorizeIndexes
* @property {(title: string) => Promise<string>} createKvNamespace
* @property {(name: string) => Promise<string>} createD1Database
* @property {(name: string) => Promise<string>} createVectorizeIndex
*/
/**
* 這顆 worker 需要這個 bindingcreateName 只在**真的要新建**時才會被拿來當名字用
* @typedef {object} BindingRequirement
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} worker 需要它的 worker script = wrangler.toml `name`
* @property {string} createName
*/
/**
* @typedef {object} PlannedAdopt
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
*/
/**
* @typedef {object} PlannedCreate
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} createName
* @property {string[]} wantedBy
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding shareSameResource建一顆大家共用
*/
/**
* @typedef {object} ResourcePlan
* @property {PlannedAdopt[]} adopt
* @property {PlannedCreate[]} create
* @property {string[]} blockers 非空 = 整趟停手applyResourcePlan 會拒絕執行
* @property {Map<string, Record<string, string>>} liveVars
* 每顆**已部署** worker 現在掛著的 plain_text varscript /未部署的不在裡面
*
* Arcrun#106讀綁定的時候本來就把整份 `bindings[]` 拿回來了var 就在同一份回應裡
* 順手帶出來**不另外打一次 API**也不新增一種查不到的失敗模式
* 讀不到綁定這件事已經在上面 blockers 那一關擋掉了
*/
/**
* @typedef {object} ResolvedResource
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* @property {string} [from]
*/
/**
* @typedef {object} WranglerRequirements
* @property {string} script worker script toml 頂層 `name`空字串 = 這份 toml 沒宣告 name不該發生
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
*/
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
/** @param {string[]} blockers */
constructor(blockers) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
/** @type {string[]} */
this.blockers = blockers;
}
}
/**
* @param {ResourceKind} kind
* @param {string} binding
* @returns {string}
*/
export function bindingKey(kind, binding) {
return `${kind}:${binding}`;
}
/** @type {Record<ResourceKind, string>} */
export const KIND_LABEL = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
/**
* @param {unknown} e
* @returns {string}
*/
function msg(e) {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源要不要新建**不寫入任何東西**
*
* @param {ResourceApi} api
* @param {readonly BindingRequirement[]} requirements
* @param {'update' | 'init'} mode
* 'update' = 這台照定義已經裝過了見下方一顆都不在規則'init' = 全新安裝允許從零建
* @returns {Promise<ResourcePlan>}
*/
export async function planResources(api, requirements, mode) {
/** @type {string[]} */
const blockers = [];
/** @type {PlannedAdopt[]} */
const adopt = [];
/** @type {PlannedCreate[]} */
const create = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
/** @type {Map<string, LiveBinding[]>} */
const live = new Map();
/** @type {Map<string, Record<string, string>>} */
const liveVars = new Map();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) {
live.set(script, res.bindings);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} 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 決定:沿用 / 新建 / 停手 ─────────────────────────
/** @type {Map<string, BindingRequirement[]>} */
const byKey = new Map();
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]);
}
/** @type {Map<ResourceKind, Set<string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = async (kind) => {
const hit = existingCache.get(kind);
if (hit) return hit;
/** @type {Set<string>} */
let set;
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];
/** @type {Array<{value: string, script: string}>} */
const found = [];
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];
/** @type {Set<string>} */
let existing;
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, liveVars };
}
/**
* 收斂不同 binding 其實是同一顆資源的情況
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`不是使用者那側的資源名
* cypher `CREDENTIALS_DB` kbdb `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告這兩個綁定指向同一顆庫 #97 那種拿名字去猜使用者的資源是兩回事
*
* 沒有這一步會出兩種錯
* 全新安裝時建出兩顆同名 D1KBDB 的資料與 credential 目錄從此分家
* 一邊已部署沿用既有另一邊沒有新建一顆空的 半套資料比全壞更難查
*
* @param {PlannedAdopt[]} adopt
* @param {PlannedCreate[]} create
* @param {Map<string, BindingRequirement[]>} byKey
* @returns {PlannedCreate[]}
*/
function shareSameResource(adopt, create, byKey) {
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
const declaredName = (kind, binding) =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
/** @type {PlannedCreate[]} */
const out = [];
/** @type {Map<string, PlannedCreate>} */
const groups = new Map();
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**一顆都不建**
*
* @param {ResourceApi} api
* @param {ResourcePlan} plan
* @returns {Promise<Map<string, ResolvedResource>>}
*/
export async function applyResourcePlan(api, plan) {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
/** @type {Map<string, ResolvedResource>} */
const out = new Map();
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,
});
}
/** @type {string[]} */
const madeSoFar = [];
for (const c of plan.create) {
/** @type {string} */
let value;
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 → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
/**
* wrangler.toml table 資源種類需求解析與注入共用同一張表兩邊才不會對不上
* @type {Record<string, ResourceKind>}
*/
export const TABLE_KIND = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* wrangler.toml 抽出這顆 worker 需要哪些資源綁定
*
* 刻意寫成行掃描而不引 TOML parser注入端injectWranglerConfig本來就是純文字操作
* 兩邊用同一種視角看這份檔案才不會對不上註解掉的區塊**不算需求**
* kbdb `[[vectorize]]` 預設是註解狀態要開語義查詢時才會被取消註解 那時才成為需求
*
* 也是零依賴的一部分不引 TOML parser 安裝器 import 這支不必多裝任何東西
*
* @param {string} toml
* @returns {WranglerRequirements}
*/
export function parseWranglerRequirements(toml) {
let script = '';
let seenTable = false;
/** @type {WranglerRequirements['bindings']} */
const bindings = [];
/** @type {ResourceKind | null} */
let kind = null;
let binding = '';
let createName = '';
const flush = () => {
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 };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
// ─────────────────────────────────────────────────────────────────────────────
/**
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
* 同一種資源在不同 API 版本欄位名不一故全都收
*
* @typedef {object} RawWorkerBinding
* @property {string} [type]
* @property {string} [name]
* @property {string} [namespace_id]
* @property {string} [id]
* @property {string} [database_id]
* @property {string} [index_name]
* @property {string} [text] `plain_text` 綁定的值#106secret_text 不會回值本來就讀不到也不該讀
*/
/**
* CF binding 陣列收斂成規則認得的三種資源不認得的型別直接略過
*
* 🔴 這支**刻意放在規則裡**不留在各自的 CF client
* 什麼才算這顆 worker 綁著某顆資源是規則的一部分
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來例如一邊認 `namespace_id`
* 另一邊只認 `id`於是一邊看得到綁定另一邊看不到 後者又去新建了
*
* @param {RawWorkerBinding[]} raw
* @returns {LiveBinding[]}
*/
export function normalizeLiveBindings(raw) {
/** @type {LiveBinding[]} */
const out = [];
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;
}
/**
* 抽出已部署 worker 上的 `plain_text` var#106
*
* 只收 `plain_text`**`secret_text` 一律不碰**CF 本來就不回值也不該被搬來搬去
* wrangler deploy 不會動 secret它們自己會留著
*
* @param {RawWorkerBinding[]} raw
* @returns {Record<string, string>}
*/
export function normalizeLiveVars(raw) {
/** @type {Record<string, string>} */
const out = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}
+60
View File
@@ -0,0 +1,60 @@
// @ts-check
/**
* demo.mjs 安裝器那條路的**可獨立執行**證明
*
* node shared/resource-rule/tests/demo.mjs
*
* 這支只 import `shared/resource-rule/`**沒有 node_modules沒有建置步驟**
* 跑得起來本身就是安裝器把 repo archive 拉下來就能直接用這句話的證據
* 對照組`acr` 那條要先 npm ci + TS 轉譯才跑得動兩條路差在外殼判斷是同一份
*
* 三種情境各跑一次印出每個 binding 選到哪顆資源以及這一趟建了幾顆
*/
import { resolveInstanceResources } from '../installer-entry.mjs';
import { makeAccount, SCENARIOS, WORKER_NEEDS } from './fixture-account.mjs';
/** 用 fixture 的需求組出各 worker 的 wrangler.toml 內容。 */
function tomls() {
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
for (const d of need.d1) {
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
}
return t;
});
}
const order = /** @type {const} */ (['fresh', 'installed', 'renamed']);
console.log('安裝器那條路(只 import shared/resource-rule/,零依賴、零建置)\n');
for (const scenario of order) {
const mode = scenario === 'fresh' ? 'init' : 'update';
const account = makeAccount(scenario);
const r = await resolveInstanceResources({
accountId: 'acct-demo',
apiToken: 'tok-demo',
wranglerTomls: tomls(),
mode,
fetch: account.fetch,
});
console.log(`── ${scenario}mode=${mode}):${SCENARIOS[scenario].label}`);
if (r.blocked) {
console.log(' ⛔ 停手,一顆資源都沒建:');
for (const b of r.blockers) console.log(`${b}`);
console.log('');
continue;
}
for (const key of Object.keys(r.bindings).sort()) {
console.log(` ${key.padEnd(30)}${r.bindings[key].padEnd(26)} ${r.origin[key]}`);
}
console.log(
` 本趟新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆、` +
`Vectorize ${account.created.vectorize.length}` +
`|沿用既有版本標籤 ARCRUN_BUNDLE_VERSION=` +
`${r.liveVars['arcrun-cypher-executor']?.ARCRUN_BUNDLE_VERSION ?? '(無,全新安裝)'}\n`,
);
}
@@ -0,0 +1,202 @@
// @ts-check
/**
* fixture-account.mjs 一個假的 Cloudflare 帳號做成 **`fetch` 替身**
*
* 為什麼是 fetch 替身不是假的 ResourceApi 物件
* 本票要證的是`acr` 那條與安裝器那條跑出來的決定必須一致
* 如果兩條路各自餵一個假的 `ResourceApi`那就只測到了 `rule.mjs` 的判斷
* **完全跳過了怎麼把 CF 回應讀成事實** Arcrun#97 的重演只需要眼睛不一樣就夠了
* 一邊把 404 當錯誤一邊漏認 `namespace_id`
* `fetch` 這一層假起兩條路就是真的走完整條鏈HTTP 解析 判斷
*
* 零依賴 ESMNode Workers 都能跑
*/
/** arcrun 各 worker 在 wrangler.toml 裡宣告的 KV binding 名(= 需求,不是資源名)。 */
export const KV_BINDINGS = [
'WEBHOOKS', 'CREDENTIALS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV',
'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', 'OAUTH_KV',
];
/** 這台實例上有資源綁定的四顆 worker,以及各自需要的綁定。 */
export const WORKER_NEEDS = {
'arcrun-cypher-executor': {
kv: ['EXEC_CONTEXT', 'WEBHOOKS', 'CREDENTIALS_KV', 'ANALYTICS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV'],
d1: [{ binding: 'CREDENTIALS_DB', database_name: 'arcrun-kbdb' }],
},
'arcrun-registry': { kv: ['SUBMISSIONS_KV', 'ANALYTICS_KV'], d1: [] },
'arcrun-mcp': { kv: ['OAUTH_KV'], d1: [] },
'arcrun-kbdb': { kv: [], d1: [{ binding: 'DB', database_name: 'arcrun-kbdb' }] },
};
/**
* WORKER_NEEDS 攤成 `BindingRequirement[]`兩條路都用**同一份需求**進去
* 才能證明差異如果有來自實作而不是輸入
* @returns {Array<{kind: 'kv_namespace'|'d1', binding: string, worker: string, createName: string}>}
*/
export function requirements() {
const out = [];
for (const [worker, need] of Object.entries(WORKER_NEEDS)) {
for (const b of need.kv) out.push({ kind: 'kv_namespace', binding: b, worker, createName: b });
for (const d of need.d1) {
out.push({ kind: 'd1', binding: d.binding, worker, createName: d.database_name });
}
}
return out;
}
/**
* 三種情境`titleFor` 決定使用者帳號上那顆資源實際叫什麼名字
* 這正是 #97 的病根所在規則**不准**拿名字當識別
*
* @typedef {'fresh' | 'installed' | 'renamed'} Scenario
*/
/** @type {Record<Scenario, {label: string, deployed: boolean, titleFor: (binding: string) => string}>} */
export const SCENARIOS = {
fresh: {
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
deployed: false,
titleFor: (b) => b,
},
installed: {
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>',
deployed: true,
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
},
renamed: {
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
deployed: true,
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
},
};
/**
* 建一個假帳號 + 對應的 `fetch` 替身
*
* @param {Scenario} scenario
* @returns {{
* fetch: typeof globalThis.fetch,
* created: {kv: string[], d1: string[], vectorize: string[]},
* userData: {workflows: string[], sessions: string[], libraries: string[]},
* kvIdFor: (binding: string) => string | undefined,
* d1Id: string,
* requestLog: string[],
* }}
*/
export function makeAccount(scenario) {
const spec = SCENARIOS[scenario];
/** title → id */
const kv = new Map();
/** name → uuid */
const d1 = new Map();
/** @type {string[]} */
const vectorize = [];
/** script → CF `/settings` 回應裡的 bindings[] 原始形狀 */
const scripts = new Map();
const created = { kv: [], d1: [], vectorize: [] };
const requestLog = [];
// 使用者的東西——驗「更新完還在不在」用。掛在資源 id 上,不是掛在名字上。
const userData = {
workflows: ['webhook:leo:daily-digest', 'webhook:leo:inbox-sync', 'webhook:leo:rag-ingest'],
sessions: ['session:leo-abc123'],
libraries: ['general', '課程', '客戶', '研究'],
};
const kvIdByBinding = new Map();
const D1_ID = 'd1id-kbdb-REAL';
if (spec.deployed) {
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
for (const b of KV_BINDINGS) {
const id = `kvid-${b.toLowerCase()}-REAL`;
kv.set(spec.titleFor(b), id);
kvIdByBinding.set(b, id);
}
d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID);
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
const bindings = [];
for (const b of need.kv) {
bindings.push({ type: 'kv_namespace', name: b, namespace_id: kvIdByBinding.get(b) });
}
for (const d of need.d1) bindings.push({ type: 'd1', name: d.binding, id: D1_ID });
// #106plain_text var 也在同一份回應裡
bindings.push({ type: 'plain_text', name: 'ARCRUN_BUNDLE_VERSION', text: '1.4.33' });
scripts.set(script, bindings);
}
}
/** @param {unknown} result @param {number} [status] */
const ok = (result, status = 200) =>
new Response(JSON.stringify({ success: true, result, errors: [] }), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** @param {string} message @param {number} status */
const fail = (message, status) =>
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** @type {typeof globalThis.fetch} */
// @ts-expect-error — 測試替身只實作用得到的那幾條路徑
const fakeFetch = async (input, init) => {
const url = new URL(typeof input === 'string' ? input : String(input));
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
const method = (init?.method ?? 'GET').toUpperCase();
requestLog.push(`${method} ${path}${url.search}`);
const body = init?.body ? JSON.parse(String(init.body)) : null;
// 已部署 worker 的綁定
const m = path.match(/^\/workers\/scripts\/([^/]+)\/settings$/);
if (m && method === 'GET') {
const script = decodeURIComponent(m[1]);
if (!scripts.has(script)) return fail('workers.api.error.script_not_found', 404);
return ok({ bindings: scripts.get(script) });
}
if (path === '/storage/kv/namespaces' && method === 'GET') {
return ok([...kv].map(([title, id]) => ({ id, title })));
}
if (path === '/storage/kv/namespaces' && method === 'POST') {
const id = `kvid-NEW-${created.kv.length + 1}`;
kv.set(body.title, id);
created.kv.push(body.title);
return ok({ id, title: body.title });
}
if (path === '/d1/database' && method === 'GET') {
return ok([...d1].map(([name, uuid]) => ({ uuid, name })));
}
if (path === '/d1/database' && method === 'POST') {
const uuid = `d1id-NEW-${created.d1.length + 1}`;
d1.set(body.name, uuid);
created.d1.push(body.name);
return ok({ uuid, name: body.name });
}
if (path === '/vectorize/v2/indexes' && method === 'GET') {
return ok(vectorize.map((name) => ({ name })));
}
if (path === '/vectorize/v2/indexes' && method === 'POST') {
vectorize.push(body.name);
created.vectorize.push(body.name);
return ok({ name: body.name });
}
return fail(`fixture 沒有實作這條路徑:${method} ${path}`, 501);
};
return {
fetch: fakeFetch,
created,
userData,
kvIdFor: (binding) => kvIdByBinding.get(binding),
d1Id: D1_ID,
requestLog,
};
}