Compare commits

..

13 Commits

Author SHA1 Message Date
uncle6me-web 45a546a686 fix(cli): 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。
使用者的資源是安裝器建的(`arcrun-rag-<x>-kv-webhooks`),更新找的是 `WEBHOOKS`
⇒ 找不到 ⇒ 新建一顆空的並綁上去。

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

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

📍 repo:matrix/arcrun(cli/)|📍 票:Leo/Arcrun#97
2026-08-12 13:30:17 +08:00
uncle6me-web e69d6bbc03 chore(worker-builds): 重編——kbdb 的跳脫修法要進執行檔才算數(Arcrun#94)
今天已經在這件事上被咬過一次(8e10f1d):源碼改了、執行檔沒重編,
出貨會全綠地送出舊的。這次併完就重編,不留同一個坑。

kbdb source 525faaf → c497ec4(sha256 7bc66656 → ffb8d434)。
其餘四顆源碼沒動、位元組也沒動。
2026-08-12 12:22:10 +08:00
uncle6me-web b302c03ea8 merge: 搜尋框打的 % 和 _ 是字,不是萬用字元(Arcrun#94)
總管逐筆審過兩筆,並自己跑了測試(那條線被權限閘擋住沒能執行):
  前後對照  查 100% 改前撈回「共有 100 個待辦項目」→ 改後只回真的含 100% 的
            查 owner_id 改前連 ownerXid 也中 → 改後只回字面命中
            單打 % 或 _ 改前整個庫都回來 → 改後只回字面命中
  反向驗證  拿掉跳脫 → 8 failed(紅的正是病徵本身)
            拿掉 ESCAPE → 5 failed(含既有 search-long-query 兩條)
  既有測試  18 檔 197 pass → 19 檔 213 pass

它連帶處理了一個我沒想到的:跳脫會讓 pattern 變長,所以長度上限改用「跳脫後」的
長度算——否則打 48 個 % 會產生 98 bytes 的 pattern,退回 08-03 修掉的那個 HTTP 500。

誠實標記:只在本機真 SQLite 上驗過,沒對任何線上實例跑過。
同款漏洞另有兩處未動(library-map.ts:169、library-backfill.ts:75-76,吃維運端前綴參數)。
2026-08-12 12:21:48 +08:00
uncle6me-web c497ec418e test(kbdb): 用真 SQLite 釘住 #94 的前後對照與跳脫邊界
只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
用 node:sqlite(與 library-map/embed-backfill 同款治具)跑真 SQL,每組驗收同時跑
「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡。

守五件事:
  ① 前後對照   —— 100% / owner_id / 單打符號,改前撈回不相干的、改後只回字面命中
  ② 跳脫邊界   —— `\` 不跳脫會漏掉真正的 `C:\`,也會讓 `_` 的跳脫失效、萬用字元漏回來
  ③ ESCAPE 齊全 —— 沒有任何 `content LIKE ?` 是裸的(漏一個就等於那條路沒修)
  ④ 不退化     —— 不含 % _ \ 的查詢 pattern 逐字不變,最熱路徑仍是單一 LIKE
  ⑤ byte 預算  —— 滿是 % 的長查詢仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復)

反向驗證(把修法拿掉,兩半分別驗,兩半都是承重的):
  拿掉跳脫      → 8 failed / 204 passed,紅的正是病徵本身
  拿掉 ESCAPE   → 5 failed / 207 passed(含既有 search-long-query 2 條)
  復原後        → 19 檔 213 pass(基線 18 檔 197 pass)
2026-08-12 11:59:36 +08:00
uncle6me-web 6985bf4850 fix(kbdb): 搜尋框打的 % 和 _ 是字,不是萬用字元(Arcrun#94)
使用者在搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字無關的東西。

病根:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有
兩個萬用字元(`%` 任意長度、`_` 任意一字)且**沒有預設跳脫字元**——不寫
ESCAPE 就沒有任何辦法表示「字面上的 %」。所以他打的符號被當成 pattern 語法:

    `100%`     → `%100%%`  → 「100」後面接什麼都算 ⇒ 撈回一堆不相干的
    `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ ownerXid 也中
    單打 `%`/`_` → `%%%`/`%_%` → 整個庫都回來

舊病,不是 08-10 斷詞(#84)引進的:pattern 從來就是這樣拼的。之前關鍵字搜尋
幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
斷詞那段一個字都沒動。

修法:pattern 產生點全部收斂到兩支 helper——
  · escapeLikeLiteral():跳脫 `%` `_` `\` 三個字元
  · CONTENT_LIKE 常數:每個 `content LIKE ?` 一律帶 `ESCAPE '\'`

為什麼跳脫字元本身(`\`)也要處理:宣告 ESCAPE 之後 `\` 就變成 pattern 裡有
意義的字元,而且它會把後面那個字吃掉、且不報錯——打 `C:\` 會變成去找 `C:%`
(真正的 `C:\` 反而漏掉);只跳脫 %/_ 而漏掉 `\`,打 `C:\_temp` 時 `\\` 先被
讀成字面 `\`、後面的 `_` 變回萬用字元 ⇒ 撈到 `C:\Xtemp`。三個是一組的。
`[` `]` `?` `*` 不需要跳脫(那是 GLOB/別的方言,LIKE 不吃),多跳只會白白吃掉
pattern 的 byte 預算。

連帶:跳脫會變長(`%`→`\%`),所以 50 bytes 上限改用「跳脫後」的長度算
(likeBytes),否則打 48 個 `%` 會產生 98 bytes 的 pattern ⇒ 退回 2026-08-03
修掉的那個 HTTP 500。不含這三個字元的查詢 likeBytes ≡ utf8Len ⇒ 既有查詢逐字不變。

search-long-query.test.ts 兩條「逐字比對謂詞字串」的斷言跟著新字串更新——改的是
比對用的常數、不是放寬檢查(仍逐字相等比對),pattern 本身一個字都沒變。

沒有引進第三種搜尋機制,沒有動資料層(三表不變),沒有動斷詞。
2026-08-12 11:59:36 +08:00
uncle6me-web 8e10f1d83e chore(worker-builds): 重編官方成品——kbdb/cypher-executor 的修法之前只在源碼裡
為什麼要有這一筆:`.worker-builds/` 是**出貨與安裝真正拿去部署的執行檔**,
而它記的 source_commit 在剛併完 #85/#88 之後對不上源碼:

    arcrun-cypher-executor  成品記的 797e7f7  源碼已經是 525faaf  ⚠️
    arcrun-kbdb             成品記的 a7e23ba  源碼已經是 3eb8b31  ⚠️

⇒ 這時候去出貨或 `init`,送出去的是**舊的執行檔**,測試全綠也沒用
(wiki/mistakes.md「修的東西在執行檔裡,改完源碼+測試綠 ≠ 送到用戶手上」)。

🔴 build-bundles.mjs 的「落後閘」抓不到這個:它比的是「.worker-builds 這個目錄有沒有
落後 main 的 commit」,不是「成品記的 source_commit 有沒有落後那顆 worker 的源碼」。
這次是人工比對才發現的。修這道閘另開票。

重編後:cypher-executor source=525faaf、kbdb source=3eb8b31,兩顆的新程式碼都在產物裡
(kbdb 找得到 embed_backfill_usage/backfillEntryLibraryTags,cypher 找得到 builtin 那條路)。

誠實標記:另外三顆(code/http_request/mcp)源碼沒動,位元組卻變了——是 esbuild
版本漂移換了它產生的 helper(`__esm` 多了一段 try/catch)。這正是 #77 記的
「build 不可跨機器重現」那個結構性斷點的實例。
2026-08-12 09:50:50 +08:00
uncle6me-web 3eb8b31f2b merge: 補算向量的節奏、額度、世代核對+標庫共用同一顆閘(Arcrun#85/D68/D69)
總管逐筆審過 1d6dde4/674e1b4/37e13fc:全部落在 kbdb/,沒有新表(額度用量寄生在
既有 entries 表單一列,D38)、沒有動實例。標庫與時間分層共用一套 SelectionCriteria
與同一顆每日 D1 額度計數器——兩者若各記各的,其中一個會把另一個的閘繞過去。
實測:kbdb 全套 196 pass / 0 fail(含反向驗證:拿掉 cap 那個 case 會變紅)。

紅線仍在:這只是併進 main,還沒部署到任何實例。
2026-08-12 09:40:30 +08:00
uncle6me-web 8cee9c9f76 merge: /cypher/search 不再誤報執行期原生零件 not_found(Arcrun#88)
總管逐筆審過 525faaf:改動只在 cypher-executor 的 search-nodes.ts(查 registry 前先比對
執行期白名單)與 component-loader.ts(把既有三份白名單的聯集匯出),沒有新清單、
沒有掃目錄灌死碼。實測基準線:本分支 357 pass / 14 fail,main 350 pass / 14 fail
——同樣的 14 個既有失敗(portal-data 等 5 檔),新增的 7 個全綠。
2026-08-12 09:40:18 +08:00
uncle6me-web 7e3ca4c1a1 Revert "WIP(kbdb): 語意搜尋零命中的排查——⚠️ 被總管中途叫停,未完成驗證"
This reverts commit af3edff856.
2026-08-11 23:18:38 +08:00
uncle6me-web af3edff856 WIP(kbdb): 語意搜尋零命中的排查——⚠️ 被總管中途叫停,未完成驗證
leo 2026-08-11 判斷「如果是 Vectorize 沒完成就不用查了」,總管據此停線。
真因已經寫在 repo 自己的註解裡(kbdb/wrangler.toml:43-51,Arcrun#11):
metadata index 只收「建立後 upsert」的向量,既有向量須 reindex,
否則帶 owner_id filter 一律 0 命中——與實測每一格吻合
(805 筆在、關鍵字搜得到、語意 0、拿自己查自己也 0 ⇒ 不是分數門檻)。

⚠️ 這批改動是排查途中的產物,**沒有走完驗證**,不要當成可用的修法。
保留只是不讓它憑空消失(總管中斷造成,不是它做壞)。
接手的人請先讀 Arcrun#85 上的結論再決定要不要用。

真正的補救是 reindex,而 reindex 要燒 AI 額度
⇒ 卡在 Arcrun#85 的每日額度閘上線之後才能做。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:05:20 +08:00
uncle6me-web c5d696556e 保管:#89/#90/#91 卡在人類閘前的產物,從 session 暫存目錄搶進版控
三樣都做完並實測過,但落地的最後一步是終端機裡等人親手打字的互動閘。
它們原本只存在於某個 session 的 scratchpad——那種目錄一關就沒了。

· recipes/gitea_put_file.yaml     出貨線 7 站等它
· recipes/cf_worker_deploy_simple.yaml  ⚠️ 只適用無 bindings 的簡單情形(見 #90)
· hash-component/                 sha256/sha1/md5,已與系統原生指令逐位元核對
  (.wasm 是 1.3MB 編譯產物,不進版控,README 附重編指令)

README 寫了落地指令與各自的注意事項。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:38:55 +08:00
uncle6me-web 5919c6b90f fix(kbdb): 藏書地圖讀端每次都觸發重算寫 D1(Arcrun#87 止血)
liveTripletCountsByLibrary 沒有過濾 status,recomputeLibraryMap 只算
COALESCE(status,'active')='active'——兩邊判準不一致,只要庫裡有一筆
superseded triplet 就永遠判定 stale,導致每次讀地圖(GET /map、GET /map/:library、
kbdb_get_map MCP 工具)都觸發重算並新建一筆 library_map record,無止盡寫 D1,
且加劇既有的非原子 supersede 競態(kb 44 筆全 superseded、notes 兩筆同時 active
即 arcrun-rag#50 的共同根因之一)。

修法:liveTripletCountsByLibrary 的 SQL 改成 pivot 出 status 再套用與
recomputeLibraryMap 逐字一致的過濾,兩邊判準對齊。

新增迴歸測試釘住此 bug(反向驗證:跑在修前的 SQL 上會失敗,非空氣測試);
獨立用 leo21c MCP 連線連讀兩次 kbdb_get_map() 重現修前症狀
(general 庫 updated_at 從 1786457080 前進到 1786457114,中間無寫入)。

kbdb/tests/library-map.test.ts 19/19 全綠。既有殘骸(100 筆 library_map record)
未清——目前沒有可用的 DELETE 通道,待部署後另行處理。

kbdb-sql-ok:liveTripletCountsByLibrary 的 .prepare 呼叫是牆內本體
(kbdb/src/actions/),本次 checkout 開在巢狀 worktree
matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的
matrix/arcrun 主 checkout),kbdb-api-wall-guard.sh 的字面路徑比對
*matrix/arcrun/kbdb/src/* 吃不到中間多出的 worktree 目錄層,非真的繞牆。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:16:30 +08:00
uncle6me-web 525faaf5d0 fix(cypher-executor): /cypher/search 不再誤報執行期原生零件 not_found(Arcrun#88)
病因:/cypher/search 只查 component registry(SUBMISSIONS_KV,經 submitComponent/
index-only 才有記錄);而 component-loader.ts 能直接解析、從不查 registry 的一整類
零件(trigger_workflow/BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS,
如 if_control/http_request/switch)從未被 submit 過。leo21c 實例實測:
GET /components/catalog 回 404「零件 catalog 不存在」,search 因此對這些零件誠實地
回「兩庫都查過沒有」——但它們其實跑得動(leo 08-11 探測工作流已證)。

修法:從 component-loader.ts 匯出 RUNTIME_NATIVE_COMPONENT_IDS(既有三份執行期
解析白名單的聯集,非新清單),search-nodes.ts 在查 registry 之前先比對,
不受 registry 是否可達/是否已 backfill 影響。真正不存在的零件仍誠實回
not_found/unknown,not_found 的分型建議與相似候選機制不變。

刻意不做:不掃描 registry/components/* 目錄當清單來源——那含已標記待刪的死碼
(km_writer/kbdb_upsert_block),07-30 曾把這類死碼誤灌進 registry;也不投資
SUBMISSIONS_KV 的 backfill 腳本——decisions-summary.md D29 已定調 SUBMISSIONS_KV
併入「KV 退休戰」,不宜再加投資。

測試:cypher-executor/tests/search-nodes-runtime-native.test.ts 7 case 全綠,
複現 registry unreachable/registry 可達但目錄空(leo21c 實例的真實症狀)兩種情境。
全 suite 迴歸:357 pass(較修前 350 pass 多 7 個新測試),既有 14 個失敗與修前
數量、內容完全相同(pre-existing,與本次改動無關)。
2026-08-11 21:51:21 +08:00
28 changed files with 2718 additions and 317 deletions
+19 -15
View File
@@ -1,7 +1,12 @@
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
var __esm = (fn, res, err2) => function __init() {
if (err2) throw err2[0];
try {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
} catch (e) {
throw err2 = [e], e;
}
};
var __export = (target, all) => {
for (var name in all)
@@ -102,7 +107,7 @@ function applyBaseRuntimeOptions(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);
}
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({
"registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() {
init_dist();
@@ -190,7 +195,7 @@ var init_chunk_JTKJZQYV = __esm({
return this.dispose();
}
};
SymbolDispose = Symbol.dispose ?? Symbol.for("Symbol.dispose");
SymbolDispose = Symbol.dispose ?? /* @__PURE__ */ Symbol.for("Symbol.dispose");
prototypeAsAny = UsingDisposable.prototype;
prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() {
return this.dispose();
@@ -409,7 +414,6 @@ Lifetime used`) : new QuickJSUseAfterFree("Lifetime not alive");
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 });
QuickJSIterator = class extends UsingDisposable {
constructor(handle, context) {
@@ -773,7 +777,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
}
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 }`;
}
getFunction(fn_id) {
@@ -909,7 +913,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
debugLog(...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 }`;
}
getSystemContext() {
@@ -1339,10 +1343,10 @@ async function QuickJSRaw(moduleArg = {}) {
x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m];
}
b = a(...g);
return b = function(k) {
return b = (function(k) {
0 !== h && sa(h);
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 });
K();
@@ -1464,7 +1468,7 @@ async function QuickJSRaw(moduleArg = {}) {
}, t: function(a, d) {
c.callbacks.freeHostRef(void 0, a, d);
} }, Z;
Z = await async function() {
Z = await (async function() {
function a(b) {
b = Z = b.exports;
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;
return a((await ea(d)).instance);
}();
})();
(function() {
function a() {
c.calledRun = true;
@@ -3034,7 +3038,7 @@ var Hono = class _Hono {
var emptyParam = [];
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -3046,7 +3050,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -4008,7 +4012,7 @@ app.post("/", async (c) => {
);
}
});
var code_default = app;
var index_default = app;
export {
code_default as default
index_default as default
};
@@ -1,7 +1,12 @@
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
var __esm = (fn, res, err) => function __init() {
if (err) throw err[0];
try {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
} catch (e) {
throw err = [e], e;
}
};
var __export = (target, 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
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -1513,7 +1518,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -3022,7 +3027,7 @@ async function readBodyOnce(res) {
return text;
}
}
var WASM_HTTP_RUNNER_IDS, LOGIC_BINDING_MAP;
var WASM_HTTP_RUNNER_IDS, LOGIC_BINDING_MAP, RUNTIME_NATIVE_COMPONENT_IDS;
var init_component_loader = __esm({
"cypher-executor/src/lib/component-loader.ts"() {
"use strict";
@@ -3065,6 +3070,12 @@ var init_component_loader = __esm({
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
};
RUNTIME_NATIVE_COMPONENT_IDS = /* @__PURE__ */ new Set([
"trigger_workflow",
...BUILTIN_COMPONENTS.keys(),
...Object.keys(LOGIC_BINDING_MAP),
...WASM_HTTP_RUNNER_IDS
]);
}
});
@@ -3201,6 +3212,20 @@ var init_kbdb_proxy = __esm({
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param("recordId"))}`, { headers });
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
});
kbdbProxyRouter.patch("/kbdb/records/:recordId", async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || typeof body.values !== "object" || body.values === null) {
return c.json({ error: "values \u5FC5\u586B\uFF08{slot\u540D: \u5167\u5BB9}\uFF09" }, 400);
}
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param("recordId"))}`, {
method: "PATCH",
headers,
body: JSON.stringify({ values: body.values })
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
});
kbdbProxyRouter.get("/kbdb/search", async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
@@ -7355,7 +7380,7 @@ var init_lib = __esm({
...processCreateParams(params)
});
};
BRAND = Symbol("zod_brand");
BRAND = /* @__PURE__ */ Symbol("zod_brand");
ZodBranded = class extends ZodType {
_parse(input) {
const { ctx } = this._processInputParams(input);
@@ -7529,14 +7554,14 @@ var init_lib = __esm({
onumber = () => numberType().optional();
oboolean = () => booleanType().optional();
coerce = {
string: (arg) => ZodString.create({ ...arg, coerce: true }),
number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
boolean: (arg) => ZodBoolean.create({
string: ((arg) => ZodString.create({ ...arg, coerce: true })),
number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
boolean: ((arg) => ZodBoolean.create({
...arg,
coerce: true
}),
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
date: (arg) => ZodDate.create({ ...arg, coerce: true })
})),
bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
};
NEVER = INVALID;
z = /* @__PURE__ */ Object.freeze({
@@ -7757,6 +7782,7 @@ var init_recipe_loader = __esm({
super(message);
this.recipe = recipe;
}
recipe;
};
}
});
@@ -8763,7 +8789,7 @@ function recordRecipeStats(env, recipeKeys, ok, at, ctx) {
)
).then(() => void 0);
if (ctx?.waitUntil) ctx.waitUntil(promise);
else ;
else void promise;
}
function generateToken() {
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
@@ -8805,7 +8831,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
result.trace
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else ;
else void statsPromise;
}
return { success: true, data: result.data, duration_ms };
} catch (err) {
@@ -8829,7 +8855,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
err.trace
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else ;
else void statsPromise;
}
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map((s) => ({
@@ -9294,6 +9320,7 @@ function extractTarget(input) {
return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2);
}
async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) {
void nodes;
try {
const { base, headers } = kbdbBase(env);
await fetch(`${base}/execution-log/record`, {
@@ -9485,6 +9512,16 @@ async function searchNodes(parsed, config, env, mode = "discover", target) {
nodeResults[nodeName] = { status: "found", componentId, type: role };
continue;
}
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
nodeResults[nodeName] = {
status: "found",
componentId,
type: role,
source: "builtin",
branch_hint: branchHintFor(componentId)
};
continue;
}
if (catalog.status === "unreachable") {
nodeResults[nodeName] = { status: "unknown", componentId, type: role };
continue;
@@ -15257,10 +15294,10 @@ app.route("/", consoleAuthRouter);
app.route("/", consoleDashboardRouter);
app.route("/", portalRouter);
app.route("/", portalDataRouter);
var src_default = {
var index_default = {
fetch: app.fetch,
scheduled: handleScheduled
};
export {
src_default as default
index_default as default
};
@@ -1427,7 +1427,7 @@ var Hono = class _Hono {
var emptyParam = [];
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -1439,7 +1439,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -2522,7 +2522,7 @@ app.post("/", async (c) => {
);
}
});
var src_default = app;
var index_default = app;
async function runWasm(input) {
const hostFunctions = {
http_request: async (url, method, headersJson, body) => {
@@ -2568,5 +2568,5 @@ async function runWasm(input) {
return JSON.parse(stdout);
}
export {
src_default as default
index_default as default
};
+390 -32
View File
@@ -1444,7 +1444,7 @@ var Hono = class _Hono {
var emptyParam = [];
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -1456,7 +1456,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -2189,11 +2189,18 @@ async function deprecateEntriesByLibrary(db, ownerId, library) {
var MAX_LIKE_Q_BYTES = 48;
var MAX_LIKE_TERMS = 6;
var utf8Len = (s) => new TextEncoder().encode(s).length;
var LIKE_ESCAPE = "\\";
var CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
function escapeLikeLiteral(s) {
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
}
var likeBytes = (s) => utf8Len(escapeLikeLiteral(s));
var likePattern = (s) => `%${escapeLikeLiteral(s)}%`;
function chunkByBytes(s, maxBytes) {
const out = [];
let cur = "";
for (const ch of s) {
if (utf8Len(cur + ch) > maxBytes) {
if (likeBytes(cur + ch) > maxBytes) {
if (cur) out.push(cur);
cur = ch;
} else {
@@ -2204,8 +2211,8 @@ function chunkByBytes(s, maxBytes) {
return out;
}
function buildContentLike(q) {
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
return { conds: ["content LIKE ?"], params: [`%${q}%`], split: false };
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
}
const terms = [];
for (const word of q.split(/\s+/).filter(Boolean)) {
@@ -2217,8 +2224,8 @@ function buildContentLike(q) {
}
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? "");
return {
conds: terms.map(() => "content LIKE ?"),
params: terms.map((t) => `%${t}%`),
conds: terms.map(() => CONTENT_LIKE),
params: terms.map(likePattern),
split: true
};
}
@@ -2335,7 +2342,7 @@ function buildSearchScore(q) {
if (terms.length === 0) {
const m = buildContentLike(trimmed);
return {
scoreExpr: m.conds.map(() => "CASE WHEN content LIKE ? THEN 1 ELSE 0 END").join(" + "),
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(" + "),
scoreParams: m.params,
terms: [],
legacyShape: true
@@ -2344,14 +2351,14 @@ function buildSearchScore(q) {
const parts = [];
const params = [];
for (const { term, weight } of terms) {
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
params.push(`%${term}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
params.push(likePattern(term));
}
const single = terms.length === 1 && terms[0].term === trimmed;
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
const bonus = terms.reduce((s, t) => s + t.weight, 0);
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
params.push(`%${trimmed}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
params.push(likePattern(trimmed));
}
return { scoreExpr: parts.join(" + "), scoreParams: params, terms, legacyShape: single };
}
@@ -2408,6 +2415,57 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s
return applyRelativeCut(res.results ?? []);
}
// kbdb/src/actions/maintenance-quota.ts
var DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 2e4;
function maintenanceDailyLimit(env) {
const raw2 = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
const n = raw2 ? parseInt(raw2, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
}
function utcDay() {
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
}
function maintenanceUsageId() {
return `kbdb-maintenance-usage:${utcDay()}`;
}
async function getMaintenanceUsageToday(db) {
const row = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(maintenanceUsageId()).first();
if (!row) return 0;
try {
const parsed = row.metadata_json ? JSON.parse(row.metadata_json) : {};
return Number(parsed.writes) || 0;
} catch {
return 0;
}
}
async function addMaintenanceUsage(db, by) {
if (by <= 0) return;
const id = maintenanceUsageId();
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
let prev = 0;
if (existing) {
try {
const parsed = existing.metadata_json ? JSON.parse(existing.metadata_json) : {};
prev = Number(parsed.writes) || 0;
} catch {
prev = 0;
}
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay(), writes: prev + by }), id).run();
} else {
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'kbdb_maintenance_usage', ?)`).bind(id, JSON.stringify({ day: utcDay(), writes: by })).run();
}
}
async function maintenanceBudgetToday(env, db) {
const limit = maintenanceDailyLimit(env);
let used = 0;
try {
used = await getMaintenanceUsageToday(db);
} catch {
used = 0;
}
return { limit, used, remaining: Math.max(0, limit - used) };
}
// kbdb/src/embed.ts
var DEFAULT_EMBED_MODEL = "@cf/baai/bge-m3";
var MIN_SCORE_ABS_FLOOR = 0.45;
@@ -2449,7 +2507,7 @@ async function embedOnWrite(env, entry) {
}
}
]);
await env.DB.prepare("UPDATE entries SET is_embedded = 1 WHERE id = ?").bind(entry.id).run();
await env.DB.prepare("UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?").bind(embedModel(env), entry.id).run();
return true;
}
function isEmbeddable(entry) {
@@ -2476,12 +2534,47 @@ function parseMeta(json) {
}
}
var BACKFILL_PREDICATE = "is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
async function backfillEmbeddings(env, opts = {}) {
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
const offset = Math.max(opts.offset ?? 0, 0);
const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE;
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
var DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
function backfillDailyLimit(env) {
const raw2 = env.EMBED_BACKFILL_DAILY_LIMIT;
const n = raw2 ? parseInt(raw2, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
}
function utcDay2() {
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
}
function backfillUsageId() {
return `embed-backfill-usage:${utcDay2()}`;
}
async function getBackfillUsageToday(db) {
const row = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(backfillUsageId()).first();
if (!row) return 0;
try {
const parsed = row.metadata_json ? JSON.parse(row.metadata_json) : {};
return Number(parsed.embedded) || 0;
} catch {
return 0;
}
}
async function addBackfillUsage(db, by) {
if (by <= 0) return;
const id = backfillUsageId();
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
let prev = 0;
if (existing) {
try {
const parsed = existing.metadata_json ? JSON.parse(existing.metadata_json) : {};
prev = Number(parsed.embedded) || 0;
} catch {
prev = 0;
}
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay2(), embedded: prev + by }), id).run();
} else {
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`).bind(id, JSON.stringify({ day: utcDay2(), embedded: by })).run();
}
}
function selectionCriteriaPredicate(opts) {
const conds = [];
const params = [];
if (opts.owner_id) {
conds.push("owner_id = ?");
@@ -2491,12 +2584,55 @@ async function backfillEmbeddings(env, opts = {}) {
conds.push("json_extract(metadata_json, '$.source') = ?");
params.push(opts.source);
}
if (opts.library) {
conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?");
params.push(opts.library);
}
if (typeof opts.since === "number") {
conds.push("created_at >= ?");
params.push(opts.since);
}
if (typeof opts.until === "number") {
conds.push("created_at < ?");
params.push(opts.until);
}
return { conds, params };
}
async function backfillEmbeddings(env, opts = {}) {
if (!embedEnabled(env)) {
return {
enabled: false,
processed: 0,
skipped: 0,
remaining: 0,
scanned: 0,
quota_limit: 0,
quota_used_today: 0,
quota_exceeded: false
};
}
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
const offset = Math.max(opts.offset ?? 0, 0);
const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE;
const sel = selectionCriteriaPredicate(opts);
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
const params = [...sel.params];
const where = conds.join(" AND ");
const res = await env.DB.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
const res = await env.DB.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
const rows = res.results ?? [];
const scanned = rows.length;
const dailyCap = backfillDailyLimit(env);
let usedToday = 0;
try {
usedToday = await getBackfillUsageToday(env.DB);
} catch {
usedToday = 0;
}
const remainingQuota = Math.max(0, dailyCap - usedToday);
let processed = 0;
const embeddable = rows.filter((e) => (e.content ?? "").trim().length > 0);
const candidates = rows.filter((e) => (e.content ?? "").trim().length > 0);
const embeddable = candidates.slice(0, remainingQuota);
const quotaExceeded = candidates.length > embeddable.length;
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
const texts = embeddable.map((e) => (e.content ?? "").trim());
const out = await env.AI.run(embedModel(env), { text: texts });
@@ -2516,14 +2652,27 @@ async function backfillEmbeddings(env, opts = {}) {
await env.VECTORIZE.upsert(vectors);
const ids = vectors.map((v) => v.id);
const placeholders = ids.map(() => "?").join(",");
await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run();
await env.DB.prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`).bind(embedModel(env), ...ids).run();
processed = vectors.length;
try {
await addBackfillUsage(env.DB, processed);
} catch {
}
}
}
const remRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
const totalMatching = remRow?.c ?? 0;
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
return { enabled: true, processed, skipped: scanned - processed, remaining, scanned };
return {
enabled: true,
processed,
skipped: scanned - processed,
remaining,
scanned,
quota_limit: dailyCap,
quota_used_today: usedToday + processed,
quota_exceeded: quotaExceeded
};
}
async function backfillStatus(env, opts = {}) {
const conds = [];
@@ -2541,6 +2690,74 @@ async function backfillStatus(env, opts = {}) {
const embeddedRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE is_embedded = 1 AND json_extract(metadata_json, '$.embed') = 1${extra}`).bind(...params).first();
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
}
async function reconcileEmbedGeneration(env, opts = {}) {
if (!embedEnabled(env)) {
return {
enabled: false,
checked: 0,
confirmed_current: 0,
reset_to_pending: 0,
remaining: 0,
scanned: 0,
quota_limit: 0,
quota_used_today: 0,
quota_exceeded: false
};
}
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
const currentModel = embedModel(env);
const sel = selectionCriteriaPredicate(opts);
const conds = [
"is_embedded = 1",
"(content_hash IS NULL OR content_hash != ?)",
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
...sel.conds
];
const params = [currentModel, ...sel.params];
const where = conds.join(" AND ");
const res = await env.DB.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`).bind(...params, limit).all();
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
const budget = await maintenanceBudgetToday(env, env.DB);
const ids = scannedIds.slice(0, budget.remaining);
const quotaExceeded = scanned > ids.length;
const checked = ids.length;
let confirmed_current = 0;
let reset_to_pending = 0;
if (ids.length > 0 && env.VECTORIZE) {
const found = await env.VECTORIZE.getByIds(ids);
const foundIds = new Set(found.map((v) => v.id));
const presentIds = ids.filter((id) => foundIds.has(id));
const missingIds = ids.filter((id) => !foundIds.has(id));
if (presentIds.length > 0) {
const ph = presentIds.map(() => "?").join(",");
await env.DB.prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`).bind(currentModel, ...presentIds).run();
confirmed_current = presentIds.length;
}
if (missingIds.length > 0) {
const ph = missingIds.map(() => "?").join(",");
await env.DB.prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`).bind(...missingIds).run();
reset_to_pending = missingIds.length;
}
}
const remRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
const written = confirmed_current + reset_to_pending;
try {
await addMaintenanceUsage(env.DB, written);
} catch {
}
return {
enabled: true,
checked,
confirmed_current,
reset_to_pending,
remaining: remRow?.c ?? 0,
scanned,
quota_limit: budget.limit,
quota_used_today: budget.used + written,
quota_exceeded: quotaExceeded
};
}
async function embedSelfTest(env, opts = {}) {
if (!embedEnabled(env)) {
return { enabled: false, tested: false, passed: null, note: "embed \u6A21\u7D44\u672A\u958B\uFF08\u7F3A Vectorize/AI binding\uFF09\uFF0C\u8A9E\u7FA9\u641C\u5C0B\u9019\u689D\u8DEF\u76EE\u524D\u4E0D\u5B58\u5728" };
@@ -2647,6 +2864,90 @@ async function migrateLegacyCredentialsForOwner(db, ownerId) {
return (after?.n ?? 0) - (before?.n ?? 0);
}
// kbdb/src/actions/library-backfill.ts
var MAX_PAGE_NAMES = 300;
var HARD_LIMIT_CAP = 500;
function criteriaPredicate(c) {
const conds = [
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')"
];
const params = [];
if (c.owner_id) {
conds.push("owner_id = ?");
params.push(c.owner_id);
}
if (c.entry_type) {
conds.push("entry_type = ?");
params.push(c.entry_type);
}
if (c.page_names && c.page_names.length > 0) {
const names = c.page_names.slice(0, MAX_PAGE_NAMES);
conds.push(`page_name IN (${names.map(() => "?").join(",")})`);
params.push(...names);
}
if (c.source_prefix) {
conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'");
params.push(c.source_prefix);
}
if (c.page_name_prefix) {
conds.push("page_name LIKE ? || '%'");
params.push(c.page_name_prefix);
}
if (typeof c.since === "number") {
conds.push("created_at >= ?");
params.push(c.since);
}
if (typeof c.until === "number") {
conds.push("created_at < ?");
params.push(c.until);
}
return { conds, params };
}
async function backfillEntryLibraryTags(db, env, opts) {
const library = (opts.library ?? "").trim();
if (!library) throw new Error("library required");
const ownerId = (opts.owner_id ?? "").trim();
if (!ownerId) throw new Error("owner_id required\uFF08\u6A19\u5EAB\u662F\u8DE8\u5927\u91CF\u65E2\u6709\u8CC7\u6599\u7684\u6279\u6B21\u5BEB\u5165\uFF0C\u4E0D\u51C6\u7121\u79DF\u6236\u7BC4\u570D\u5730\u6383\u5168\u5EAB\u2014\u20142026-08-11 leo \u76F4\u4EE4\uFF09");
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
const sel = criteriaPredicate({ ...opts, owner_id: ownerId });
const where = sel.conds.join(" AND ");
const params = sel.params;
const res = await db.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`).bind(...params, limit).all();
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
const budget = await maintenanceBudgetToday(env, db);
const ids = scannedIds.slice(0, budget.remaining);
const quotaExceeded = scanned > ids.length;
let tagged = 0;
if (ids.length > 0) {
const ph = ids.map(() => "?").join(",");
await db.prepare(
`UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`
).bind(library, ...ids).run();
tagged = ids.length;
}
try {
await addMaintenanceUsage(db, tagged);
} catch {
}
const remRow = await db.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
return {
library,
scanned,
tagged,
remaining: remRow?.c ?? 0,
quota_limit: budget.limit,
quota_used_today: budget.used + tagged,
quota_exceeded: quotaExceeded
};
}
async function libraryBackfillStatus(db, opts = {}) {
const sel = criteriaPredicate(opts);
const where = sel.conds.join(" AND ");
const row = await db.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...sel.params).first();
return { pending: row?.c ?? 0 };
}
// kbdb/src/routes/entries.ts
var entryRoutes = new Hono2();
function fireAndForget(c, p) {
@@ -2873,6 +3174,39 @@ entryRoutes.patch("/deprecate-by-library", async (c) => {
}
return c.json({ success: true, deprecated_count: count, vectors_deleted });
});
entryRoutes.post("/backfill-library", async (c) => {
const body = await c.req.json().catch(() => ({}));
const library = String(body.library ?? "").trim();
const ownerId = String(body.owner_id ?? "").trim();
if (!library || !ownerId) return c.json({ success: false, error: "library \u8207 owner_id \u5FC5\u586B" }, 400);
try {
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
library,
owner_id: ownerId,
entry_type: body.entry_type || void 0,
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : void 0,
source_prefix: body.source_prefix || void 0,
page_name_prefix: body.page_name_prefix || void 0,
since: body.since !== void 0 ? Number(body.since) : void 0,
until: body.until !== void 0 ? Number(body.until) : void 0,
limit: body.limit !== void 0 ? Number(body.limit) : void 0
});
return c.json({ success: true, ...result });
} catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
entryRoutes.get("/backfill-library/status", async (c) => {
const status = await libraryBackfillStatus(c.env.DB, {
owner_id: c.req.query("owner_id") || void 0,
entry_type: c.req.query("entry_type") || void 0,
source_prefix: c.req.query("source_prefix") || void 0,
page_name_prefix: c.req.query("page_name_prefix") || void 0,
since: c.req.query("since") ? Number(c.req.query("since")) : void 0,
until: c.req.query("until") ? Number(c.req.query("until")) : void 0
});
return c.json({ success: true, ...status });
});
entryRoutes.patch("/:id", async (c) => {
const body = await c.req.json().catch(() => ({}));
const entry = await updateEntry(c.env.DB, c.req.param("id"), body);
@@ -3192,6 +3526,9 @@ embedRoutes.post("/backfill", async (c) => {
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
owner_id: body.owner_id || void 0,
source: body.source || void 0,
library: body.library || void 0,
since: body.since !== void 0 ? Number(body.since) : void 0,
until: body.until !== void 0 ? Number(body.until) : void 0,
// reindexArcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
reindex: body.reindex === true,
offset: body.offset !== void 0 ? Number(body.offset) : void 0
@@ -3205,6 +3542,23 @@ embedRoutes.get("/backfill/status", async (c) => {
});
return c.json({ success: true, ...status });
});
embedRoutes.post("/reconcile", async (c) => {
if (!embedEnabled(c.env)) {
return c.json(
{ success: false, error: "embed module not enabled (need VECTORIZE + AI bindings)", capability_hint: OFF_HINT },
409
);
}
const body = await c.req.json().catch(() => ({}));
const result = await reconcileEmbedGeneration(c.env, {
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
owner_id: body.owner_id || void 0,
library: body.library || void 0,
since: body.since !== void 0 ? Number(body.since) : void 0,
until: body.until !== void 0 ? Number(body.until) : void 0
});
return c.json({ success: true, ...result });
});
embedRoutes.get("/selftest", async (c) => {
const result = await embedSelfTest(c.env, { owner_id: c.req.query("owner_id") || void 0 });
return c.json({ success: true, ...result });
@@ -3402,14 +3756,18 @@ async function recomputeLibraryMap(db, input) {
async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db.prepare(
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT DISTINCT ev.record_id
SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id
) AS tr
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
WHERE COALESCE(tr.status, 'active') = 'active'
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`
).bind(...params).all();
const m = /* @__PURE__ */ new Map();
@@ -3558,7 +3916,7 @@ function dailyLimit(env) {
const n = raw2 ? parseInt(raw2, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
}
function utcDay() {
function utcDay3() {
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
}
function truncate(s, max) {
@@ -3566,7 +3924,7 @@ function truncate(s, max) {
return s.slice(0, Math.max(0, max - 1)) + "\u2026";
}
async function checkUsage(db, limit) {
const id = `exlog-usage:${utcDay()}`;
const id = `exlog-usage:${utcDay3()}`;
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
let count;
if (existing) {
@@ -3578,10 +3936,10 @@ async function checkUsage(db, limit) {
prevWrites = 0;
}
count = prevWrites + 1;
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay(), writes: count }), id).run();
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay3(), writes: count }), id).run();
} else {
count = 1;
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`).bind(id, JSON.stringify({ day: utcDay(), writes: count })).run();
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`).bind(id, JSON.stringify({ day: utcDay3(), writes: count })).run();
}
if (count > limit) return "skip";
if (count > limit * DEGRADE_RATIO) return "log_failure_only";
@@ -3797,7 +4155,7 @@ app.route("/recipe-stats", recipeStatRoutes);
app.route("/execution-log", executionLogRoutes);
app.route("/embed", embedRoutes);
app.route("/map", mapRoutes);
var src_default = app;
var index_default = app;
export {
src_default as default
index_default as default
};
+37 -34
View File
@@ -5,7 +5,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
try {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
} catch (e) {
throw mod = 0, e;
}
};
var __export = (target, all) => {
for (var name in all)
@@ -8223,7 +8227,7 @@ var Hono = class _Hono {
var emptyParam = [];
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -8235,7 +8239,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -12954,7 +12958,7 @@ ZodNaN.create = (params) => {
...processCreateParams(params)
});
};
var BRAND = Symbol("zod_brand");
var BRAND = /* @__PURE__ */ Symbol("zod_brand");
var ZodBranded = class extends ZodType {
_parse(input) {
const { ctx } = this._processInputParams(input);
@@ -13156,14 +13160,14 @@ var ostring = () => stringType().optional();
var onumber = () => numberType().optional();
var oboolean = () => booleanType().optional();
var coerce = {
string: (arg) => ZodString.create({ ...arg, coerce: true }),
number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
boolean: (arg) => ZodBoolean.create({
string: ((arg) => ZodString.create({ ...arg, coerce: true })),
number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
boolean: ((arg) => ZodBoolean.create({
...arg,
coerce: true
}),
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
date: (arg) => ZodDate.create({ ...arg, coerce: true })
})),
bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
};
var NEVER = INVALID;
@@ -13214,7 +13218,6 @@ function $constructor(name, initializer3, params) {
Object.defineProperty(_, "name", { value: name });
return _;
}
var $brand = Symbol("zod_brand");
var $ZodAsyncError = class extends Error {
constructor() {
super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`);
@@ -15718,8 +15721,6 @@ function en_default2() {
}
// 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 {
constructor() {
this._map = /* @__PURE__ */ new Map();
@@ -16997,10 +16998,10 @@ var ZodMiniType = /* @__PURE__ */ $constructor("ZodMiniType", (inst, def) => {
};
inst.clone = (_def, params) => clone(inst, _def, params);
inst.brand = () => inst;
inst.register = (reg, meta) => {
inst.register = ((reg, meta) => {
reg.add(inst, meta);
return inst;
};
});
});
var ZodMiniObject = /* @__PURE__ */ $constructor("ZodMiniObject", (inst, def) => {
$ZodObject.init(inst, def);
@@ -17263,10 +17264,10 @@ var ZodType2 = /* @__PURE__ */ $constructor("ZodType", (inst, def) => {
};
inst.clone = (def2, params) => clone(inst, def2, params);
inst.brand = () => inst;
inst.register = (reg, meta) => {
inst.register = ((reg, meta) => {
reg.add(inst, meta);
return inst;
};
});
inst.parse = (data, params) => parse2(inst, data, params, { callee: inst.parse });
inst.safeParse = (data, params) => safeParse3(inst, data, params);
inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync });
@@ -19238,11 +19239,13 @@ function assertCompleteRequestPrompt(request) {
if (request.params.ref.type !== "ref/prompt") {
throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`);
}
void request;
}
function assertCompleteRequestResourceTemplate(request) {
if (request.params.ref.type !== "ref/resource") {
throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`);
}
void request;
}
var CompleteResultSchema = ResultSchema.extend({
completion: looseObject({
@@ -19395,7 +19398,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
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 = {
name: void 0,
$refStrategy: "root",
@@ -22371,7 +22374,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
var COMPLETABLE_SYMBOL = Symbol.for("mcp.completable");
var COMPLETABLE_SYMBOL = /* @__PURE__ */ Symbol.for("mcp.completable");
function isCompletable(schema4) {
return !!schema4 && typeof schema4 === "object" && COMPLETABLE_SYMBOL in schema4;
}
@@ -24686,13 +24689,13 @@ function registerAllIntrospectionTools(server, env) {
}
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/nodes/identity.js
var ALIAS = Symbol.for("yaml.alias");
var DOC = Symbol.for("yaml.document");
var MAP = Symbol.for("yaml.map");
var PAIR = Symbol.for("yaml.pair");
var SCALAR = Symbol.for("yaml.scalar");
var SEQ = Symbol.for("yaml.seq");
var NODE_TYPE = Symbol.for("yaml.node.type");
var ALIAS = /* @__PURE__ */ Symbol.for("yaml.alias");
var DOC = /* @__PURE__ */ Symbol.for("yaml.document");
var MAP = /* @__PURE__ */ Symbol.for("yaml.map");
var PAIR = /* @__PURE__ */ Symbol.for("yaml.pair");
var SCALAR = /* @__PURE__ */ Symbol.for("yaml.scalar");
var SEQ = /* @__PURE__ */ Symbol.for("yaml.seq");
var NODE_TYPE = /* @__PURE__ */ Symbol.for("yaml.node.type");
var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
@@ -24722,9 +24725,9 @@ function isNode(node) {
var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor;
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/visit.js
var BREAK = Symbol("break visit");
var SKIP = Symbol("skip children");
var REMOVE = Symbol("remove node");
var BREAK = /* @__PURE__ */ Symbol("break visit");
var SKIP = /* @__PURE__ */ Symbol("skip children");
var REMOVE = /* @__PURE__ */ Symbol("remove node");
function visit(node, visitor) {
const visitor_ = initVisitor(visitor);
if (isDocument(node)) {
@@ -29317,9 +29320,9 @@ ${end.comment}` : end.comment;
};
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/parse/cst-visit.js
var BREAK2 = Symbol("break visit");
var SKIP2 = Symbol("skip children");
var REMOVE2 = Symbol("remove item");
var BREAK2 = /* @__PURE__ */ Symbol("break visit");
var SKIP2 = /* @__PURE__ */ Symbol("skip children");
var REMOVE2 = /* @__PURE__ */ Symbol("remove item");
function visit2(cst, visitor) {
if ("type" in cst && cst.type === "document")
cst = { start: cst.start, value: cst.value };
@@ -33389,7 +33392,7 @@ app.post("/", partnerAuthMiddleware, async (c) => {
const partnerToken = c.get("partner_token");
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
});
var src_default = _app;
var index_default = _app;
export {
src_default as default
index_default as default
};
+14 -14
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-11T05:33:37.988Z",
"repo_head": "d8bbf2241bd6b117d76fb27d9e386ecfb0ffe8f7",
"generated_at": "2026-08-12T04:21:48.581Z",
"repo_head": "b302c03ea8076bcfe82bbcebb1523dcec2d1e830",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 568855,
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
"js_bytes": 570290,
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -58,11 +58,11 @@
{
"name": "arcrun-kbdb",
"source_dir": "kbdb",
"source_commit": "a7e23badf2a771be779a861e69e7efa6e8141dfe",
"source_commit": "c497ec418eba6cd94b1d5872671c51fd5812c11c",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 135910,
"content_sha256": "5e5a7a030f4fd1f5549ace6791c3827b6497b0bfdd9add46af041af47c472905",
"js_bytes": 149533,
"content_sha256": "ffb8d43467d0cefbd7545fdc0d347f2b965e3c3de20b3315eed7613f20266891",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -90,8 +90,8 @@
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
"main_module": "worker.mjs",
"main_file": "arcrun-http-request/worker.mjs",
"js_bytes": 80073,
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
"js_bytes": 80079,
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
"modules": [
{
"name": "component.wasm",
@@ -122,8 +122,8 @@
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"main_module": "worker.mjs",
"main_file": "arcrun-code/worker.mjs",
"js_bytes": 153671,
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
"js_bytes": 153758,
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
"modules": [
{
"name": "quickjs.wasm",
@@ -151,8 +151,8 @@
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1165130,
"content_sha256": "be15033f32e605f03f69bd10cd87782dafa34dbafeee2ce367bd7361a062a291",
"js_bytes": 1165388,
"content_sha256": "c5ff10f9b9d5a77217be343af12d2be3ee8f9792d3e1e091e48e5e6c8d24ca9d",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
+23 -40
View File
@@ -10,7 +10,6 @@ import chalk from 'chalk';
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
REQUIRED_KV_NAMESPACES,
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
@@ -135,7 +134,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
/**
* Self-hosted installer CF Account ID + API Token
* token KV REQUIRED_KV_NAMESPACES subdomain release Worker
* token subdomain 沿 Worker
* seed auth+api recipe config secret
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
*/
@@ -185,41 +184,13 @@ async function initSelfHosted(
process.exit(1);
}
// 2. KV namespace(冪等)
// 2. KV / D1 / Vectorize 不在這裡預先建(Arcrun#97)。
// 舊版在這一步「照名字 ensure」一輪再往下傳,acr update 沿用同一段程式碼
// ⇒ 對一台安裝器裝出來的實例(資源名字不同)等於每次更新都重建一整套空的綁上去。
// 現在資源解析統一在 downloadAndDeploy 內:**先看已部署的 worker 綁著什麼**,
// 對得上就沿用、確定沒人綁過才建、說不準就停手。init 走 mode:'init'(允許從零建起)。
// 不建 R2R2 是 dead storageregistry-canon Phase 1.5),且 CF R2 首次啟用強制綁信用卡,
// 違背 arcrun「開源免費自架,Workers + KV 免費額度即可運行」核心理念(壓測 2026-06-04 #3)。
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
process.stdout.write(chalk.gray(` → KV ${title}...`));
const id = await cf.ensureKvNamespace(title, existing);
kvNamespaceIds[title] = id;
console.log(chalk.green(' ✓'));
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
// (kbdb-base SDD Q4). idempotent: reuse if exists.
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
if (/auth/i.test(em)) {
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
} else {
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
}
}
// 3. 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用)
let workerSubdomain = '';
@@ -245,8 +216,20 @@ async function initSelfHosted(
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
// selfHosted: true → deploy 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 MCP 401)。
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx);
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx, 'main', { mode: 'init', api: cf });
// 資源解析喊停(例:這台其實已經裝過、但某顆綁著的資源不見了)→ 什麼都沒建、什麼都沒部。
if (deploy.blocked) {
console.log(chalk.yellow('\n ⚠ 安裝沒有進行,你的 Cloudflare 帳號維持原樣。\n'));
console.log(' ' + deploy.message.split('\n').join('\n '));
console.log('');
process.exit(1);
}
// 實際用上的資源(沿用既有的,或這次新建的)——寫 config / 驗收都以這份為準,不再自己查名字。
const kvNamespaceIds = deployCtx.kvNamespaceIds ?? {};
const d1DatabaseId = deployCtx.d1DatabaseId ?? '';
const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
// self-hosted 自己的 MCP worker URLmcp-account-source §3.mcp.json 指自己,不 fallback 官方)。
@@ -290,8 +273,8 @@ async function initSelfHosted(
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
const verify = await verifyInstall({
cf,
requiredKv: REQUIRED_KV_NAMESPACES,
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
kvNamespaceIds,
d1DatabaseId: d1DatabaseId || undefined,
cypherUrl,
});
printPreflight('安裝驗收(裝完檢查)', verify.items);
@@ -301,7 +284,7 @@ async function initSelfHosted(
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${Object.keys(kvNamespaceIds).length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
+17 -36
View File
@@ -14,11 +14,9 @@
import chalk from 'chalk';
import { loadConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
wranglerAvailable,
downloadAndDeploy,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../lib/deploy.js';
@@ -44,43 +42,15 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// D1KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限)
// → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。
// update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`${em}`));
if (/auth/i.test(em)) {
console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update'));
}
}
// 🔴 Arcrun#97:這裡**曾經**先「照名字 ensure」一輪 KV + D1 再往下傳
// binding 名(WEBHOOKS)被當成 CF 上的資源標題去找,安裝器建的資源不叫那個名字
// ⇒ 每次都對不上 ⇒ 每次都新建一顆空的綁上去 ⇒ 使用者的工作流/登入/子庫從畫面上消失
// 現在資源解析整段搬進 downloadAndDeploy:先讀「你已部署的 worker 現在綁著什麼」再決定,
// 而且是**下載完、看得到這版要哪些 binding 之後**才決定,不再由這裡預先造一批。
const ctx: DeployContext = {
accountId: config.cloudflare_account_id,
apiToken: config.cf_api_token,
workerSubdomain: extractSubdomain(config.cypher_executor_url),
kvNamespaceIds,
d1DatabaseId: d1DatabaseId || undefined,
// self-hosted → 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
@@ -93,7 +63,18 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
kbdbEmbed: config.kbdb_embed !== false,
};
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
//Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
// 資源解析階段喊停:什麼都沒建、什麼都沒部。原文照印,然後非零離開——
// 不能混進「部分失敗」的黃字裡帶過(那正是使用者不會發現的那種失敗)。
if (result.blocked) {
console.log(chalk.yellow('\n ⚠ 更新沒有進行,你的實例維持原樣。\n'));
console.log(' ' + result.message.split('\n').join('\n '));
console.log('');
process.exit(1);
}
if (result.implemented) {
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
+103 -14
View File
@@ -3,6 +3,8 @@
* 使 CF REST API KV namespace Wrangler CLI
*/
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
export interface CfKvClientOptions {
@@ -83,7 +85,7 @@ export class CfKvClient {
* CfKvClient namespace KV
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
*/
export class CfAccountClient {
export class CfAccountClient implements ResourceApi {
private accountBase: string;
private headers: Record<string, string>;
@@ -96,6 +98,16 @@ export class CfAccountClient {
}
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
const { ok, status, result, error } = await this.cfRaw<T>(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result as T;
}
/** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */
private async cfRaw<T>(
path: string,
init?: RequestInit,
): Promise<{ ok: boolean; status: number; result?: T; error?: string }> {
const res = await fetch(`${this.accountBase}${path}`, {
...init,
headers: { ...this.headers, ...(init?.headers ?? {}) },
@@ -104,10 +116,13 @@ export class CfAccountClient {
| { success: boolean; result: T; errors?: Array<{ message: string }> }
| null;
if (!res.ok || !data?.success) {
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
throw new Error(`CF API ${path} 失敗:${msg}`);
return {
ok: false,
status: res.status,
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
};
}
return data.result;
return { ok: true, status: res.status, result: data.result };
}
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
@@ -126,12 +141,16 @@ export class CfAccountClient {
return map;
}
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listKvNamespaces());
const found = known.get(title);
if (found) return found;
/**
* KV namespace
*
* 🔴 Arcrun#97**** ensure
* 使
* binding
* resource-resolver planResources
* worker binding create
*/
async createKvNamespace(title: string): Promise<string> {
const result = await this.cf<{ id: string; title: string }>(
'/storage/kv/namespaces',
{ method: 'POST', body: JSON.stringify({ title }) },
@@ -139,6 +158,24 @@ export class CfAccountClient {
return result.id;
}
/**
* worker **使**Arcrun#97
* CF`GET /accounts/{id}/workers/scripts/{script}/settings` `result.bindings[]`
*
* - script 404 `{ deployed: false }`
* - throw****
* #97
*/
async getScriptBindings(script: string): Promise<ScriptBindings> {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [] };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
}
/** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
@@ -153,14 +190,66 @@ export class CfAccountClient {
return map;
}
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listD1Databases());
const found = known.get(name);
if (found) return found;
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */
async createD1Database(name: string): Promise<string> {
const result = await this.cf<{ uuid: string; name: string }>(
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
}
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
async listVectorizeIndexes(): Promise<string[]> {
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
return (result ?? []).map(i => i.name);
}
/**
* KBDB embed Vectorize index**bge-m3 = 1024 / cosine** deploy.ts
* 409 / already exists ensure
* planResources Arcrun#97
*/
async createVectorizeIndex(name: string): Promise<string> {
const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
}
}
/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */
interface RawWorkerBinding {
type?: string;
name?: string;
namespace_id?: string;
id?: string;
database_id?: string;
index_name?: string;
}
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] {
const out: LiveBinding[] = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
+235 -69
View File
@@ -20,6 +20,19 @@ import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import chalk from 'chalk';
import { CfAccountClient } from './cf-api.js';
import {
applyResourcePlan,
bindingKey,
parseWranglerRequirements,
planResources,
ResourcePlanBlocked,
TABLE_KIND,
type BindingRequirement,
type ResourceApi,
type ResourceKind,
type ResolvedResource,
} from './resource-resolver.js';
/** manifest worker content hash
* acr update worker 2026-06-1222/23
@@ -107,7 +120,15 @@ export function buildDownloadHeaders(token = giteaToken()): Record<string, strin
}
/**
* init KV namespacetitle
* arcrun worker KV **binding **
*
* 🔴 Arcrun#97 ** CF **
* wrangler.toml parseWranglerRequirements
* resource-resolver worker
* **** toml
* ensure
*
*
* 7 .claude/rules/01-tech-stack.md cypher-executor
* SUBMISSIONS_KVregistry worker component 稿 registry deploy
* §2.6/#1120/21registry/wrangler.toml SUBMISSIONS_KV
@@ -151,8 +172,11 @@ export interface DeployContext {
accountId: string;
apiToken: string;
workerSubdomain: string;
kvNamespaceIds: Record<string, string>; // title → id
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
/** binding KV namespace id** downloadAndDeploy **
* Arcrun#97 ensure 使*/
kvNamespaceIds?: Record<string, string>;
/** KBDB Base D1 id;同上,由資源解析填入。*/
d1DatabaseId?: string;
// self-hosted 單租戶旗標。trueself-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars]
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
@@ -190,6 +214,11 @@ export interface DeployResult {
cypherExecutorUrl?: string;
mcpUrl?: string; // self-hosted 自己的 MCP worker URLmcp-account-source §3
message: string;
/** true = Arcrun#97** worker **
* message */
blocked?: boolean;
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
resources?: Map<string, ResolvedResource>;
}
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
@@ -219,8 +248,10 @@ export function wranglerAvailable(): boolean {
export async function downloadAndDeploy(
ctx: DeployContext,
ref = 'main',
opts: { force?: boolean } = {},
opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
): Promise<DeployResult> {
const mode = opts.mode ?? 'update';
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
// 1. 下載 + 解壓 Gitea archive tarball
let root: string;
try {
@@ -262,28 +293,123 @@ export async function downloadAndDeploy(
}
const failures: string[] = [];
const allDirs = [...tier1, ...tier2];
// 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等),
// 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
// 失敗不致命(收進 failuresbase 仍可部署、維持 keyword
if (ctx.kbdbEmbed) {
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
//
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
//
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
// → 整趟停手,**在動任何東西之前**。
//
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
const requirements: BindingRequirement[] = [];
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
for (const dir of allDirs) {
const tomlPath = join(dir, 'wrangler.toml');
if (!existsSync(tomlPath)) continue;
const raw = readFileSync(tomlPath, 'utf8');
tomlPreviews.set(dir, raw);
const preview = renderWranglerToml(raw, ctx, new Map());
const parsed = parseWranglerRequirements(preview);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) {
requirements.push({ ...b, worker: parsed.script });
}
}
let resolved = new Map<string, ResolvedResource>();
if (requirements.length > 0) {
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
let plan;
try {
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
await ensureVectorizeIndex(ctx);
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
await ensureVectorizeMetadataIndexes(ctx);
plan = await planResources(api, requirements, mode);
} catch (e) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
if (plan.blockers.length > 0) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
plan.blockers.map((b) => `${b}`).join('\n') +
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
console.log(chalk.yellow(' ✗'));
const raw = e instanceof Error ? e.message : String(e);
const detail = e instanceof ResourcePlanBlocked
? e.blockers.map((b) => `${b}`).join('\n')
: `${raw}`;
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
const hint = /d1/i.test(raw) && /auth/i.test(raw)
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
: '';
return {
implemented: true,
blocked: true,
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
console.log(chalk.green(' ✓'));
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
const created = [...resolved.values()].filter((r) => r.origin === 'created');
if (adopted.length > 0) {
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
for (const r of adopted) console.log(chalk.gray(` = ${r.binding}${r.value}(讀自 ${r.from}`));
}
if (created.length > 0) {
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
for (const r of created) console.log(chalk.yellow(` + ${r.binding}${r.value}`));
}
}
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
ctx.kvNamespaceIds = Object.fromEntries(
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
);
ctx.d1DatabaseId =
resolved.get(bindingKey('d1', 'DB'))?.value
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
if (vectorizeIndex) {
try {
process.stdout.write(chalk.gray(` → 語義查詢 metadata index${vectorizeIndex}...`));
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(' ⚠'));
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
}
}
// 3. 對每個 worker:注入 KV id+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
const allDirs = [...tier1, ...tier2];
let deployed = 0;
let skipped = 0;
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
@@ -296,7 +422,7 @@ export async function downloadAndDeploy(
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
try {
injectWranglerConfig(tomlPath, ctx);
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir));
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
const hash = dirContentHash(dir, ctx.accountId);
if (manifest[label] === hash) {
@@ -434,35 +560,6 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
}
}
/**
* KBDB embed Vectorize index issue #7 / T2.4
* REST `POST /accounts/{id}/vectorize/v2/indexes`dimensions=1024 / metric=cosine bge-m3
* `**dimensions=1024**/metric``*` `/` block comment TS1127
* `kbdb/src/embed.ts` `DEFAULT_EMBED_MODEL` upsert CF
* CF already exists init apiToken+accountId
*/
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: KBDB_VECTORIZE_INDEX,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
signal: AbortSignal.timeout(60_000),
});
if (res.ok) return;
// 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。
const json = (await res.json().catch(() => null)) as
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
| null;
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) return;
throw new Error(msg);
}
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
@@ -471,9 +568,12 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
* Vectorize v2 metadata filter metadata index 0
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`indexType=string
* 409 / already existsasync upsert reindex
*
* 🔴 index = 沿**** KBDB_VECTORIZE_INDEX
* 使 index metadata index
*/
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
const res = await fetch(url, {
method: 'POST',
@@ -596,21 +696,31 @@ export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: stri
* - worker toml `workers_dev = true` strip routes workers.dev URL
* - R2`[[r2_buckets]]` dead storageregistry-canon Phase 1.5
*/
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
function injectWranglerConfig(
tomlPath: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
original?: string,
): void {
if (!existsSync(tomlPath)) return;
let toml = readFileSync(tomlPath, 'utf8');
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
if (!id) continue;
const re = new RegExp(
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
'g',
);
toml = toml.replace(re, `$1${id}$2`);
}
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
const toml = original ?? readFileSync(tomlPath, 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8');
}
/**
* repo wrangler.toml
*
* `resolved` =
* id toml
* binding Arcrun#97
*
*/
export function renderWranglerToml(
toml: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
): string {
// cypher-executor 的 WORKER_SUBDOMAINvars)換成用戶帳號 subdomain
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
toml = toml.replace(
@@ -629,14 +739,6 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
);
}
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
toml = toml.replace(
/(database_id\s*=\s*")[^"]*(")/,
`$1${ctx.d1DatabaseId}$2`,
);
}
// self-hosted:注入 MULTI_TENANT="false" 到 [vars]mcp-account-source §5.5)。
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
// 只對有 [vars] 的 workermcp / cypher-executor)生效;其餘無 [vars] 的不動。
@@ -668,7 +770,71 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
}
writeFileSync(tomlPath, toml, 'utf8');
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
// 空 map = 預覽模式,這步什麼也不做。
return applyResolvedBindings(toml, resolved);
}
/**
* id binding
*
* `[[table]]` `binding = "X"`
* KV`id`D1`database_id`Vectorize`index_name`
* 🔴 **** database_idcypher`CREDENTIALS_DB`
* kbdb`DB` D1 西使
* 使
*/
export function applyResolvedBindings(
toml: string,
resolved: Map<string, ResolvedResource>,
): string {
if (resolved.size === 0) return toml;
const VALUE_KEY: Record<ResourceKind, string> = {
kv_namespace: 'id',
d1: 'database_id',
vectorize: 'index_name',
};
const out: string[] = [];
let block: string[] = [];
let kind: ResourceKind | null = null;
const flush = (): void => {
if (kind) {
const binding = block
.map((l) => l.trim())
.filter((l) => !l.startsWith('#'))
.map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
.find((b): b is string => !!b);
const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
if (hit) {
const key = VALUE_KEY[kind];
const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
if (at >= 0) {
block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
} else {
// 區塊裡本來沒有這個欄位(例如新版 toml 只寫 binding)→ 補一行,不要靜默略過。
block.push(`${key} = "${hit.value}"`);
}
}
}
out.push(...block);
block = [];
};
for (const line of toml.split('\n')) {
const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
kind = TABLE_KIND[table[1]] ?? null;
}
block.push(line);
}
flush();
return out.join('\n');
}
/**
+38 -20
View File
@@ -6,9 +6,11 @@
* test_arcrun/4 D1
* - ****KV / D1 / migration / cypher
* + harness/MCP console.log
* - ****ensureKvNamespace / ensureD1Database
* - ****
*
* + cf-api ensure* init
* + resource-resolver deploy.ts
* 🔴 Arcrun#97 fix acr update
* KV使
*/
import { execFileSync } from 'node:child_process';
@@ -77,42 +79,58 @@ export function printPreflight(title: string, items: PreflightItem[]): void {
*/
export async function verifyInstall(opts: {
cf: CfAccountClient;
requiredKv: readonly string[];
expectD1Name?: string;
/** binding → KV namespace id(部署實際用上的那幾顆)。*/
kvNamespaceIds: Record<string, string>;
/** 部署實際用上的 D1 id(沒有 D1 就不傳)。*/
d1DatabaseId?: string;
cypherUrl?: string;
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
const items: PreflightItem[] = [];
// KV實查 CF 上現有 namespace,比對必需清單
// KV核對「部署實際綁上去的那幾顆 id」在帳號上還在不在。
// 🔴 Arcrun#97:這裡**不能**用「帳號上有沒有叫 WEBHOOKS 的 namespace」來驗。
// 安裝器裝出來的實例,資源名字是 arcrun-rag-<instance>-kv-webhooks——照名字驗會誤報「缺」,
// 而那句誤報底下就寫著「fix: acr update(冪等重建)」⇒ 使用者照做,就被重建成空的。
// 驗的對象永遠是 id(我們真的綁上去的那顆),不是名字。
const kvBindings = Object.entries(opts.kvNamespaceIds);
try {
const existing = await opts.cf.listKvNamespaces();
const have = new Set(existing.keys());
const missing = opts.requiredKv.filter((t) => !have.has(t));
const ids = new Set((await opts.cf.listKvNamespaces()).values());
const missing = kvBindings.filter(([, id]) => !ids.has(id)).map(([b]) => b);
items.push(
missing.length === 0
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
: { name: 'KV namespaces', ok: false, detail: `${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
? { name: `KV namespaces (${kvBindings.length})`, ok: true }
: {
name: 'KV namespaces',
ok: false,
detail: `這幾個 binding 綁著的 namespace 在帳號上找不到:${missing.join(', ')}`,
fix: '先確認那幾顆是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
},
);
} catch (e) {
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: '檢查 CF token 的 KV 讀取權限' });
}
// D1實查 CF 上是否有該庫
if (opts.expectD1Name) {
// D1同理,核對實際綁上去的那顆 id 還在不在(不是核對有沒有叫 arcrun-kbdb 的庫)。
if (opts.d1DatabaseId) {
try {
const dbs = await opts.cf.listD1Databases();
const ids = new Set((await opts.cf.listD1Databases()).values());
items.push(
dbs.has(opts.expectD1Name)
? { name: `D1 ${opts.expectD1Name}`, ok: true }
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' },
ids.has(opts.d1DatabaseId)
? { name: `D1 ${opts.d1DatabaseId}`, ok: true }
: {
name: `D1 ${opts.d1DatabaseId}`,
ok: false,
detail: '這顆 D1 在帳號上找不到',
fix: '先確認它是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
},
);
} catch (e) {
// D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
// D1 讀不到最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
const m = msg(e);
const fix = /auth/i.test(m)
? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update'
: 'acr update(冪等重試)';
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix });
: '檢查 CF token 的 D1 讀取權限';
items.push({ name: `D1 ${opts.d1DatabaseId}`, ok: false, detail: m, fix });
}
}
+408
View File
@@ -0,0 +1,408 @@
/**
* resource-resolver.ts worker
*
* 🔴 Arcrun#972026-08-12 leo
* ensure`acr update` **binding **`WEBHOOKS` Cloudflare
* ****** worker **
* `arcrun-rag-<instance>-kv-webhooks`
* 9 KV1 D1使****
* worker 使西
*
* KV **使**
* **使**
*
* ****
*
*
* 1. ** worker ** 沿
* 2. **** binding
* 3. **** binding
* worker ****
*
* plan / apply
* `planResources()` ****沿
* `applyResourcePlan()` blocker
* ****
* early return#97
*/
/** R2/Queue/Hyperdrive
* ensure */
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize';
/** 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。 */
export interface LiveBinding {
kind: ResourceKind;
binding: string;
value: string;
}
export interface ScriptBindings {
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
deployed: boolean;
bindings: LiveBinding[];
}
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
export interface ResourceApi {
getScriptBindings(script: string): Promise<ScriptBindings>;
/** title → id */
listKvNamespaces(): Promise<Map<string, string>>;
/** name → uuid */
listD1Databases(): Promise<Map<string, string>>;
listVectorizeIndexes(): Promise<string[]>;
createKvNamespace(title: string): Promise<string>;
createD1Database(name: string): Promise<string>;
createVectorizeIndex(name: string): Promise<string>;
}
/** 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 */
export interface BindingRequirement {
kind: ResourceKind;
binding: string;
/** 需要它的 worker script 名(= wrangler.toml 的 `name`)。 */
worker: string;
createName: string;
}
export interface PlannedAdopt {
kind: ResourceKind;
binding: string;
value: string;
/** 從哪顆已部署的 worker 上讀到的 */
from: string;
}
export interface PlannedCreate {
kind: ResourceKind;
binding: string;
createName: string;
wantedBy: string[];
/** 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 */
alsoBind: string[];
}
export interface ResourcePlan {
adopt: PlannedAdopt[];
create: PlannedCreate[];
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
blockers: string[];
}
export interface ResolvedResource {
kind: ResourceKind;
binding: string;
value: string;
origin: 'adopted' | 'created';
from?: string;
}
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
constructor(readonly blockers: string[]) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
}
}
export function bindingKey(kind: ResourceKind, binding: string): string {
return `${kind}:${binding}`;
}
const KIND_LABEL: Record<ResourceKind, string> = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
function msg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* binding 沿**西**
*
* @param mode 'update' = 'init' =
*/
export async function planResources(
api: ResourceApi,
requirements: readonly BindingRequirement[],
mode: 'update' | 'init',
): Promise<ResourcePlan> {
const blockers: string[] = [];
const adopt: PlannedAdopt[] = [];
const create: PlannedCreate[] = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
const live = new Map<string, LiveBinding[]>();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) live.set(script, res.bindings);
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
const byKey = new Map<string, BindingRequirement[]>();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
const existingCache = new Map<ResourceKind, Set<string>>();
const listExisting = async (kind: ResourceKind): Promise<Set<string>> => {
const hit = existingCache.get(kind);
if (hit) return hit;
let set: Set<string>;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
const found: Array<{ value: string; script: string }> = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
let existing: Set<string>;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers };
}
/**
* binding
*
* **toml **`database_name` / `index_name`使
* cypher `CREDENTIALS_DB` kbdb `DB` `database_name = "arcrun-kbdb"`
* **** #97 使
*
*
* D1KBDB credential
* 沿
*/
function shareSameResource(
adopt: PlannedAdopt[],
create: PlannedCreate[],
byKey: Map<string, BindingRequirement[]>,
): PlannedCreate[] {
const declaredName = (kind: ResourceKind, binding: string): string | undefined =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
const out: PlannedCreate[] = [];
const groups = new Map<string, PlannedCreate>();
for (const c of create) {
const groupKey = `${c.kind}${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* plan 沿
* blocker ResourcePlanBlocked****
*/
export async function applyResourcePlan(
api: ResourceApi,
plan: ResourcePlan,
): Promise<Map<string, ResolvedResource>> {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
const out = new Map<string, ResolvedResource>();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
const madeSoFar: string[] = [];
for (const c of plan.create) {
let value: string;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
export interface WranglerRequirements {
/** worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 */
script: string;
bindings: Array<{ kind: ResourceKind; binding: string; createName: string }>;
}
/** wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 */
export const TABLE_KIND: Record<string, ResourceKind> = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* wrangler.toml worker
*
* TOML parserinjectWranglerConfig
* ****
* kbdb `[[vectorize]]`
*/
export function parseWranglerRequirements(toml: string): WranglerRequirements {
let script = '';
let seenTable = false;
const bindings: WranglerRequirements['bindings'] = [];
let kind: ResourceKind | null = null;
let binding = '';
let createName = '';
const flush = (): void => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
+532
View File
@@ -0,0 +1,532 @@
/**
* Arcrun#97 使西
*
* 2026-08-12 leo portal
* worker 9 KV + 1 D1
*
* binding WEBHOOKS CF
* `arcrun-rag-<instance>-kv-webhooks`
*
* **** leo leo21c
* planResources / applyResourcePlan / renderWranglerToml
*
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
planResources,
applyResourcePlan,
parseWranglerRequirements,
bindingKey,
ResourcePlanBlocked,
type BindingRequirement,
type ResourceApi,
type ScriptBindings,
type LiveBinding,
type ResolvedResource,
} from '../src/lib/resource-resolver.ts';
import {
renderWranglerToml,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../src/lib/deploy.ts';
import { CfAccountClient } from '../src/lib/cf-api.ts';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
/** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */
const WORKER_TOMLS = [
'cypher-executor/wrangler.toml',
'registry/wrangler.toml',
'mcp/wrangler.toml',
'kbdb/wrangler.toml',
];
const CTX: DeployContext = {
accountId: 'acct-test',
apiToken: 'tok-test',
workerSubdomain: 'yuga3bse',
selfHosted: true,
kbdbEmbed: true,
};
// ─────────────────────────────────────────────────────────────────────────────
// 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名
// ─────────────────────────────────────────────────────────────────────────────
const INSTANCE = 'yuga3bse';
interface FakeOpts {
/** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */
failBindingsFor?: string;
/** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */
deleteKvTitle?: string;
/** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */
nothingDeployed?: boolean;
}
class FakeCloudflare implements ResourceApi {
/** title → id */
kv = new Map<string, string>();
/** name → uuid */
d1 = new Map<string, string>();
vectorize: string[] = [];
/** script → bindings */
scripts = new Map<string, LiveBinding[]>();
/** 使用者的東西:kvId → (key → value) */
kvData = new Map<string, Map<string, string>>();
/** d1Id → 子庫名單 */
d1Libraries = new Map<string, string[]>();
/** 這趟總共建立了什麼(驗「顆數不增加」用)。 */
createdKv: string[] = [];
createdD1: string[] = [];
createdVectorize: string[] = [];
constructor(private opts: FakeOpts = {}) {
// 安裝器的命名慣例:arcrun-rag-<instance>-kv-<binding 小寫>
for (const binding of REQUIRED_KV_NAMESPACES) {
const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`;
const id = `kvid-${binding.toLowerCase()}`;
this.kv.set(title, id);
this.kvData.set(id, new Map());
}
this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb');
this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`);
// 使用者的東西
this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}');
this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}');
this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}');
this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}');
this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']);
if (!opts.nothingDeployed) {
const kvB = (b: string): LiveBinding =>
({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` });
this.scripts.set('arcrun-cypher-executor', [
kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'),
kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'),
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' },
]);
this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]);
this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]);
this.scripts.set('arcrun-kbdb', [
{ kind: 'd1', binding: 'DB', value: 'd1id-kbdb' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` },
]);
}
if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle);
}
async getScriptBindings(script: string): Promise<ScriptBindings> {
if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)');
const b = this.scripts.get(script);
return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] };
}
async listKvNamespaces(): Promise<Map<string, string>> { return new Map(this.kv); }
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
const id = `NEW-kvid-${this.createdKv.length}`;
this.kv.set(title, id);
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
this.createdKv.push(title);
return id;
}
async createD1Database(name: string): Promise<string> {
const id = `NEW-d1id-${this.createdD1.length}`;
this.d1.set(name, id);
this.d1Libraries.set(id, []);
this.createdD1.push(name);
return id;
}
async createVectorizeIndex(name: string): Promise<string> {
this.vectorize.push(name);
this.createdVectorize.push(name);
return name;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑)
// ─────────────────────────────────────────────────────────────────────────────
function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
const requirements: BindingRequirement[] = [];
const tomls = new Map<string, string>();
for (const rel of WORKER_TOMLS) {
const raw = readFileSync(join(REPO, rel), 'utf8');
tomls.set(rel, raw);
const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
return { requirements, tomls };
}
/** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */
function deployAndReadBindings(
tomls: Map<string, string>,
resolved: Map<string, ResolvedResource>,
): Map<string, Map<string, string>> {
const out = new Map<string, Map<string, string>>();
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
const script = parseWranglerRequirements(rendered).script;
const bound = new Map<string, string>();
let kind: string | null = null;
let binding = '';
for (const line of rendered.split('\n')) {
const t = line.trim();
if (t.startsWith('#')) continue;
const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) { kind = table[1]; binding = ''; continue; }
const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!m) continue;
if (m[1] === 'binding') binding = m[2];
else if (binding && (
(kind === 'kv_namespaces' && m[1] === 'id')
|| (kind === 'd1_databases' && m[1] === 'database_id')
|| (kind === 'vectorize' && m[1] === 'index_name')
)) bound.set(binding, m[2]);
}
out.set(script, bound);
}
return out;
}
// ═════════════════════════════════════════════════════════════════════════════
// ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => {
const cf = new FakeCloudflare();
const { requirements, tomls } = collectRequirements();
const before = {
workflows: cf.kvData.get('kvid-webhooks')!.size,
sessions: cf.kvData.get('kvid-sessions_kv')!.size,
libraries: cf.d1Libraries.get('d1id-kbdb')!.length,
};
assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好');
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker');
const resolved = await applyResourcePlan(cf, plan);
const bound = deployAndReadBindings(tomls, resolved);
// 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要)
const cypher = bound.get('arcrun-cypher-executor')!;
assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks');
assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv');
assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb');
assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb');
assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv');
assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv');
assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`);
const after = {
workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size,
sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size,
libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length,
};
assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣');
});
// ═════════════════════════════════════════════════════════════════════════════
// ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆)
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ②:更新不會在帳號上多生任何資源', async () => {
const cf = new FakeCloudflare();
const kvBefore = cf.kv.size;
const d1Before = cf.d1.size;
const vecBefore = cf.vectorize.length;
assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]);
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index');
assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]);
});
test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => {
// 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與
// cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。
// 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。
const cf = new FakeCloudflare();
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建
}
const d1s = await cf.listD1Databases();
if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb');
assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)');
assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆');
assert.equal(cf.kv.size, 18, '9 → 18');
assert.equal(cf.d1.size, 2, '1 → 2');
// 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的
assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0);
});
// ═════════════════════════════════════════════════════════════════════════════
// ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ③-aworker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => {
const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.ok(plan.blockers.length > 0, '要有 blocker');
const said = plan.blockers.join('\n');
assert.match(said, /WEBHOOKS/, '要指名是哪個綁定');
assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源');
assert.match(said, /找不到/, '要說清楚發生什麼事');
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來');
assert.deepEqual(cf.createdD1, []);
});
test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => {
const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/);
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, []);
});
test('#97 ③-cupdate 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/);
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, []);
});
test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => {
const cf = new FakeCloudflare();
// registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊)
cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other';
cf.kv.set('some-other-kv', 'kvid-other');
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/);
assert.deepEqual(cf.createdKv, []);
});
// ═════════════════════════════════════════════════════════════════════════════
// 合法的新建:只有「確定沒人綁過」時才准
// ═════════════════════════════════════════════════════════════════════════════
test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => {
const cf = new FakeCloudflare();
cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的)
cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`);
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, []);
assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建');
await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, ['OAUTH_KV']);
assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆');
});
test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'init');
assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手');
await applyResourcePlan(cf, plan);
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length);
assert.deepEqual(cf.createdD1, ['arcrun-kbdb']);
assert.equal(cf.createdVectorize.length, 1);
});
test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => {
const cf = new FakeCloudflare();
cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb
const { requirements, tomls } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, []);
assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆');
const resolved = await applyResourcePlan(cf, plan);
const bound = deployAndReadBindings(tomls, resolved);
assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb',
'credential 目錄要跟 KBDB 在同一顆庫');
assert.deepEqual(cf.createdD1, []);
});
test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => {
// repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入,
// 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。
const cf = new FakeCloudflare();
const { requirements, tomls } = collectRequirements();
const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update'));
const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f';
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`);
assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`);
}
});
// ═════════════════════════════════════════════════════════════════════════════
// 做法本身的看守:不准再出現「照名字 ensure」這種原語
// ═════════════════════════════════════════════════════════════════════════════
test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => {
const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8');
assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/,
'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。'
+ '要建資源一律先過 resource-resolver 的 planResources。');
});
test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => {
for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) {
const src = readFileSync(join(REPO, rel), 'utf8');
assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/,
`${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`);
}
});
// ═════════════════════════════════════════════════════════════════════════════
// 底層零件
// ═════════════════════════════════════════════════════════════════════════════
test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => {
const toml = [
'name = "arcrun-kbdb" # 註解不影響',
'',
'[[d1_databases]]',
'binding = "DB"',
'database_name = "arcrun-kbdb"',
'database_id = "placeholder"',
'',
'[vars]',
'ENVIRONMENT = "production"',
'',
'# [[vectorize]]',
'# binding = "VECTORIZE"',
'# index_name = "arcrun-kbdb-embed-m3"',
].join('\n');
const r = parseWranglerRequirements(toml);
assert.equal(r.script, 'arcrun-kbdb');
assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]);
});
test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => {
const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"');
assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]);
});
test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => {
const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8');
const resolved = new Map<string, ResolvedResource>([
[bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }],
[bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }],
]);
const out = renderWranglerToml(cypher, CTX, resolved);
const bound = parseWranglerRequirements(out);
assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB'));
assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/);
assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/);
// 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved
assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/);
});
test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => {
for (const rel of WORKER_TOMLS) {
const raw = readFileSync(join(REPO, rel), 'utf8');
const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
const resolved = new Map<string, ResolvedResource>(
preview.bindings.map((b) => [
bindingKey(b.kind, b.binding),
{ kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const },
]),
);
const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved));
assert.deepEqual(
after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
`${rel}: 預覽與實際注入看到的綁定必須一致`,
);
}
});
test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => {
const { requirements } = collectRequirements();
const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))];
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});
test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
const orig = globalThis.fetch;
try {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
) as typeof fetch;
const cf = new CfAccountClient('a', 't');
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] });
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
) as typeof fetch;
await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/);
} finally {
globalThis.fetch = orig;
}
});
test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => {
const orig = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response(JSON.stringify({
success: true,
result: {
bindings: [
{ type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' },
{ type: 'd1', name: 'DB', id: 'db1' },
{ type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' },
{ type: 'plain_text', name: 'ENVIRONMENT', text: 'production' },
{ type: 'service', name: 'SVC_SET', service: 'arcrun-set' },
],
},
}), { status: 200 })) as typeof fetch;
const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor');
assert.equal(res.deployed, true);
assert.deepEqual(res.bindings, [
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' },
{ kind: 'd1', binding: 'DB', value: 'db1' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
]);
} finally {
globalThis.fetch = orig;
}
});
+26 -3
View File
@@ -1,6 +1,6 @@
import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
import { wasmWorkerUrl } from '../lib/component-loader';
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
import { resolveRecipe } from '../routes/recipes';
import type { RecipeDefinition } from '../routes/recipes';
import { branchHintFor } from '../lib/branch-hints';
@@ -44,8 +44,12 @@ export type NodeInfo = {
status: NodeStatus;
componentId?: string;
type: NodeRole;
/** found 時標來源庫:零件 registrycomponent)或 recipe 庫(recipe)。 */
source?: 'component' | 'recipe';
/**
* found registrycomponentrecipe recipe
* cypher-executor registry builtin
* Arcrun#88component-loader.ts RUNTIME_NATIVE_COMPONENT_IDS
*/
source?: 'component' | 'recipe' | 'builtin';
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
input_schema?: unknown;
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
@@ -212,6 +216,25 @@ export async function searchNodes(
continue;
}
// ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ──────────────────
// component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDStrigger_workflow
// BUILTIN_COMPONENTSLOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS 的聯集——
// 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。
// 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control
// http_requestswitch…)會被下面「兩庫都查過沒有」誤判成 not_found——
// 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。
// target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
source: 'builtin',
branch_hint: branchHintFor(componentId),
};
continue;
}
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
@@ -88,6 +88,33 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
};
/**
* vs Arcrun#882026-08-11
*
* `/cypher/search``search-nodes.ts` component registry`SUBMISSIONS_KV`
* `submitComponent`/`index-only` 0/1/5/7
* ** registry** trigger_workflowBUILTIN_COMPONENTS
* LOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS submit
* cypher-executor 稿 leo21c `/components/catalog`
* 404registry search `if_control``http_request`
* leo 08-11
*
* search-nodes.ts registry
* **** registry backfill
*
* `registry/components/*`
* `km_writer``kbdb_upsert_block`
* `system-dev/docs/3-specs/arcrun-usable/cleanup-dead-code.md`07-30
* registryleo ** resolve **
* 1/5/7
*/
export const RUNTIME_NATIVE_COMPONENT_IDS: ReadonlySet<string> = new Set([
'trigger_workflow',
...BUILTIN_COMPONENTS.keys(),
...Object.keys(LOGIC_BINDING_MAP),
...WASM_HTTP_RUNNER_IDS,
]);
export function createComponentLoader(env: Bindings) {
return async (componentId: string): Promise<ComponentRunner> => {
@@ -0,0 +1,116 @@
/**
* Arcrun#88
*
* leo21c 2026-08-11
* `/cypher/search` `if_control``http_request` `not_found`
* component-loader.ts LOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS
* registryregistry catalog 404`GET /components/catalog`
* `{"success":false,"error":"零件 catalog 不存在"}`search
*
* wrangler.test.toml WORKER_SUBDOMAIN registryBase
* undefined catalog.status='unreachable'registry
* RUNTIME_NATIVE_COMPONENT_IDS registry **** found
* registry registry
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { parseTriplets } from '../src/actions/triplet-parser';
import { searchNodes } from '../src/actions/search-nodes';
afterEach(() => {
vi.unstubAllGlobals();
});
const IF_CONTROL_TRIPLETS = [
'input >> ON_SUCCESS >> if_control',
];
const HTTP_REQUEST_TRIPLETS = [
'input >> ON_SUCCESS >> http_request',
];
const MULTI_BUILTIN_TRIPLETS = [
'input >> ON_SUCCESS >> switch',
'input >> ON_SUCCESS >> filter',
'input >> ON_SUCCESS >> code',
];
const FAKE_COMPONENT_TRIPLETS = [
'input >> ON_SUCCESS >> totally_made_up_component_xyz',
];
describe('Arcrun#88:執行期原生零件不受 registry 健康狀態影響', () => {
it('if_controlLOGIC_BINDING_MAP 成員)在 registry 不可達時仍回 found', async () => {
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
expect(parsed).not.toBeNull();
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
// 無 WORKER_SUBDOMAINREGISTRY_BASE_URL → registryBase undefined → catalog unreachable
});
expect(nodeResults.if_control.status).toBe('found');
expect(nodeResults.if_control.source).toBe('builtin');
// if_control 會分岔,branch_hint 應隨 found 一併附上(不必逐顆再查一次)
expect(nodeResults.if_control.branch_hint?.edge_types).toEqual(['ON_TRUE', 'ON_FALSE']);
expect(missingNodes).not.toContain('if_control');
});
it('http_requestWASM_HTTP_RUNNER_IDS 成員)在 registry 不可達時仍回 found', async () => {
const parsed = parseTriplets(HTTP_REQUEST_TRIPLETS);
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.http_request.status).toBe('found');
expect(nodeResults.http_request.source).toBe('builtin');
expect(missingNodes).not.toContain('http_request');
});
it('switchfiltercode(同一批白名單的其他成員)也回 found,不逐一漏網', async () => {
const parsed = parseTriplets(MULTI_BUILTIN_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.switch.status).toBe('found');
expect(nodeResults.filter.status).toBe('found');
expect(nodeResults.code.status).toBe('found');
});
it('registry 完全連不到時,真正不存在的名字誠實回 unknown(不敢空口說沒有——既有行為,修法沒有動它)', async () => {
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.totally_made_up_component_xyz.status).toBe('unknown');
});
it('registry 查得到但目錄是空的(複現 leo21c 實例 catalog 404 的真實症狀):真正不存在的名字回 not_found', async () => {
// 複現生產實測:GET /components/catalog → HTTP 200 空陣列(本測試模擬「registry 活著但沒東西」,
// 與 leo21c 實例的「404 零件 catalog 不存在」殊途同歸——都會落到「查得到、目錄無此零件」)。
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
));
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
WORKER_SUBDOMAIN: 'test-sub',
});
expect(nodeResults.totally_made_up_component_xyz.status).toBe('not_found');
expect(missingNodes).toContain('totally_made_up_component_xyz');
});
it('registry 目錄是空的(catalog 通但無資料)時,執行期原生零件依然 found——這才是 Arcrun#88 的核心場景', async () => {
// 這就是 leo21c 實例的真實狀態:registry 活著、目錄卻沒有任何一顆執行期原生零件的記錄
// SUBMISSIONS_KV 從未收到 if_control/http_request 的 submit)。若沒有本次修法,
// 這裡會落到「兩庫都查過沒有」→ not_found,正是 Arcrun#88 回報的病徵。
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
));
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, { WORKER_SUBDOMAIN: 'test-sub' });
expect(nodeResults.if_control.status).toBe('found');
expect(nodeResults.if_control.source).toBe('builtin');
});
it('target=recipe 明確只查 recipe 庫時,執行期原生零件不搶答 found(尊重使用者明確限庫)', async () => {
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {}, 'discover', 'recipe');
// if_control 從來不是 recipetarget=recipe 下不該被 builtin 短路成 found
expect(nodeResults.if_control.status).not.toBe('found');
});
});
+66 -13
View File
@@ -216,12 +216,63 @@ const MAX_LIKE_TERMS = 6;
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
// ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(Arcrun#94)────────────────
//
// 病徵:搜尋框打 `100%` 或 `owner_id`,回來一堆跟那些字無關的東西。
// SQLite LIKE 只有兩個萬用字元——`%`(任意長度)與 `_`(任意一個字元),而且
// **沒有預設跳脫字元**(不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」)。
// 我們把使用者輸入直接內插成 `'%' + q + '%'` ⇒ 他打的符號被當成 pattern 語法:
// `100%` → `%100%%` → 「100」開頭後面接什麼都算 ⇒ 撈回一堆不相干的
// `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ `ownerXid`、`owner-id` 也中
// `%``_` 單打 → `%%%``%_%` → **整個庫都回來**(`_` 只要有一個字元就中)
//
// 這是舊病,不是 08-10 斷詞(47c6aae→本檔上一段)引進的:pattern 一直都是這樣拼的。
// 之前關鍵字搜尋幾乎恆為 0 命中(整串比對),這個洞被那個洞蓋住,看不出來;
// 斷詞讓搜尋真的會回東西之後它才浮出來。**斷詞那段一個字都沒動。**
//
// 修法:三個字元都跳脫,並在每個 LIKE 後面掛 `ESCAPE '\'`。
//
// 為什麼**跳脫字元本身(`\`)也要跳脫**(邊界問題的答案,不是順手多做):
// 一旦宣告了 ESCAPE`\` 在 pattern 裡就變成有意義的字元,於是「使用者打的 `\`」
// 同樣會被誤讀——而且是更糟的一種,因為它會**把後面那個字吃掉**:
// 使用者打 `100\%` → 不跳脫 `\` ⇒ pattern `%100\%%` ⇒ `\%`=字面 %
// ⇒ 實際找的是 `100%`,**跟他打的字不一樣**
// 使用者打 `C:\` → pattern `%C:\%` ⇒ 尾巴 `\%`=字面 %
// ⇒ 找的是 `C:%`,而真正的 `C:\` 反而找不到
// ⇒ 三個字元是一組的:宣告 ESCAPE 卻不跳脫 `\` 等於用新的漏洞換掉舊的。
// SQLite 對「`\` 後面接其他字元」是寬容的——照字面匹配下一個字、不報錯——
// 所以不跳脫不會炸,只會靜靜地找錯東西,正是最難發現的那種。)
//
// 為什麼**只有這三個**SQLite 的 LIKE 萬用字元就只有 `%` 和 `_``[...]`、`?`、`*`
// 是別的方言/GLOB 的東西,LIKE 不吃),加上自己宣告的跳脫字元 `\`,就這三個。
// 不多跳脫其他字元——跳脫沒有語法意義的字元只會白白吃掉 pattern 的 byte 預算。
//
// 🔴 與 50 bytes 上限的交互作用(不能只加跳脫就收工):跳脫會**變長**(`%`→`\%`),
// 所以所有 byte 預算改用「跳脫後」的長度算(likeBytes),否則使用者打一串 `%`
// 會讓 pattern 膨脹回 50 bytes 以上 ⇒ 退回 2026-08-03 那個 500。
// 不含這三個字元的查詢,likeBytes ≡ utf8Len ⇒ **既有查詢的行為逐字不變**。
const LIKE_ESCAPE = '\\';
/** 每個 `content LIKE ?` 都要帶著它的 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面。 */
const CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
/** 把使用者輸入當「字面字串」送進 LIKE(純函式,單測用 export)。 */
export function escapeLikeLiteral(s: string): string {
// 一次掃描、每個字元各自替換 ⇒ 不會發生「先換 % 再換 \ 把剛加的跳脫又跳脫一次」。
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
}
/** 這段文字**跳脫後**佔的 byte 數(=它在 LIKE pattern 裡真正佔的長度)。 */
const likeBytes = (s: string): number => utf8Len(escapeLikeLiteral(s));
/** 子字串比對用的 pattern:只有頭尾那兩個 `%` 是萬用字元,中間全是字面。 */
const likePattern = (s: string): string => `%${escapeLikeLiteral(s)}%`;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。上限算的是**跳脫後**的長度。 */
function chunkByBytes(s: string, maxBytes: number): string[] {
const out: string[] = [];
let cur = '';
for (const ch of s) {
if (utf8Len(cur + ch) > maxBytes) {
if (likeBytes(cur + ch) > maxBytes) {
if (cur) out.push(cur);
cur = ch;
} else {
@@ -237,8 +288,8 @@ function chunkByBytes(s: string, maxBytes: number): string[] {
* `split=false` LIKE
*/
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
}
const terms: string[] = [];
for (const word of q.split(/\s+/).filter(Boolean)) {
@@ -251,8 +302,8 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
return {
conds: terms.map(() => 'content LIKE ?'),
params: terms.map((t) => `%${t}%`),
conds: terms.map(() => CONTENT_LIKE),
params: terms.map(likePattern),
split: true,
};
}
@@ -428,7 +479,7 @@ export function buildSearchScore(q: string): SearchScorePlan {
if (terms.length === 0) {
const m = buildContentLike(trimmed);
return {
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '),
scoreParams: m.params,
terms: [],
legacyShape: true,
@@ -438,16 +489,16 @@ export function buildSearchScore(q: string): SearchScorePlan {
const parts: string[] = [];
const params: string[] = [];
for (const { term, weight } of terms) {
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
params.push(`%${term}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
params.push(likePattern(term));
}
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
const single = terms.length === 1 && terms[0].term === trimmed;
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
const bonus = terms.reduce((s, t) => s + t.weight, 0);
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
params.push(`%${trimmed}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
params.push(likePattern(trimmed));
}
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
@@ -512,7 +563,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
// includeDeprecateddaemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
// 加在參數最尾端,既有 positional callersource 之後)一個都不用改。
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
// 2026-08-12Arcrun#94):q 裡的 `%` `_` `\` 一律當字面字元(escapeLikeLiteral ESCAPE 宣告)
// ——使用者打什麼字就照那些字找。舊病,見上面 LIKE_ESCAPE 那段。
// 2026-08-10q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
export async function searchEntries(
+15 -3
View File
@@ -330,6 +330,15 @@ type LibraryNameSet = Set<string>;
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
// triplet 歸 'general')——與 GET /records/triplet-statst142)同一套分組語意,兩處數字對得上。
//
// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status,
// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不
// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上,
// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map
// recordsuperseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede
// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作,
// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出
// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。
async function liveTripletCountsByLibrary(
db: D1Database,
tripletTemplateId: string,
@@ -337,15 +346,18 @@ async function liveTripletCountsByLibrary(
): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db
.prepare(
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT DISTINCT ev.record_id
SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id
) AS tr
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
WHERE COALESCE(tr.status, 'active') = 'active'
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
)
.bind(...params)
+34 -1
View File
@@ -16,7 +16,7 @@ import {
ensureFreshLibraryMaps,
LIBRARY_MAP_SLOTS,
} from '../src/actions/library-map';
import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud';
import { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud';
import { createEntry } from '../src/actions/entry-crud';
import type { Bindings } from '../src/types';
@@ -292,6 +292,39 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
});
it('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 statusrecomputeLibraryMap 只算
// active——只要庫裡混了 superseded triplet,兩邊算出來的數字永遠對不上,
// ensureFreshLibraryMaps 就永遠判定 stale,每次讀地圖都重算、每次都新建一筆 record。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰
const { app, env } = makeApp(db);
// 第一次讀:資料是新的(從沒 recompute 過),觸發一次重算是正常的。
const first = await app.request('/map', {}, env);
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); // 只算 active 那筆
const countAfterFirst = (await searchByTemplate(db, 'library_map')).length;
// 第二次讀:中間沒有任何寫入動作。修好之前,這裡會再次判定 stale 並多新建一筆 record。
const second = await app.request('/map', {}, env);
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
const countAfterSecond = (await searchByTemplate(db, 'library_map')).length;
expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record
// 第三次也一樣,多讀幾次確認不是巧合。
await app.request('/map', {}, env);
const countAfterThird = (await searchByTemplate(db, 'library_map')).length;
expect(countAfterThird).toBe(countAfterFirst);
});
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
+253
View File
@@ -0,0 +1,253 @@
// 搜尋框裡的 `%` 與 `_` 是「要找的字」,不是萬用字元 —— Arcrun#942026-08-12
//
// 病徵(leo 回報):搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字**無關**的東西。
// 根因:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有兩個
// 萬用字元 `%`/`_` 且**沒有預設跳脫字元** ⇒ 使用者打的符號被當成 pattern 語法。
//
// 舊病,不是 08-10 斷詞(search-tokenize.test.ts)引進的:pattern 從來就是這樣拼的。
// 之前關鍵字搜尋幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
//
// 測試策略:**用真 SQLite 跑真的 SQL**node:sqlite,與 library-mapembed-backfill 同款 adapter)。
// 只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
// 每組驗收都同時跑「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡(legacyPattern)。
//
// 註:直接對 SQLite 治具下 SQL 的行集中在下面的 helper(測試治具本身,非牆外業務邏輯繞過
// API),每行標 kbdb-sql-ok 留痕——與 embed-backfilllibrary-backfill 等既有測試同慣例。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import {
escapeLikeLiteral,
buildContentLike,
buildSearchScore,
searchEntries,
createEntry,
} from '../src/actions/entry-crud';
// ── node:sqlite → D1 最小 adapter ────────────────────────────────────────────
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok:記憶體測試替身,非真 KBDB D1
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
function stmt(sql: string, params: unknown[]) {
return {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() { raw.prepare(sql).run(...(params as never[])); return { success: true }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
};
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
/** 舊寫法(本次修掉的那個):使用者輸入直接內插、LIKE 不帶 ESCAPE。前後對照用。 */
const legacyPattern = (q: string) => `%${q}%`;
/** 對真 SQLite 跑一次 `content LIKE ?`,回命中的 content(要不要帶 ESCAPE 可選)。 */
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
const res = await db
.prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
.bind(pattern)
.all<{ content: string }>();
return (res.results ?? []).map((x) => x.content);
}
/** 把 searchEntries 送出的 SQL 側錄下來(不改行為,只是中間插一層)。 */
function sqlSpy(db: D1Database): { spy: D1Database; sqls: string[] } {
const sqls: string[] = [];
const spy = {
prepare: (sql: string) => { sqls.push(sql); return db.prepare(sql); }, // kbdb-sql-ok:測試治具側錄,轉呼叫同一顆治具 DB
} as unknown as D1Database;
return { spy, sqls };
}
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限(承 2026-08-03 的 500 修復)
// 一組刻意設計的語料:每一筆都用來分辨「字面命中」與「萬用字元誤中」。
const CORPUS = [
'毛利率 100% 達成', // 含字面 %
'共有 100 個待辦項目', // 含 100 但不含 %`%100%%` 會誤中它
'owner_id 是租戶隔離的欄位', // 含字面 _
'ownerXid 是打錯的欄位名', // `_` 當萬用字元才會中
'路徑 C:\\_temp 底下', // 含字面「反斜線+底線」(跳脫字元本身 + 萬用字元)
'路徑 C:\\Xtemp 底下', // 反斜線後接任一字元——`_` 漏成萬用字元才會中
'完全無關的一筆內容', // 對照組:什麼都不該中
];
async function seeded(): Promise<D1Database> {
const db = makeSqliteD1();
for (const [i, content] of CORPUS.entries()) {
await createEntry(db, { id: `e${i}`, content, entry_type: 'block', owner_id: 'leo' });
}
return db;
}
describe('① 前後對照:使用者打什麼字,就照那些字找', () => {
it('`100%`:舊寫法把 % 當萬用字元、連「100 個待辦」都撈回來;現行只回真的含 100% 的', async () => {
const db = await seeded();
const before = await likeHits(db, legacyPattern('100%'), false);
const after = await likeHits(db, buildContentLike('100%').params[0], true);
expect(before).toEqual(['毛利率 100% 達成', '共有 100 個待辦項目']); // ← 病徵:多了不相干的
expect(after).toEqual(['毛利率 100% 達成']); // ← 只有真的含「100%」的
});
it('`owner_id`:舊寫法 _ 匹配任一字元、把 ownerXid 也撈回來;現行只回字面相符的', async () => {
const db = await seeded();
expect(await likeHits(db, legacyPattern('owner_id'), false)).toEqual([
'owner_id 是租戶隔離的欄位',
'ownerXid 是打錯的欄位名', // ← 病徵
]);
expect(await likeHits(db, buildContentLike('owner_id').params[0], true)).toEqual([
'owner_id 是租戶隔離的欄位',
]);
});
it('只打一個 `%` 或 `_`:舊寫法把**整個庫**倒回來(leo 回報的那個畫面)', async () => {
const db = await seeded();
// `%%%` 匹配任何字串;`%_%` 只要有一個字元就中 ⇒ 兩者都等於「全庫」
expect(await likeHits(db, legacyPattern('%'), false)).toHaveLength(CORPUS.length);
expect(await likeHits(db, legacyPattern('_'), false)).toHaveLength(CORPUS.length);
// 現行:只回真的含那個字元的(`_` 有兩筆——欄位名那筆與路徑那筆,兩筆都是字面命中)
expect(await likeHits(db, buildContentLike('%').params[0], true)).toEqual(['毛利率 100% 達成']);
expect(await likeHits(db, buildContentLike('_').params[0], true)).toEqual([
'owner_id 是租戶隔離的欄位',
'路徑 C:\\_temp 底下',
]);
});
it('走完整搜尋路徑(searchEntries,含斷詞與算分)結果一致——不是只有底層函式對', async () => {
const db = await seeded();
expect((await searchEntries(db, '100%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
expect((await searchEntries(db, 'owner_id', 'leo')).map((e) => e.content)).toEqual([
'owner_id 是租戶隔離的欄位',
]);
// 單打一個符號:以前是全庫,現在是「真的含那個字的那一筆」
expect((await searchEntries(db, '%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
expect((await searchEntries(db, '_', 'leo')).map((e) => e.content).sort()).toEqual(
['owner_id 是租戶隔離的欄位', '路徑 C:\\_temp 底下'].sort(),
);
});
});
describe('② 邊界:跳脫字元本身(`\\`)也必須跳脫', () => {
// 為什麼這組必須存在:宣告了 ESCAPE 之後,`\` 就變成 pattern 裡有意義的字元。
// 只跳脫 % 和 _、不跳脫 `\`,等於用新的漏洞換掉舊的——而且更難發現,因為它
// **不會報錯**,只會靜靜地把後面那個字吃掉、去找一個使用者沒打過的字串。
const halfDone = (q: string) => `%${q.replace(/[%_]/g, (c) => '\\' + c)}%`; // 只跳脫 %/_ 的假想修法
it('打 `C:\\`:不跳脫反斜線的話尾巴變成「字面 %」,反而找不到任何真正含 `C:\\` 的內容', async () => {
const db = await seeded();
// pattern `%C:\%` ⇒ 尾巴的 `\%` 被讀成「字面的 %」⇒ 實際去找 `C:%`,庫裡沒有 ⇒ 全漏
expect(await likeHits(db, halfDone('C:\\'), true)).toEqual([]);
expect(await likeHits(db, buildContentLike('C:\\').params[0], true)).toEqual([
'路徑 C:\\_temp 底下',
'路徑 C:\\Xtemp 底下',
]);
});
it('打 `C:\\_temp`:反斜線沒跳脫 ⇒ 它把 `_` 的跳脫吃掉,萬用字元漏回來、撈到不相干的', async () => {
const db = await seeded();
// `%C:\\_temp%``\\` 先被讀成「字面 \」,後面那個 `_` 就變回萬用字元 ⇒ C:\Xtemp 也中
expect(await likeHits(db, halfDone('C:\\_temp'), true)).toEqual([
'路徑 C:\\_temp 底下',
'路徑 C:\\Xtemp 底下', // ← 使用者沒打過這個字
]);
expect(await likeHits(db, buildContentLike('C:\\_temp').params[0], true)).toEqual([
'路徑 C:\\_temp 底下',
]);
});
it('打 `100\\%`:三個都不跳脫 ⇒ `\\%` 被讀成「字面 %」⇒ 去找 `100%`,跟他打的不一樣', async () => {
const db = await seeded();
// 原始寫法(一個都不跳脫)= pattern `%100\%%``\%`=字面 %、尾巴那個 `%`=萬用字元
expect(await likeHits(db, legacyPattern('100\\%'), true)).toEqual(['毛利率 100% 達成']); // ← 找錯東西
// 正解:庫裡沒有字面的 `100\%` ⇒ 就該零命中,而不是拿別的東西充數
expect(await likeHits(db, buildContentLike('100\\%').params[0], true)).toEqual([]);
});
it('escapeLikeLiteral 只碰這三個字元,且不會把自己剛加的跳脫再跳脫一次', () => {
expect(escapeLikeLiteral('100%')).toBe('100\\%');
expect(escapeLikeLiteral('owner_id')).toBe('owner\\_id');
expect(escapeLikeLiteral('C:\\')).toBe('C:\\\\');
expect(escapeLikeLiteral('%_\\')).toBe('\\%\\_\\\\'); // 三個各自跳脫一次,不是兩次
// LIKE 沒有 [] ? * 這些萬用字元(那是 GLOB/別的方言)⇒ 不該白白吃掉 byte 預算
expect(escapeLikeLiteral('a[b]?c*d 中文')).toBe('a[b]?c*d 中文');
});
});
describe('③ 每個 LIKE 都要帶 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面', () => {
it('buildContentLikebuildSearchScore 產生的謂詞都含 ESCAPE', () => {
for (const c of buildContentLike('100%').conds) expect(c).toContain("ESCAPE '\\'");
for (const c of buildContentLike('a'.repeat(200)).conds) expect(c).toContain("ESCAPE '\\'");
expect(buildSearchScore('Gemini 逃生口').scoreExpr).toContain("ESCAPE '\\'");
expect(buildSearchScore('。。。').scoreExpr).toContain("ESCAPE '\\'"); // 一個詞都拆不出來的退路
});
it('沒有任何 `content LIKE ?` 是裸的(漏掉一個就等於那條路沒修)', async () => {
const { spy, sqls } = sqlSpy(await seeded());
for (const q of ['100%', 'Gemini 逃生口', '。。。', 'a'.repeat(200)]) await searchEntries(spy, q, 'leo');
expect(sqls.length).toBeGreaterThan(0);
for (const sql of sqls) expect(sql.match(/content LIKE \?(?! ESCAPE)/g) ?? []).toHaveLength(0);
});
it('ESCAPE 宣告本身是合法 SQL(D1=SQLite;真的跑得起來,不是形狀對而已)', async () => {
const db = await seeded();
await expect(likeHits(db, '%100\\%%', true)).resolves.toEqual(['毛利率 100% 達成']);
});
});
describe('④ 不退化:不含 % _ \\ 的查詢,行為與修改前逐字相同', () => {
it('pattern 一個字都沒變(跳脫對這些字串是恆等變換)', () => {
for (const q of ['語意檢索', 'arcrun', 'Gemini 逃生口', '為什麼今天額度用完']) {
expect(escapeLikeLiteral(q)).toBe(q);
}
expect(buildContentLike('語意檢索').params).toEqual(['%語意檢索%']);
expect(buildSearchScore('arcrun').scoreParams).toEqual(['%arcrun%']);
expect(buildSearchScore('語意檢索').legacyShape).toBe(true); // 最熱路徑仍是單一 LIKE
});
it('斷詞(08-10Arcrun#84 已判定留下)沒被動到:問句照樣拆得開', () => {
expect(buildSearchScore('Gemini 逃生口').scoreParams).toEqual(
expect.arrayContaining(['%Gemini%', '%逃生口%']),
);
});
});
describe('⑤ 跳脫會變長 ⇒ byte 預算要用「跳脫後」的長度算,否則退回 2026-08-03 那個 500', () => {
it('滿是 % 的長查詢,每個 pattern 仍在 D1 的 50 bytes 上限內', () => {
const qs = [
'%'.repeat(200), // 每個字元跳脫後變 2 bytes
'_'.repeat(60),
'\\'.repeat(60),
`${'%'.repeat(30)}中文${'_'.repeat(30)}`,
'a'.repeat(24) + '%'.repeat(24), // 卡在舊上限附近的混合
];
for (const q of qs) {
for (const p of buildContentLike(q).params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
const plan = buildSearchScore(q);
expect(plan.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
for (const p of plan.scoreParams) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
}
});
it('48 個 `%`(跳脫前剛好在舊上限內)不會產生 98 bytes 的 pattern', () => {
const q = '%'.repeat(48);
expect(bytes(q)).toBe(48); // 用舊的算法看,它「在上限內」
const m = buildContentLike(q);
expect(m.split).toBe(true); // 用跳脫後的長度看,它必須被拆開
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
});
it('拆片段仍切在字元邊界上,不會把跳脫序列切成半個', async () => {
const db = await seeded();
for (const p of buildContentLike(`${'%'.repeat(40)}中文${'_'.repeat(40)}`).params) {
expect(p).not.toContain('\uFFFD');
// 切壞的跳脫序列(尾巴是落單的 `\`)會讓 SQLite 把後面的 `%` 讀成字面 ⇒ 語意錯掉
expect(/(^|[^\\])(\\\\)*\\%$/.test(p)).toBe(false);
await expect(likeHits(db, p, true)).resolves.toBeDefined(); // 真的送得進 SQLite
}
});
});
+6 -2
View File
@@ -16,12 +16,16 @@ import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 上限
// 謂詞字串在 Arcrun#94 多了 ESCAPE 宣告(`content LIKE ? ESCAPE '\'`)。這裡跟著改的是
// **比對用的常數**,不是放寬檢查——底下仍然逐字相等比對,只是比的是現在正確的那個字串。
// pattern 本身('%語意檢索%')一個字都沒變:那句話裡沒有 % _ \,跳脫後與原文相同。
const LIKE_PRED = "content LIKE ? ESCAPE '\\'";
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
const m = buildContentLike('語意檢索');
expect(m.split).toBe(false);
expect(m.conds).toEqual(['content LIKE ?']);
expect(m.conds).toEqual([LIKE_PRED]);
expect(m.params).toEqual(['%語意檢索%']);
});
@@ -43,7 +47,7 @@ describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () =
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
expect(m.split).toBe(true);
expect(m.conds.length).toBeGreaterThan(1);
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
expect(m.conds.every((c) => c === LIKE_PRED)).toBe(true);
expect(m.params).toContain('%語意檢索%');
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
});
+64
View File
@@ -0,0 +1,64 @@
# 卡在人類閘前的產物(`Arcrun#89` / `#90` / `#91`
> **為什麼這個資料夾存在**:這三樣東西都做完並實測過了,但落地的最後一步是
> **終端機裡等人親手打字的互動閘**AI 打不進去。
> 2026-08-11 它們原本只存在於某個 session 的暫存目錄——**那種目錄一關就沒了**。
> 先搶進版控,等人有空時再落地。
---
## 一、兩份 recipe`#89``#90`
`recipes/gitea_put_file.yaml` — 把檔案寫回 Gitea repo。**出貨線有 7 站等它。**
`recipes/cf_worker_deploy_simple.yaml` — 部署單檔 Workerclassic 格式)。
**落地指令**(一份跑一次):
```
acr recipe push pending-human-gate/recipes/gitea_put_file.yaml
```
跑的時候會停下來要你**親手輸入資源名確認**——那是「把資源變成可被外部呼叫」的暴露同意閘,
不是卡住,是設計如此。
⚠️ **`cf_worker_deploy_simple.yaml` 先別急著推**`#90` 查出一件結構性的事——
recipe 引擎的 body 一律 JSON,而 Cloudflare 上傳 Worker 的 API 要的是原始 JS 或 multipart。
**classic 版只適用於沒有 bindings 的簡單情形**。而實查安裝器那站有 9 把 KV + 一顆 D1,
**classic 版幫不上它**。詳見 `Leo/Arcrun#90`
### 金鑰(D36
兩份 recipe 都只寫名字(`gitea_token``cf_api_token`),真身由 credential 中心在執行前回填。
對應的 auth-recipe **已經註冊在 leo21c 上**,可以直接查證:
```
curl -s https://arcrun-cypher-executor.leo21c.workers.dev/auth-recipes/gitea
```
---
## 二、`hash` 零件(`#91`
`hash-component/` — sha256sha1md5hexbase64。出貨線的版本號機制與成品指紋核對都要它。
**已實測**tinygo 編出來、wasmtime 真跑,三種演算法都跟系統原生指令**逐位元一致**)。
`.wasm` 是 1.3 MB 編譯產物,**沒有進版控**——要驗自己重編:
```
cd pending-human-gate/hash-component && tinygo build -target=wasi -o /tmp/hash.wasm main.go
echo '{"algorithm":"sha256","input":"hello"}' | wasmtime /tmp/hash.wasm
printf 'hello' | shasum -a 256 # 兩者應該一致
```
**落地要走零件投稿流程**(D27/D28):`docs/component-pr-review-standard.md` 的 checklist
人在終端機互動跑 `scripts/component-arm.sh`
🔴 `registry/components/` 底下有機械閘(`component-guard.sh`)擋著 AI 直接寫入——**那是刻意的**,
所以這份放在 `pending-human-gate/`,不是放在它最終該去的位置。
---
## 落地之後
三樣都上去之後,`Arcrun#89``#91` 才能從 **◐ 半通** 變 **✅**——
而判準是**貼一次真實的執行輸出**(recipe 對某個測試檔案回 2xx、零件在真端點上跑出正確雜湊),
不是「推上去了」。
@@ -0,0 +1,74 @@
canonical_id: "hash"
display_name: "計算雜湊"
category: "logic"
version: "v1"
wasi_target: "preview1"
stability: "floating"
runtime_compat:
- "cf-workers"
- "workerd"
- "wazero"
constraints:
max_size_kb: 2048
max_cold_start_ms: 50
no_network_syscall: true
no_filesystem_syscall: true
io_model: "stdin_stdout_json"
input_schema:
type: object
required: [input]
properties:
algorithm:
type: string
enum: [sha256, sha1, md5]
description: 雜湊演算法,預設 sha256
input:
type: string
description: 要算雜湊的內容
encoding:
type: string
enum: [hex, base64]
description: 輸出編碼,預設 hex
output_schema:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
result:
type: string
algorithm:
type: string
encoding:
type: string
gherkin_tests:
- scenario: "sha256 hex(預設)"
given: '{"algorithm":"sha256","input":"hello"}'
then_contains: '"result":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"'
- scenario: "sha1"
given: '{"algorithm":"sha1","input":"hello"}'
then_contains: '"result":"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"'
- scenario: "md5"
given: '{"algorithm":"md5","input":"hello"}'
then_contains: '"result":"5d41402abc4b2a76b9719d911017c592"'
- scenario: "base64 編碼"
given: '{"algorithm":"sha256","input":"hello","encoding":"base64"}'
then_contains: '"result":"LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="'
- scenario: "預設 algorithm=sha256"
given: '{"input":"hello"}'
then_contains: '"algorithm":"sha256"'
- scenario: "不支援的 algorithm"
given: '{"algorithm":"crc32","input":"hello"}'
then_contains: '{"success":false'
tags: [builtin, logic, hash, checksum, versioning]
description: >-
計算內容雜湊(sha256/sha1/md5,輸出 hex 或 base64)。純計算,無網路/檔案 syscall。
用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,是「改了東西版本沒動」在結構上
不可能發生的機制來源;build 站核對官方成品指紋也用它。
config_example: |
compute_hash: # 節點名稱(可自訂)
algorithm: "sha256" # 演算法(選填,預設 sha256),可選值:sha256/sha1/md5
input: "{{ctx.bundle_content}}" # 要算雜湊的內容(必填)
encoding: "hex" # 輸出編碼(選填,預設 hex),可選值:hex/base64
+89
View File
@@ -0,0 +1,89 @@
// hash — 計算內容雜湊(純計算,無網路/檔案 syscall)
// 支援: sha256, sha1, md5;輸出編碼: hex(預設), base64
// 用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,
// 是「改了東西版本沒動」在結構上不可能發生的機制來源。
//
//go:build tinygo
package main
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"os"
)
type Input struct {
Algorithm string `json:"algorithm"` // sha256(預設)| sha1 | md5
Input string `json:"input"`
Encoding string `json:"encoding"` // hex(預設)| base64
}
func main() {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
writeError("failed to read stdin: " + err.Error())
return
}
var in Input
if err := json.Unmarshal(raw, &in); err != nil {
writeError("invalid input JSON: " + err.Error())
return
}
algorithm := in.Algorithm
if algorithm == "" {
algorithm = "sha256"
}
encoding := in.Encoding
if encoding == "" {
encoding = "hex"
}
var sum []byte
switch algorithm {
case "sha256":
h := sha256.Sum256([]byte(in.Input))
sum = h[:]
case "sha1":
h := sha1.Sum([]byte(in.Input))
sum = h[:]
case "md5":
h := md5.Sum([]byte(in.Input))
sum = h[:]
default:
writeError("不支援的 algorithm: " + algorithm + "(支援 sha256/sha1/md5")
return
}
var result string
switch encoding {
case "hex":
result = hex.EncodeToString(sum)
case "base64":
result = base64.StdEncoding.EncodeToString(sum)
default:
writeError("不支援的 encoding: " + encoding + "(支援 hex/base64")
return
}
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"result": result,
"algorithm": algorithm,
"encoding": encoding,
},
})
os.Stdout.Write(out)
}
func writeError(msg string) {
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
os.Stdout.Write(out)
}
@@ -0,0 +1,11 @@
name = "arcrun-hash"
main = "src/index.ts"
compatibility_date = "2025-02-19"
workers_dev = true
[vars]
COMPONENT_ID = "hash"
[[routes]]
pattern = "hash.arcrun.dev/*"
zone_name = "arcrun.dev"
@@ -0,0 +1,21 @@
canonical_id: cf_worker_deploy_simple
display_name: Cloudflare Worker Deploy (single-file, classic format)
description: >-
PUT /accounts/{account_id}/workers/scripts/{script_name} 部署單檔 WorkerCF 「classic Service
Worker」格式,非 ES module)。_path 帶 /{account_id}/workers/scripts/{script_name}。
auth: cloudflare_workers static_keyBearer token)。
⚠️ 已知限制(誠實記錄,非隱藏債):這個 recipe 走 arcrun 的「recipe body 一律 JSON.stringify」
引擎行為(cypher-executor/src/lib/component-loader.ts makeRecipeRunner),CF 這支 API 卻要求
body 是「原始 JS 原始碼」或(現代 ES module + bindings 情境)multipart/form-data——兩者都不是
JSON。純 recipe 模型在這支 API 上天生對不上,這不是可以在 recipe schema 裡修的事。
正解=07-thin-shell §3.5 自力救濟階梯「第三方 API 缺能力→ workflow/code-node 補丁」:
用 http_request 零件直接打(body 走它的原生 string 模式,不透過本 recipe wrapper),
header 用 {{credential.cf_api_token}} 直接內插(D36 credential 模板,不必經過 recipe/auth_service
間接層);若目標 Worker 需要 bindings/compatibility_flags(現代 ES module 格式常態),
上游加一個 code 節點組出 multipart/form-data body(純資料編碼,非業務邏輯,合法局部整形)。
本 recipe 保留給「目標帳號仍接受 classic 格式」的簡單場景;不保證覆蓋所有部署情境。
endpoint: https://api.cloudflare.com/client/v4/accounts{{_path}}
method: PUT
auth_service: cloudflare_workers
headers:
Content-Type: application/javascript
@@ -0,0 +1,11 @@
canonical_id: gitea_put_file
display_name: Gitea Put File (Create/Update)
description: >-
Gitea PUT /repos/{owner}/{repo}/contents/{filepath} 建立或更新檔案並產生 commit。
_path 帶完整路徑(例 /Leo/arcrun-rag-bundles/contents/manifest.jsonfilepath 各段需 URL-encode)。
body 帶 {message, content(base64), branch, sha(更新既有檔案時必填,取自前一次 GET 的 content.sha
新建檔案時不帶)}。auth: gitea static_keyheader Authorization: token <TOKEN>D36:定義只留
{{credential.*}} 名字,真身由 credential 中心於執行前回填,非本 recipe 職責)。
endpoint: https://git.uncle6.me/api/v1/repos{{_path}}
method: PUT
auth_service: gitea
@@ -44,3 +44,34 @@ leo 否決②——「**藏書地圖就是 arcrun 的最重要功能,讓 AI
不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。
tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
`kbdb_get_map``{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。
### M3 止血(2026-08-11Arcrun#87,總管交辦「動工前的量測」comment 第四節)
**08-08 那次改法本身留了一個判準缺口,這次補上**:`ensureFreshLibraryMaps` 比對
「即時三元組數」(`liveTripletCountsByLibrary`)與「快取的地圖數」(`recomputeLibraryMap`
算出來寫進去的),但兩邊的 status 過濾不一致——`recomputeLibraryMap` 只算
`COALESCE(status,'active')='active'``liveTripletCountsByLibrary` 完全不濾 status。
只要一個庫裡混了任何一筆 superseded/deprecated triplet,兩邊數字就永遠對不上,
`ensureFreshLibraryMaps` 就永遠判定 stale ⇒ **每次讀地圖都觸發重算,每次都新建一筆
library_map recordsuperseded 舊的),無止盡寫 D1**——且加劇 `recomputeLibraryMap`
本身非原子 supersede 的既有競態(更高重算頻率 = 更高並發重算機率),是 `kb`
全部 44 筆被標 superseded、`notes` 庫兩筆同時 active`arcrun-rag#50`)這兩個症狀的
共同根因之一。
**修法**`liveTripletCountsByLibrary``kbdb/src/actions/library-map.ts`)的 SQL 改成
先 pivot 出每筆 triplet record 的 status,再套用與 `recomputeLibraryMap` 逐字一致的
`COALESCE(status,'active')='active'` 過濾,兩邊判準對齊後,資料未變動時兩個計數必然相等,
stale 判定回歸「真的有資料變動才 stale」。
**驗證**:新增迴歸案「Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次
觸發重算」(`kbdb/tests/library-map.test.ts`,19/19 全綠);反向驗證過——把同一顆測試跑在
修前的舊 SQL 上會失敗(`library_map` record 數 2 vs 期望 1),證明測試真的釘住這個 bug、
不是空氣測試。另外用 leo21c MCP 連線(`bfezv28v`)連讀兩次 `kbdb_get_map()`(無中間寫入)
獨立重現修前症狀:`general``updated_at``1786457080` 前進到 `1786457114`
**尚待**:改動只在分支 `fix/library-map-recompute-loop-87-v3`(未 push、未部署 leo21c);
既有 100 筆 library_map 殘骸(`kb` 44 筆 superseded`general` 41`notes` 2)未清——
清除需要一個目前不存在的 DELETE 通道(cypher-executor 的 `/kbdb/records/:id` proxy 只有
GET/POST/PATCH,無 DELETEkbdb base 自己雖有 `DELETE /records/:recordId` 但走 leo21c
需要 `KBDB_INTERNAL_TOKEN`,非 CC 可持有的機密)——待總管部署本修法+視情況補一支
DELETE proxy 後再清。