Compare commits

..

25 Commits

Author SHA1 Message Date
uncle6me-web 8e9bd09072 fix(engine): 回應太大就說「回應太大」——不再冒充「請求失敗」(Arcrun#92)
真因:零件(main.go)給 host function 的接收緩衝區是固定大小(http_request 64KB、
claude_api 1MB)。回應超過這個大小時,wasi-shim 的 writeOut 照寫不誤:

    new Uint8Array(buf, outPtr, data.length).set(data)

data 比零件的 outBuf 大 → 覆寫零件堆積體,零件接著 outBuf[:outLen] 切片 panic;
或 writeOut 撞 memory 邊界丟例外 → 回 1 → 零件印一句 "HTTP request failed"。
使用者照那句去查連線/URL/防火牆,方向全錯。

修法(容量握手,不改 host function 簽名、向後相容):
- 零件呼叫前把 outBuf 長度預先寫進 *outLenPtr(宣告容量)
- host 在寫回前讀這個值當上限;塞不下就**不寫**(不再覆寫零件記憶體),回新的
  HOST_TOO_LARGE=3
- http_request host fn 收到 3 → 改寫一段講真話的 error envelope(實際大小+上限+
  「不是連線失敗」+分頁/篩選的具體做法+機器可讀 code/actual_bytes/limit_bytes),
  沿用既有 parsed["error"] 判定鏈原樣送到使用者面前
- 舊零件沒宣告容量(讀到 0)→ 維持舊行為。不可硬套 64KB 預設:各零件緩衝區大小不同,
  硬套會把原本正常的大回應誤判成「太大」,那只是換一種說謊

同一條路徑上另一個「訊息與真因脫節」一併修:component-loader 的 makeHttpRunner
`try res.json() catch res.text()`,在零件回非 JSON 時 body 已被消費 → 丟
"Body has already been used",與真因無關(同檔 readBodyOnce 的註解早就寫明這個坑)。
改成只讀一次。

驗證狀態(誠實標示,mindset §7):
- 通:5 顆零件 tinygo build 全過,wasm 已重編進 .component-builds/
  (claude_api 依 .gitignore 慣例不入庫,由部署端重編)
- 未跑:runtime 驗證。本 session 的權限層擋掉 node/vitest/wasmtime,
  before/after 實測輸出待人跑 scripts/repro-oversize-response.mjs
- 未做:.worker-builds/ 重編(需 node scripts/build-worker-artifacts.mjs),
  否則修法不會進 self-hosted 安裝路徑(Arcrun#93 同款陷阱)

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

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

arcrun-cypher-executor  source a5e4caff1370e22  sha256=8411ed59b7ad

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

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

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

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

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

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

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

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

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

誠實標記:驗證是本機模擬(假 CF API),**沒有在真機上跑過**。
2026-08-12 13:36:17 +08:00
uncle6me-web a5e4caf5cb fix(portal): 讀不到就說讀不到——總圖不再把「讀不到」畫成「你沒有」(Arcrun#100)
leo 2026-08-12 打開總圖看到:「0 個實體 · 0 條關聯/知識庫還沒有任何關聯——
上傳文件後 AI 會自動織網」。**而他庫裡有 1854 條三元組。**
那句話會叫他去做一件不需要做的事。

三個獨立的洞疊起來才變成那句謊:
  ① console-dashboard.ts 打 kbdb-graph-plugin 沒帶認證(同段落打 kbdb 的兩支都有帶)
  ② 那顆 worker 不在更新的部署清單裡 ⇒ token 一換它就落單
  ③ **畫面把 null 畫成 0**——後端已經誠實回 null 了,是前端把它變成謊話

為什麼一直沒被發現:graph plugin 原本身上沒有 token ⇒ 門開著 ⇒ 沒帶也進得去。
2026-08-12 輪替後它有了 token,門關上,401 才浮出來。

📍 repo:matrix/arcrun(cypher-executor/src/routes/、console-ui/public/)
📍 票:Leo/Arcrun#100
2026-08-12 13:33:53 +08:00
uncle6me-web 45a546a686 fix(cli): 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。
使用者的資源是安裝器建的(`arcrun-rag-<x>-kv-webhooks`),更新找的是 `WEBHOOKS`
⇒ 找不到 ⇒ 新建一顆空的並綁上去。

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

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

📍 repo:matrix/arcrun(cli/)|📍 票:Leo/Arcrun#97
2026-08-12 13:30:17 +08:00
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 37e13fc9bf merge: 併入 main 最新(b6ef0f0 三元組 library 補標 proxy+測試)保持分支不落後 2026-08-11 18:46:03 +08:00
uncle6me-web 674e1b4fa2 fix(kbdb): D69 節流世代核對+標庫共用 D1 額度,補「挑哪一批」的統一 SelectionCriteria(Arcrun#85)
leo 逐行複核 fix/embed-backfill-d68 後點出的破口+二次裁決(票上全文見 Leo/Arcrun#85):

一、向量化優先序(今天寫的立刻/本週在跑的先跑/有查詢紀錄的庫優先/半年前慢慢跑)表達
不出來——策略要能從外面(工作流)指定,不能焊死在資料層。新增 embed.ts 的
`SelectionCriteria`(owner_id/source/library/since/until),backfillEmbeddings 與
reconcileEmbedGeneration 共用同一套形狀;「按庫」那一層現在有資料可用即可運作(見下)。

二、世代核對(reconcileEmbedGeneration)不打 AI 但逐筆寫 D1,47 萬筆候選 ≈ 4.7 倍 D1
100,000 rows/日免費額度,先前零保護。新增 actions/maintenance-quota.ts(單一 entries
列/日的共用計數器,精神同 execution-log.ts/embed.ts 既有慣例,不新增表)。

三、leo 二度裁決:「標庫」與「時間分層」其實是一件事,判定標準要從第一天同時容納兩者,
不能先做一半再回頭改。新增 actions/library-backfill.ts 的 backfillEntryLibraryTags——
呼叫端(ingest/daemon/Arcrun#87)決定要貼哪個庫、用 page_names(Gitea 原稿卡名精準
點名,leo 定案的正解)或 source_prefix/page_name_prefix 過渡 fallback 篩選候選,base
只負責安全、節流地寫入。owner_id 刻意必填(leo 點出「補錯 owner 等於白做」——實查卡片
掛在 owner_id=bfezv28v,換成 'leo' 查卻是空的)。

D69:reconcile 與標庫 backfill 共用同一顆「今天還剩多少 D1 寫入額度」計數器(不共用的話
其中一個會把另一個的閘繞過去);新增 POST /entries/backfill-library + GET .../status,
擴充 POST /embed/backfill 與 /embed/reconcile 吃 library/since/until 參數。

測試:92 → 39 個新增/擴充案例覆蓋 since/until/library 篩選、reconcile 額度真的擋
(含「拿掉 cap 會變紅」反向驗證)、標庫 backfill 冪等/owner_id 必填/page_names 精準比對、
以及兩個操作共用同一顆額度計數器的跨模組驗證(雙向:先 reconcile 耗盡再標庫、反之亦然)。
kbdb 全套 192 個測試綠燈,tsc --noEmit 除既有 auth.test.ts 舊缺陷外無新增錯誤。

紅線:未併 main、未部署、未動任何實例的 is_embedded 旗標(只在本地 SQLite 測試治具跑過)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:39:55 +08:00
uncle6me-web 1d6dde4a01 fix(kbdb/embed): D68 補算向量照新到舊排序+每日額度上限+修復舊世代 is_embedded 誤判
leo 2026-08-11 拍板(D68,system-dev/wiki/decisions-summary.md):補算向量要照時間
由新到舊、且每天有額度上限,不能一次把 Workers AI 每日免費 10,000 neurons 燒光
(與萃取共用同一份額度,見 ops-facts.md)。對應 Leo/Arcrun#85 列出的三個缺口:
① 補算是由舊到新(ORDER BY created_at ASC)② 沒有每日額度上限 ③ 沒有任何自動觸發。

改動:
- backfillEmbeddings:ORDER BY created_at DESC(新到舊),並在打 AI 前依
  env.EMBED_BACKFILL_DAILY_LIMIT(未設用推導出的預設值 1800,算式見 embed.ts 註解)
  截斷候選、額度用完即停手不再打 AI。額度用量存在 entries 表單一列
  (entry_type='embed_backfill_usage',UTC 日期切),不新增表(D38)。
- embedOnWrite / backfillEmbeddings 成功嵌入後在既有 content_hash 欄位蓋上
  現行模型名(世代戳記),修復 leo21c 資料還原案:從備份整批灌回的列帶著對已退役
  768 維索引的 is_embedded=1,現行 1024 維索引永遠不會補到它們。
- 新增 reconcileEmbedGeneration + POST /embed/reconcile:對 is_embedded=1 但
  content_hash 非現行世代的候選,問 Vectorize.getByIds 是否真的在現行 index——
  在→只補 content_hash 不打 AI;不在→重置 is_embedded=0 交回正常 backfill 佇列。
- 新增 kbdb/tests/embed-backfill.test.ts(改走真 SQLite,比舊版手刻假 DB 更硬):
  14 個測試涵蓋新到舊排序、額度真的擋(含「拿掉 cap 會變紅」的反向驗證)、
  世代核對端到端(reconcile → 重置 → backfill 真的補回來)、既有行為不迴歸。

現況誠實回報:目前沒有任何東西會自動觸發補算(無 cron/scheduled handler)——
唯一的「自動」路徑是 entries.ts 的語意搜尋回 0 命中時 fire-and-forget 觸發一次
(既有行為,本次未改動),仍需人或 CC 主動呼叫 /embed/backfill 或掛排程。

紅線:未動 leo 正式實例 leo21c;未動資料層形狀(三表不變,仍走既有 content_hash
bookkeeping 欄);未 push main,本 commit 在獨立分支 fix/embed-backfill-d68。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:42:54 +08:00
59 changed files with 5099 additions and 550 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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
};
+194 -33
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, 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);
}
@@ -2645,7 +2650,7 @@ var init_recipes = __esm({
});
// cypher-executor/src/lib/constants.ts
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, BUILTIN_COMPONENTS;
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, WAIT_MAX_MS, BUILTIN_COMPONENTS;
var init_constants3 = __esm({
"cypher-executor/src/lib/constants.ts"() {
"use strict";
@@ -2693,6 +2698,7 @@ var init_constants3 = __esm({
"CLICK": "ON_CLICK",
"SUBFLOW": "CALLS_SUBFLOW"
};
WAIT_MAX_MS = 3e4;
BUILTIN_COMPONENTS = /* @__PURE__ */ new Map([
["comp_passthrough", (ctx) => ctx],
["comp_uppercase", (ctx) => {
@@ -2702,6 +2708,54 @@ var init_constants3 = __esm({
["comp_counter", (ctx) => {
const c = ctx;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
["wait", async (ctx) => {
const c = ctx && typeof ctx === "object" ? ctx : {};
const requested = typeof c.ms === "number" ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: "ms \u5FC5\u9808\u5927\u65BC 0" };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
await new Promise((resolve) => setTimeout(resolve, ms));
const passthrough = c.context && typeof c.context === "object" && !Array.isArray(c.context) ? c.context : {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}]
]);
}
@@ -3022,7 +3076,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";
@@ -3055,7 +3109,12 @@ var init_component_loader = __esm({
filter: "SVC_FILTER",
merge: "SVC_MERGE",
try_catch: "SVC_TRY_CATCH",
wait: "SVC_WAIT",
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: "SVC_SET",
array_ops: "SVC_ARRAY_OPS",
string_ops: "SVC_STRING_OPS",
@@ -3065,6 +3124,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
]);
}
});
@@ -3134,6 +3199,11 @@ function graphBase(env) {
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, "");
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
function graphHeaders(env) {
const headers = {};
if (env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return headers;
}
var kbdbProxyRouter, NEED_KEY;
var init_kbdb_proxy = __esm({
"cypher-executor/src/routes/kbdb-proxy.ts"() {
@@ -3201,6 +3271,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);
@@ -3253,8 +3337,7 @@ var init_kbdb_proxy = __esm({
kbdbProxyRouter.get("/kbdb/graph/neighbors/:name", async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param("name"))}`, { headers });
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
@@ -7355,7 +7438,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 +7612,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 +7840,7 @@ var init_recipe_loader = __esm({
super(message);
this.recipe = recipe;
}
recipe;
};
}
});
@@ -8763,7 +8847,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 +8889,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 +8913,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 +9378,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 +9570,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;
@@ -14500,6 +14595,20 @@ async function fetchJson(url, headers) {
return null;
}
}
async function fetchTripletTotal(env, tenant2) {
const { base, headers } = kbdbBase(env);
const data = await fetchJson(
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant2)}`,
headers
);
if (!data || !Array.isArray(data.stats)) return null;
let total = 0;
for (const row of data.stats) {
if (typeof row?.triplet_count !== "number") return null;
total += row.triplet_count;
}
return total;
}
async function fetchEntryTotal(env, filters) {
const { base, headers } = kbdbBase(env);
const params = new URLSearchParams({ ...filters, limit: "1" });
@@ -14607,6 +14716,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal
@@ -14618,7 +14728,13 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)),
fetchJson(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson(`${graphUrl}/triplets/stats`),
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
fetchJson(
`${graphUrl}/triplets/stats`,
graphHeaders(c.env)
),
fetchTripletTotal(c.env, tenant2),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant2 }),
fetchEntryTotal(c.env, { entry_type: "wiki_card", owner_id: tenant2 }),
@@ -14726,13 +14842,14 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
system: {
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
embed: embedStatus ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } : null,
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
graph: { ok: graphStats !== null, triplets: tripletTotal },
workflow_total: workflowTotal
},
kb: {
entries_total: entriesTotal,
wiki_card_total: wikiCardTotal,
triplets_total: graphStats?.total ?? null
triplets_total: tripletTotal
},
generated_at: new Date(now2).toISOString()
});
@@ -14740,22 +14857,22 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now2 = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson(
`${base}/entries?${new URLSearchParams({ owner_id: tenant2, entry_type: "wiki_card", limit: "1" }).toString()}`,
headers
),
fetchJson(`${graphUrl}/triplets/stats`),
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
fetchTripletTotal(c.env, tenant2),
fetchJson(`${base}/embed/backfill/status`, headers)
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
return c.json({
wiki_card_total: typeof wikiCards?.total === "number" ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now2, latestMs),
triplets_total: typeof graphStats?.total === "number" ? graphStats.total : null,
triplets_total: tripletTotal,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now2).toISOString()
@@ -14907,6 +15024,27 @@ function findBestNodeMatch(searchTerm, nodeNames) {
if (hits.length === 0) return null;
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
async function tripletCount(env, owner) {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
if (!res.ok) return null;
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.stats)) return null;
let total = 0;
for (const row of body.stats) {
if (typeof row?.triplet_count !== "number") return null;
total += row.triplet_count;
}
return total;
} catch {
return null;
}
}
async function tripletCensus(env, tenant2) {
const owned = await tripletCount(env, tenant2);
if (owned !== 0) return { owned, any: null };
return { owned, any: await tripletCount(env, "") };
}
async function fuzzyFindNode(env, tenant2, searchTerm) {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
@@ -15008,8 +15146,7 @@ portalDataRouter.get(
return c.json(mapGraphWorkflowOutput(result.data));
}
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
@@ -15044,12 +15181,19 @@ portalDataRouter.get(
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
}
const tenant2 = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`),
tripletCensus(c.env, tenant2)
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
const body = await res.json().catch(() => null);
const records = body && Array.isArray(body.records) ? body.records : [];
if (!body || !Array.isArray(body.records)) {
return c.json({ error: "\u4E09\u5143\u7D44\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = /* @__PURE__ */ new Set();
const edges = [];
@@ -15075,7 +15219,24 @@ portalDataRouter.get(
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
let emptyReason = null;
if (nodes.length === 0) {
if (census.owned === null) emptyReason = "unreadable";
else if (census.owned > 0) emptyReason = "scope_mismatch";
else if (census.any === null) emptyReason = "unreadable";
else emptyReason = census.any > 0 ? "scope_mismatch" : "confirmed_empty";
}
return c.json({
nodes,
edges,
node_count: nodes.length,
edge_count: edges.length,
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
truncated: truncated || records.length >= 500,
triplets_total: tripletsTotal,
empty_confirmed: nodes.length > 0 || emptyReason === "confirmed_empty",
empty_reason: emptyReason
});
})
);
portalDataRouter.get(
@@ -15257,10 +15418,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-12T07:29:58.626Z",
"repo_head": "1791ffa4972b4135dacd4208e805f67b747479c4",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 568855,
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
"js_bytes": 577374,
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
"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;
}
});
@@ -277,9 +277,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} else {
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
}
rows.push(sys.graph && sys.graph.ok
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
: sysRow('知識圖譜', '● 打不通', 'bad'));
// Arcrun#100:「服務活著嗎」與「庫裡有幾條」拆兩列。混一列時,圖服務打不通會把
// 「其實有 1854 條」整個吞掉,畫面看起來就像知識庫是空的。數字讀不到寫「讀不到」,不寫 0。
var gOk = !!(sys.graph && sys.graph.ok);
var tri = sys.graph && sys.graph.triplets != null ? sys.graph.triplets : null;
rows.push(sysRow('知識圖譜服務', gOk ? '● 正常' : '● 打不通', gOk ? 'ok' : 'bad'));
rows.push(sysRow('三元組(關聯)', tri == null ? '讀不到' : tri.toLocaleString() + ' 條', tri == null ? 'off' : ''));
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
// 三元組/已嵌入 已各有一列)
+5 -4
View File
@@ -934,14 +934,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
fetch(API_BASE + '/console/kb-scale-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
// #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上)
if (!d) { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; return; }
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
var latest = d.wiki_card_latest_ago_minutes;
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
})
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
.catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; });
}
$('se-sem').addEventListener('click', function () {
S.semantic = !S.semantic;
@@ -1504,7 +1505,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
]).then(function (rs) {
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
var rows = '';
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
+52 -31
View File
@@ -1108,7 +1108,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
})();
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
// ── t53 完成安裝清單(進站必見,做完才消失)───────────────────────────────
// 件數演進:t53 三件 → t54 兩件(設定檔改由小幫手憑帳密自取)
// → arcrun-rag#81 一件(AI 問答改走 Workers AI,不再要用戶自備金鑰)。
function setupSteps() {
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
}
@@ -1123,7 +1125,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var p = S.profile || {};
if (p.role !== 'admin') return;
var s = setupSteps();
if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
// arcrun-rag#81leo 08-12:「已經改用 workers AI,刪掉」):只剩「下載小幫手」一件。
// 舊的 s.key(貼 Google AI 金鑰)已整條移除,見下方 innerHTML 處的說明。
// 相容:舊瀏覽器 localStorage 裡殘留的 s.key 不再被讀 —— 沒設過 key 的人也不會被卡住。
if (s.daemon) return; // t54 起設定改由小幫手輸入帳密自取,不再下載檔案
var cfg = window.ARCRUN_CONFIG || {};
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
@@ -1134,7 +1139,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var el = document.createElement('div');
el.id = 'setup-checklist';
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
el.innerHTML = '<b>還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
// 🔴 arcrun-rag#81leo 08-12):第二項「啟用 AI 問答(貼 Google AI 金鑰)」整條刪除。
// 為什麼不是「一個沒用的欄位」而已:它長在**安裝完成清單裡而且是勾選項**
// ⇒ 用戶會以為不做這步就沒裝完,而它要人離開流程、去第三方網站申請帳號、
// 把金鑰貼進表單——整條安裝路徑上最重的一個動作,而且是白做的。
// 真相:雲端問答走 t18108-04)改好的 Workers AI`env.AI` binding,免金鑰)——
// /portal/data/chat → tenant 的 rag_chat workflow → `workers_ai_chat` recipe
// api-recipe-seeds.ts:150endpoint `@cf/meta/llama-4-scout-17b-16e-instruct`)。
// 這裡貼的金鑰是打 POST /portal/admin/ai 存一筆雲端 gemini_api_key credential
// **現行問答鏈路一個地方都沒有讀它**。
// 後端 route 本身不動(同 08-09 拿掉檢修孔按鈕的處置:端點無害、已無任何 UI 呼叫,
// 純粹清路標);已經存過金鑰的人那筆 credential 也原封不動,不做刪除。
// ⚠️ 別把這個跟設定頁「AI 設定」面板講的地端萃取金鑰搞混——那把是填在
// **同步小幫手**托盤裡的,從來不經過這個清單。
el.innerHTML = '<b>還差 1 步,安裝就真的完成了</b>'
+ row(s.daemon, 'daemon',
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
+ (daemonUrl
@@ -1156,34 +1174,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
+ '<div class="muted" style="font-size:12.5px;margin-top:4px">裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。</div>')
+ row(s.key, 'key',
'<b>啟用 AI 問答</b><a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
document.body.appendChild(el);
var dl = document.getElementById('sc-daemon');
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
// t54config.json 下載鈕已移除(設定由小幫手憑帳密自取)
var kb = document.getElementById('sc-key-save');
if (kb) kb.addEventListener('click', function () {
var k = (document.getElementById('sc-key').value || '').trim();
var m = document.getElementById('sc-key-msg');
if (!k) { m.textContent = '請先貼上金鑰'; return; }
kb.disabled = true; m.textContent = '啟用中…';
fetch(API_BASE + '/portal/admin/ai', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ gemini_api_key: k })
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
kb.disabled = false;
if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; }
markStep('key');
})
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
});
// arcrun-rag#81:金鑰輸入框的送出邏輯(POST /portal/admin/ai)一併移除——
// 只藏畫面留著那條路,等於這個要求還在,只是變得更難發現。
var later = document.getElementById('sc-later');
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
}
@@ -1486,7 +1483,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
$('se-q').value = name;
doGraphSearch(name);
}
// 讀不到就明說「讀不到」——標題列**絕不**留著 0 或舊數字(Arcrun#100leo 看到
// 「0 個實體・0 條關聯」以為要去上傳文件,其實庫裡有 1854 條,只是這支讀失敗了)。
function mapUnavailable(html) {
$('map-meta').textContent = '讀不到';
$('map-box').innerHTML = '<div class="err" style="padding:30px 10px">' + html + '</div>';
}
function loadMap() {
$('map-meta').textContent = '';
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
$('map-md-link').innerHTML = SOURCE_WEB_BASE
? '<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
@@ -1495,14 +1499,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + '')) + '</div>'; return; }
var nodes = x.d.nodes || [];
var edges = x.d.edges || [];
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
if (!x.ok) { mapUnavailable(esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ''))); return; }
// Arcrun#100:「0」只准在後端確認過真的是 0 的時候出現。
// 形狀不對 → 讀不到(不是空庫);nodes 為空但 empty_confirmed 不成立 → 讀不到。
if (!Array.isArray(x.d.nodes) || !Array.isArray(x.d.edges)) {
mapUnavailable('總圖回應格式不對——沒有拿到關聯資料。這不代表知識庫是空的。');
return;
}
var nodes = x.d.nodes, edges = x.d.edges;
var total = typeof x.d.triplets_total === 'number' ? x.d.triplets_total : null;
if (!nodes.length && x.d.empty_confirmed !== true) {
mapUnavailable(x.d.empty_reason === 'scope_mismatch'
? '讀不到你這個帳號的關聯資料——知識庫裡有三元組'
+ (total ? '(本帳號範圍算到 ' + total.toLocaleString() + ' 條)' : '')
+ ',但這張圖一條都抽不出來。<br>'
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>;比較像資料的歸屬範圍對不上,請通知管理員。'
: '讀不到知識庫的關聯資料,無法確認庫裡有沒有關聯。<br>'
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>——是這次讀取失敗,請稍後重整或通知管理員。');
return;
}
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯'
+ (total !== null && x.d.truncated ? '(全庫共 ' + total.toLocaleString() + ' 條,已達單次上限)' : x.d.truncated ? '・已達上限截斷' : '');
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
renderMap(nodes, edges);
})
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
.catch(function (e) { mapUnavailable('請求失敗:' + esc(friendlyErr(e))); });
}
function renderMap(nodes, edges) {
var N = nodes.length;
+13 -3
View File
@@ -77,7 +77,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
filter: 'SVC_FILTER',
merge: 'SVC_MERGE',
try_catch: 'SVC_TRY_CATCH',
wait: 'SVC_WAIT',
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: 'SVC_SET',
array_ops: 'SVC_ARRAY_OPS',
string_ops: 'SVC_STRING_OPS',
@@ -268,8 +273,13 @@ function makeHttpRunner(url: string): ComponentRunner {
const text = await res.text();
return { success: false, status: res.status, error: text.slice(0, 200) };
}
try { return await res.json(); }
catch { return { success: true, data: await res.text() }; }
// 只讀一次 body(同檔 readBodyOnce 的註解已寫明這個坑,這裡以前卻正好踩到):
// 舊寫法 `try { res.json() } catch { res.text() }` 在零件回非 JSON 時,
// res.json() 失敗當下 body 已被消費 → 第二次讀丟 "Body has already been used"
// 使用者看到的是這句跟真因(零件回了非 JSON)完全無關的訊息(Arcrun#92 同類)。
const text = await res.text();
try { return JSON.parse(text); }
catch { return { success: true, data: text }; }
};
}
+62
View File
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
'SUBFLOW': 'CALLS_SUBFLOW',
};
/**
* wait registry/components/wait/component.contract.yaml
* **調**Arcrun#101
*
*/
export const WAIT_MAX_MS = 30000;
/**
*
* WASM = Workercypher-executor HTTP URL R2
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
const c = ctx as Record<string, unknown>;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
['wait', async (ctx) => {
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: 'ms 必須大於 0' };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
await new Promise<void>((resolve) => setTimeout(resolve, ms));
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
? c.context as Record<string, unknown>
: {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}],
]);
export const SCORE_THRESHOLD = 0.5;
+106 -8
View File
@@ -27,6 +27,21 @@ export interface ArcrunHostEnv {
const WASI_ESUCCESS = 0;
const WASI_ENOSYS = 76;
// ── host function 回傳碼(u6u.*)─────────────────────────────────────────────
// 零件(main.go)用同一組數字判斷,改這裡要同步改 registry/components/*/main.go。
export const HOST_OK = 0;
/** host 端出錯(memory 不可用 / 例外)— 零件無從得知細節 */
export const HOST_ERROR = 1;
/** 查無此 key / refkv_get、secret_get 用) */
export const HOST_NOT_FOUND = 2;
/**
* Arcrun#92********
* data outBuf
* `outBuf[:outLen]` panic writeOut memory 1
* "HTTP request failed"使
*/
export const HOST_TOO_LARGE = 3;
// fd 常數
const FD_STDIN = 0;
const FD_STDOUT = 1;
@@ -75,6 +90,51 @@ export interface WasiHostFunctions {
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
}
/**
* Arcrun#92
* declaredCapacity === 0 host
* http_request 64KBclaude_api 1MB
*
*/
export function outFitsCapacity(declaredCapacity: number, dataLength: number): boolean {
return declaredCapacity === 0 || dataLength <= declaredCapacity;
}
/** 位元組數轉人看得懂的單位(訊息裡要出現真實數字,不能只說「太大」) */
export function formatBytes(n: number): string {
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
return `${n} bytes`;
}
/**
* Arcrun#92 error envelope
*
*
* 1. ****
* 2. ****使
* 3. ****
* code / actual_bytes / limit_bytes
*
* status 0 413 413 413
* fetch envelope 0 = HTTP
*/
export function oversizeResponseEnvelope(actualBytes: number, limitBytes: number) {
return {
error:
`回應太大,裝不下:對方回了 ${formatBytes(actualBytes)}` +
`超過這個零件單次能接收的 ${formatBytes(limitBytes)} 上限。` +
`這不是連線失敗,資料也沒有被截掉一半——是整包放不進零件。` +
`做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試;` +
`真的需要整包資料時,改成分頁多抓幾次、每次處理一批。`,
code: 'response_too_large',
actual_bytes: actualBytes,
limit_bytes: limitBytes,
status: 0,
body: '',
};
}
/**
* WASI shim
* @param stdinData - stdin UTF-8 JSON.stringify(input)
@@ -95,14 +155,34 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
}
// 寫入結果到 WASM 的 outPtr bufferhost function 共用)
// 回傳 0 = 成功,1 = memory 不可用
// 回傳 HOST_OK / HOST_ERROR / HOST_TOO_LARGE
//
// 容量握手(Arcrun#92):零件在呼叫 host function 前,把自己 outBuf 的長度預先寫進
// *outLenPtrhost 在寫回前讀這個值當容量上限。塞不下就**不寫**(避免覆寫零件記憶體)
// 並回 HOST_TOO_LARGE,讓上層改寫一段講真話的訊息。
//
// 舊零件(沒做握手)讀到 0 = 「未宣告容量」→ 維持舊行為。這裡不能自作聰明假設 64KB:
// 各零件緩衝區大小不同(http_request 64KB、claude_api 1MB),統一硬套會把原本
// 跑得好好的大回應誤判成太大。
function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number {
try {
const view = new DataView(buf);
const declaredCapacity = view.getUint32(outLenPtr, true);
if (!outFitsCapacity(declaredCapacity, data.length)) return HOST_TOO_LARGE;
new Uint8Array(buf, outPtr, data.length).set(data);
new DataView(buf).setUint32(outLenPtr, data.length, true);
return 0;
view.setUint32(outLenPtr, data.length, true);
return HOST_OK;
} catch {
return 1;
return HOST_ERROR;
}
}
/** 讀零件宣告的緩衝區容量(0 = 舊零件沒宣告) */
function declaredCapacityOf(buf: ArrayBuffer, outLenPtr: number): number {
try {
return new DataView(buf).getUint32(outLenPtr, true);
} catch {
return 0;
}
}
@@ -352,12 +432,28 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
try {
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffergrow 會產生新的 ArrayBuffer
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
const encoded = new TextEncoder().encode(result);
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
if (status !== HOST_TOO_LARGE) return status;
// Arcrun#92:回應塞不進零件緩衝區。以前這裡會硬寫(覆寫零件記憶體)或回 1,
// 零件對外只講得出 "HTTP request failed"——訊息與真因脫節。
// 現在改寫一個講真話的 error envelope(零件既有的 parsed["error"] 判定鏈
// 會原樣帶到使用者面前,不必改零件也能講對原因)。
const capacity = declaredCapacityOf(memory.buffer, outLenPtr);
const envelope = new TextEncoder().encode(
JSON.stringify(oversizeResponseEnvelope(encoded.length, capacity)),
);
const envStatus = writeOut(memory.buffer, outPtr, outLenPtr, envelope);
// 連這段說明都塞不下(緩衝區極小)→ 回 3,由零件自己講「回應太大」
return envStatus === HOST_OK ? HOST_OK : HOST_TOO_LARGE;
} catch (e) {
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
// 取代只 return 1WASM 寫無資訊的 "HTTP request failed")。
// writeOut 失敗(memory 壞)才 fallback return 1。
const errDetail = e instanceof Error ? e.message : String(e);
// 訊息截到 200 字:這段本身若超過零件緩衝區會被判成 HOST_TOO_LARGE
// 零件就會把「連不上」說成「回應太大」——又一次訊息與真因脫節(Arcrun#92)。
const errDetail = (e instanceof Error ? e.message : String(e)).slice(0, 200);
const errEnv = new TextEncoder().encode(
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
);
@@ -366,7 +462,8 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
})
: () => 1,
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
// kv_get(keyPtr, keyLen, outPtr, outLenPtr)
// → 0 成功;1 錯誤;2 找不到 key;3 值太大塞不進零件緩衝區(Arcrun#92)
kv_get: hostFunctions?.kv_get
? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) { console.error('[kv_get] memory null'); return 1; }
@@ -387,7 +484,8 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
})
: () => 1,
// secret_get(refPtr, refLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 ref
// secret_get(refPtr, refLen, outPtr, outLenPtr)
// → 0 成功;1 錯誤;2 找不到 ref;3 值太大塞不進零件緩衝區(Arcrun#92)
// 與 kv_get 同款 pointer/memory-write 機制;差別只在 host 端實作來源(env[ref] 而非 KV.get)。
secret_get: hostFunctions?.secret_get
? hostWrap(async (refPtr: number, refLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
@@ -48,7 +48,7 @@
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase, graphBase } from './kbdb-proxy';
import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import {
type KbdbEntry,
@@ -104,6 +104,31 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
}
}
/**
* ****null = 0
*
* 🔴 Arcrun#100 graph-plugin `/triplets/stats` `total`
* `total` **** COUNT `/records/by-template/triplet`KBDB
* `searchByTemplate` limit=100 500 owner 1854
* 100 401 0100
* KBDB `/records/triplet-stats` SQL COUNT(*) owner_id
* `{ success, stats: [{ library, triplet_count }] }`
*/
async function fetchTripletTotal(env: Bindings, tenant: string): Promise<number | null> {
const { base, headers } = kbdbBase(env);
const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>(
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`,
headers,
);
if (!data || !Array.isArray(data.stats)) return null;
let total = 0;
for (const row of data.stats) {
if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到,不半信半疑加總
total += row.triplet_count;
}
return total;
}
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
const { base, headers } = kbdbBase(env);
@@ -254,6 +279,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal,
@@ -265,7 +291,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(
`${graphUrl}/triplets/stats`,
graphHeaders(c.env),
),
fetchTripletTotal(c.env, tenant),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant }),
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
@@ -400,13 +432,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
embed: embedStatus
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
: null,
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
graph: { ok: graphStats !== null, triplets: tripletTotal },
workflow_total: workflowTotal,
},
kb: {
entries_total: entriesTotal,
wiki_card_total: wikiCardTotal,
triplets_total: graphStats?.total ?? null,
triplets_total: tripletTotal,
},
generated_at: new Date(now).toISOString(),
});
@@ -420,15 +453,15 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
headers,
),
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
fetchTripletTotal(c.env, tenant),
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
@@ -436,7 +469,7 @@ consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
return c.json({
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
triplets_total: tripletTotal,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now).toISOString(),
+16 -2
View File
@@ -223,13 +223,27 @@ export function graphBase(env: Bindings): string {
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
/**
* kbdb-graph-plugin internal headers** plugin **Arcrun#100
*
* plugin kbdb-graph-plugin/src/index.ts `/triplets` `/graph` `/search` `/entities`
* Bearer KBDB_INTERNAL_TOKEN 401
* neighborsportal-data neighborsconsole-dashboard stats
* `/triplets/stats` 401 0
*
*/
export function graphHeaders(env: Bindings): Record<string, string> {
const headers: Record<string, string> = {};
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return headers;
}
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
+74 -6
View File
@@ -24,7 +24,7 @@ import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
import { graphBase } from './kbdb-proxy';
import { graphBase, graphHeaders } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
@@ -186,6 +186,45 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
/**
* KBDB `/records/triplet-stats` SQL COUNTowner '' KBDB
* `?1 = '' OR e.owner_id = ?1`nullcaller 0
*/
async function tripletCount(env: Bindings, owner: string): Promise<number | null> {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
if (!body || !Array.isArray(body.stats)) return null;
let total = 0;
for (const row of body.stats) {
if (typeof row?.triplet_count !== 'number') return null;
total += row.triplet_count;
}
return total;
} catch {
return null;
}
}
/**
* Arcrun#100
*
* leo portal.ts §.5 daemon diagnostics**
* **t161 record owner_id None owner_id
* 0 ****
* owner
* owned>0
* owned=0 any=0 0
* owned=0 any>0 owner_id /
* owned=null
*/
async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> {
const owned = await tripletCount(env, tenant);
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
return { owned, any: await tripletCount(env, '') };
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
try {
@@ -343,8 +382,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
// ② plugin fallbackMira/leo21c 相容)
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
@@ -383,14 +421,23 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
const tenant = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`),
tripletCensus(c.env, tenant),
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown> }[] }
| null;
const records = body && Array.isArray(body.records) ? body.records : [];
// #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖,
// 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
if (!body || !Array.isArray(body.records)) {
return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = new Set<string>();
const edges: { subject: string; predicate: string; object: string }[] = [];
@@ -413,7 +460,28 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
// #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)——
// confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」
// scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科)
// unreadable :連條數都讀不到 → 只能說讀不到
let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
if (nodes.length === 0) {
if (census.owned === null) emptyReason = 'unreadable';
else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
else if (census.any === null) emptyReason = 'unreadable';
else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
}
return c.json({
nodes,
edges,
node_count: nodes.length,
edge_count: edges.length,
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
truncated: truncated || records.length >= 500,
triplets_total: tripletsTotal,
empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
empty_reason: emptyReason,
});
}),
);
@@ -0,0 +1,147 @@
/**
* Arcrun#100 0 0
*
* leo 0 0 AI
* 1854
*
*
* kbdb-graph-plugin `/triplets` Bearer cypher ** token**
* console-dashboard stats 401
* KBDB `/records/triplet-stats` SQL COUNT owner
* **** plugin `/triplets/stats` `total`KBDB 100/500
* 1854 100 401 0100
* null / 502 / empty_confirmed=false**退 0**
*
* KBDBgraph-plugin fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test、
* KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { graphHeaders, graphBase } from '../src/routes/kbdb-proxy';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
const GRAPH = 'https://graph.test';
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
/** KBDB `/records/triplet-stats` — 真 COUNT 的形狀:{ success, stats: [{library, triplet_count}] } */
function mockTripletStats(rows: { library: string; triplet_count: number }[] | null, status = 200) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(status, rows === null ? { success: false, error: 'boom' } : { success: true, stats: rows });
}
// ═══════════════ 1. graphHeaders:打 plugin 的 header 只有一份 ═══════════════
describe('graphHeaders#100 漂移的根:三處手拼 → 一支函式)', () => {
it('有 KBDB_INTERNAL_TOKEN → 帶 Bearerplugin 的 /triplets /graph /search /entities 全靠它)', () => {
expect(graphHeaders({ KBDB_INTERNAL_TOKEN: 'tok-abc' } as unknown as Bindings)).toEqual({
Authorization: 'Bearer tok-abc',
});
});
it('沒設 token → 空 headersplugin 未設 secret 時本來就開放,不硬塞空 Bearer)', () => {
expect(graphHeaders({} as unknown as Bindings)).toEqual({});
});
it('graphBase 仍照舊(KBDB_GRAPH_URL 優先、去尾斜線)', () => {
expect(graphBase({ KBDB_GRAPH_URL: 'https://graph.test/' } as unknown as Bindings)).toBe('https://graph.test');
});
});
// ═══════════════ 2. /console/kb-scale-data:數字對得上庫裡真正的數量 ═══════════════
describe('GET /console/kb-scale-data — 三元組數=KBDB 真 COUNT', () => {
it('庫裡 1854 條(跨三個庫)→ triplets_total 回 1854,不是 plugin 的分頁長度 100', async () => {
mockTripletStats([
{ library: 'general', triplet_count: 1200 },
{ library: 'finance', triplet_count: 600 },
{ library: 'ops', triplet_count: 54 },
]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
expect(res.status).toBe(200);
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(1854);
});
it('反向:triplet-stats 讀不到(500)→ triplets_total = null**不是 0**', async () => {
mockTripletStats(null, 500);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
expect(res.status).toBe(200);
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBeNull();
expect(d.triplets_total).not.toBe(0); // 這一行就是 #100 的整個重點
});
it('反向:回應形狀不對(stats 不是陣列)→ null,不半信半疑當 0', async () => {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: 'oops' });
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBeNull();
});
it('真的是 0(庫存在但沒有任何三元組)→ 誠實回 0(0 只在這種時候出現)', async () => {
mockTripletStats([]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(0);
});
it('kb-scale-data 不再打 graph-plugin(沒有 plugin interceptor 也能拿到數字)', async () => {
mockTripletStats([{ library: 'general', triplet_count: 7 }]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(7); // 打 GRAPH 的話 disableNetConnect 會讓它變 null
});
});
// ═══════════════ 3. /console/dashboard-data:燈號問 plugin、數字問 KBDB ═══════════════
describe('GET /console/dashboard-data — 圖服務健康 vs 三元組數量是兩件事', () => {
it('打 plugin /triplets/stats **有帶 Bearer** → graph.ok=true;數量仍取 KBDB 真 COUNT', async () => {
// headers matcher:漏帶 Authorization 就配不到這個 interceptor → 請求失敗 → graph.ok=false
fetchMock
.get(GRAPH)
.intercept({
path: (p: string) => p.startsWith('/triplets/stats'),
method: 'GET',
headers: { authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` },
})
.reply(200, { total: 100 }); // plugin 的分頁長度,故意與真值不同
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
const res = await SELF.fetch('http://localhost/console/dashboard-data');
expect(res.status).toBe(200);
const d = (await res.json()) as {
system: { graph: { ok: boolean; triplets: number | null } };
kb: { triplets_total: number | null };
};
expect(d.system.graph.ok).toBe(true); // 帶了 token 才會是 true#100 迴歸閘)
expect(d.system.graph.triplets).toBe(1854); // 不是 plugin 的 100
expect(d.kb.triplets_total).toBe(1854);
});
it('反向:plugin 打不通 → graph.ok=false,但三元組數照樣是真的(不被服務狀態吞掉)', async () => {
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
const res = await SELF.fetch('http://localhost/console/dashboard-data');
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
expect(d.system.graph.ok).toBe(false);
expect(d.system.graph.triplets).toBe(1854);
});
it('反向:兩邊都讀不到 → ok=false + triplets=null(不是 0', async () => {
const res = await SELF.fetch('http://localhost/console/dashboard-data');
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
expect(d.system.graph.ok).toBe(false);
expect(d.system.graph.triplets).toBeNull();
});
});
@@ -0,0 +1,79 @@
/**
* Arcrun#92
*
* main.go host function http_request 64KB
* claude_api 1MB host
* panic writeOut memory 1
* "HTTP request failed"使URL
*
* ****
* 1.
* 2. + + +
*/
import { describe, it, expect } from 'vitest';
import {
outFitsCapacity,
formatBytes,
oversizeResponseEnvelope,
HOST_TOO_LARGE,
} from '../src/lib/wasi-shim';
describe('容量握手的判定規則', () => {
it('宣告 64KB、資料 200KB → 塞不下', () => {
expect(outFitsCapacity(65536, 200_000)).toBe(false);
});
it('剛好等於容量 → 塞得下(不可 off-by-one 誤殺)', () => {
expect(outFitsCapacity(65536, 65536)).toBe(true);
});
it('舊零件沒宣告容量(0)→ 一律視為塞得下,維持舊行為', () => {
// 這條是防「換一種說謊」:不能因為新規則就把 claude_api 那種 1MB 緩衝區的
// 大回應統統誤判成「太大」。沒宣告 = host 不知道上限 = 不准亂猜。
expect(outFitsCapacity(0, 900_000)).toBe(true);
});
it('HOST_TOO_LARGE 與零件端的 hostTooLarge 常數同值(registry/components/*/main.go', () => {
expect(HOST_TOO_LARGE).toBe(3);
});
});
describe('formatBytes', () => {
it('分別用 bytes / KB / MB', () => {
expect(formatBytes(512)).toBe('512 bytes');
expect(formatBytes(65536)).toBe('64 KB');
expect(formatBytes(3_355_443)).toBe('3.2 MB');
});
});
describe('「回應太大」的訊息本身', () => {
const env = oversizeResponseEnvelope(3_355_443, 65536);
it('講出實際大小與上限(不是只說「太大」)', () => {
expect(env.error).toContain('3.2 MB');
expect(env.error).toContain('64 KB');
expect(env.actual_bytes).toBe(3_355_443);
expect(env.limit_bytes).toBe(65536);
});
it('明講「不是連線失敗」,把使用者從錯誤方向拉回來', () => {
expect(env.error).toContain('不是連線失敗');
});
it('給得出下一步(分頁/篩選),不是叫人「稍後再試」', () => {
expect(env.error).toMatch(/分頁|篩選/);
expect(env.error).not.toMatch(/稍後再試|請重新操作/);
});
it('不准退回萬用句', () => {
expect(env.error).not.toMatch(/請求失敗|HTTP request failed|未知錯誤/);
});
it('帶機器可讀欄位,上層不必比對字串', () => {
expect(env.code).toBe('response_too_large');
});
it('status 不偽造上游狀態碼(對方沒回 413)', () => {
expect(env.status).toBe(0);
});
});
+88
View File
@@ -942,3 +942,91 @@ describe('GET /portal/daemon/diagnosticst213 daemon 版)', () => {
expect(JSON.stringify(body.notes)).not.toContain('截圖');
});
});
// ═══ Arcrun#100: 總圖的「0」只准在真的是 0 的時候出現 ═══
describe('GET /portal/data/graph/overview#100 空圖三態)', () => {
/** KBDB `/records/triplet-stats`:帶 owner 與不帶 owner 是兩條不同路徑,分別攔。 */
function mockCount(scoped: number | null, global?: number | null) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith(`/records/triplet-stats?owner_id=${TENANT}`), method: 'GET' })
.reply(scoped === null ? 500 : 200, scoped === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: scoped }] });
if (global !== undefined) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p === '/records/triplet-stats?owner_id=', method: 'GET' })
.reply(global === null ? 500 : 200, global === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: global }] });
}
}
function mockTriplets(body: object, status = 200) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(status, body);
}
async function overview(token: string) {
await seedSession(token, `rec_${token}`);
mockGetRecord(`rec_${token}`, userValues({ libraries: '["*"]', role: 'admin' }));
return get('/portal/data/graph/overview', { Authorization: `Bearer ${token}` });
}
it('有資料 → 照常回圖,並附上全庫真實條數', async () => {
mockTriplets({ success: true, records: [{ values: { subject: 'A', predicate: '連到', object: 'B' } }] });
mockCount(1854);
const res = await overview('tok-ov1');
expect(res.status).toBe(200);
const d = (await res.json()) as { node_count: number; triplets_total: number; empty_confirmed: boolean };
expect(d.node_count).toBe(2);
expect(d.triplets_total).toBe(1854);
expect(d.empty_confirmed).toBe(true);
});
it('真的空(本租戶 0、全庫也 0)→ empty_confirmed=true,畫面才准印 0', async () => {
mockTriplets({ success: true, records: [] });
mockCount(0, 0);
const res = await overview('tok-ov2');
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
expect(d.node_count).toBe(0);
expect(d.empty_confirmed).toBe(true);
expect(d.empty_reason).toBe('confirmed_empty');
});
it('🔴 反向:本租戶查到 0、全庫卻有 1854(t161 owner_id 對不上)→ 不准說空,回 scope_mismatch', async () => {
mockTriplets({ success: true, records: [] });
mockCount(0, 1854);
const res = await overview('tok-ov3');
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string };
expect(d.empty_confirmed).toBe(false);
expect(d.empty_reason).toBe('scope_mismatch');
});
it('🔴 反向:條數讀不到 → unreadable(不是 confirmed_empty,畫面顯示「讀不到」)', async () => {
mockTriplets({ success: true, records: [] });
mockCount(null);
const res = await overview('tok-ov4');
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string; triplets_total: number | null };
expect(d.empty_confirmed).toBe(false);
expect(d.empty_reason).toBe('unreadable');
expect(d.triplets_total).toBeNull();
});
it('🔴 反向:有條數卻一條邊都抽不出來 → scope_mismatch,不是空庫', async () => {
mockTriplets({ success: true, records: [{ values: { subject: '', object: '' } }] });
mockCount(1854);
const res = await overview('tok-ov5');
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
expect(d.node_count).toBe(0);
expect(d.empty_reason).toBe('scope_mismatch');
expect(d.empty_confirmed).toBe(false);
});
it('🔴 反向:KBDB 回應形狀不對(沒有 records 陣列)→ 502,不再回一張空圖', async () => {
mockTriplets({ success: true, items: [] }); // 欄位名不對=讀不出來
mockCount(1854);
const res = await overview('tok-ov6');
expect(res.status).toBe(502);
const d = (await res.json()) as { error: string };
expect(d.error).toContain('三元組讀取失敗');
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* waitArcrun#101
*
* leo 2026-08-12 youlin stage input >> wait
* ms=3000 38.9s 503(1102) / ms=20000 34.0s / ms=30000 34.9s / 3000 34.8s
* ms N N CPUms=3000 3
*
*
* wait TinyGo WASMtime.Sleep WASI poll_oneoffcomponent worker
* WASI shim poll_oneoff ENOSYS TinyGo 退 clock_time_get
* Workers I/O
*
*
* A. workerd
* B. wait timer
* C. workflow wait
* D. wait step 1 arcrun-wait worker fetch
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { env } from 'cloudflare:test';
import { BUILTIN_COMPONENTS, WAIT_MAX_MS } from '../src/lib/constants';
import { createComponentLoader } from '../src/lib/component-loader';
import type { Bindings, ComponentRunner } from '../src/types';
const wait = BUILTIN_COMPONENTS.get('wait') as ComponentRunner;
afterEach(() => {
vi.unstubAllGlobals();
});
// ── A. 反向驗證:舊路徑為什麼不可能便宜地等 ──────────────────────────────────
//
// 直接跑那顆 component.wasm 沒辦法寫成安全的測試 —— 它會把 isolate 卡到 CPU 上限,
// 測試無從中止(那正是 bug 本身)。所以這裡驗的是「**沙箱裡根本沒有睡覺這個手段**」。
//
// 🔴 這裡本來有一條斷言「Workers 的時鐘在同步執行期間凍結,所以自旋迴圈的結束條件
// 永遠不成立」。**實跑打臉了**:在 vitest-pool-workers 的 workerd 裡,2553 圈之後
// Date.now() 就前進了。⇒ 那條斷言被刪掉,不是改鬆——它從一開始就不是證據。
//
// 保留下來的是**查證得動的那一半**WASI shim 把 poll_oneoff 實作成 ENOSYS(76)
// TinyGo 的 time.Sleep 只有這一條路可走 ⇒ 拿不到「睡到某個時刻」的手段,
// 只能退化成自旋。至於「自旋為什麼會拖到 35 秒才死」的完整機制**仍是推測**,
// 證據是 leo 在 youlin stage 的四次實測(見檔頭),不是本檔任何一條斷言。
//
// ⇒ 而修法不依賴那個推測:純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)
// 本來就沒有「不花 CPU 地等」這種東西,會等的只有宿主。無論卡死的細節是什麼,
// 等待都該搬回引擎。
// 「poll_oneoff 是 ENOSYS」這件事查原始碼即可(`wasi-shim.ts:319` 的
// `poll_oneoff: () => WASI_ENOSYS`,以及 13 個 `.component-builds/*/src/index.ts`
// 的 `poll_oneoff: () => 76`)。**沒有為它硬寫一條測試**——寫得出來的只會是
// 「把字串抓出來比對」,那驗的是抓字串,不是行為。事實放註解,斷言留給真的驗行為的 B/C/D。
describe('A. 反向驗證:WASI 沙箱裡沒有「睡覺」這個手段', () => {
it('對照組:await 一個 timer 之後時鐘才會前進(=為什麼修法必須在引擎側 await)', async () => {
const t0 = Date.now();
await new Promise<void>((r) => setTimeout(r, 20));
expect(Date.now()).toBeGreaterThan(t0);
});
});
// ── B. 修法本體:等待是 timer,不是佔用執行緒 ────────────────────────────────
describe('B. 引擎側的 wait 真的讓出執行緒(等 30 秒與等 3 秒同價)', () => {
it('5 個 300ms 的 wait 併發跑完 ≈ 300ms 而非 1500ms(會 blocking 的實作做不到這件事)', async () => {
const started = Date.now();
const results = await Promise.all(
Array.from({ length: 5 }, () => wait({ ms: 300 })),
);
const elapsed = Date.now() - started;
for (const r of results) {
expect(r).toEqual({ success: true, data: { waited_ms: 300 } });
}
// 序列化(blocking)會是 ~1500ms;讓出執行緒則 5 個計時器同時走完 ≈ 300ms。
// 抓 900ms 當門檻:離 300 夠鬆、離 1500 夠遠。
expect(elapsed).toBeLessThan(900);
expect(elapsed).toBeGreaterThanOrEqual(300);
});
it('等待期間 event loop 沒被佔住:同時排的 timer 照樣先到', async () => {
const order: string[] = [];
const waited = Promise.resolve(wait({ ms: 400 })).then(() => { order.push('wait-400'); });
const ticked = new Promise<void>((r) => setTimeout(r, 50)).then(() => { order.push('tick-50'); });
await Promise.all([waited, ticked]);
expect(order).toEqual(['tick-50', 'wait-400']);
});
});
// ── C. 契約沒變:既有 wait 節點定義不用改 ────────────────────────────────────
//
// 逐條對 registry/components/wait/component.contract.yaml 的 gherkin_tests。
describe('C. I/O 契約與 WASM 版一致(既有 workflow 不必改定義)', () => {
it('contract gherkin:等待 100ms → waited_ms:100', async () => {
expect(await wait({ ms: 100 })).toEqual({ success: true, data: { waited_ms: 100 } });
});
it('contract gherkinms 為 0 時失敗(不是靜靜跳過)', async () => {
expect(await wait({ ms: 0 })).toEqual({ success: false, error: 'ms 必須大於 0' });
});
it('ms 缺漏 / 負數 / 非數字,一律誠實回 success:false,不假裝等過', async () => {
for (const bad of [undefined, null, -1, 'abc', {}, []]) {
expect(await wait({ ms: bad })).toEqual({ success: false, error: 'ms 必須大於 0' });
}
});
it('contract gherkinms=99999 截斷為上限 30000(不是報錯、也不是真的等 99 秒)', async () => {
// 不真的等 30 秒:換掉 setTimeout,攔下引擎「要求等多久」再立刻放行。
const asked: number[] = [];
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
asked.push(Number(delay));
fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout);
expect(await wait({ ms: 99999 })).toEqual({ success: true, data: { waited_ms: WAIT_MAX_MS } });
expect(asked).toEqual([WAIT_MAX_MS]);
expect(WAIT_MAX_MS).toBe(30000); // 紅線:上限不准為了閃避資源限制被調小
});
it('ms=30000 一路走到底也只是「排一個 30 秒的 timer」,沒有任何同步佔用', async () => {
const asked: number[] = [];
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
asked.push(Number(delay));
fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout);
expect(await wait({ ms: 30000 })).toEqual({ success: true, data: { waited_ms: 30000 } });
expect(asked).toEqual([30000]);
});
it('context 照契約透傳,並補上 waited_ms', async () => {
const r = await wait({ ms: 5, context: { order_id: 'A-1', payload: { n: 2 } } });
expect(r).toEqual({
success: true,
data: { order_id: 'A-1', payload: { n: 2 }, waited_ms: 5 },
});
});
it('node.data 經 interpolateData 後 ms 會是字串 —— 收得下(WASM 版在這裡直接 unmarshal 失敗)', async () => {
expect(await wait({ ms: '250' })).toEqual({ success: true, data: { waited_ms: 250 } });
});
});
// ── D. 路由:不再打 arcrun-wait worker ───────────────────────────────────────
describe('D. component-loader 把 wait 解到內建 runnerstep 1),不發任何 fetch', () => {
it('loader("wait") 跑起來不會對外送出任何請求', async () => {
const fakeEnv = { ...env, WORKER_SUBDOMAIN: 'test-sub' } as unknown as Bindings;
const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
const runner = await createComponentLoader(fakeEnv)('wait');
const r = await runner({ ms: 10 });
expect(r).toEqual({ success: true, data: { waited_ms: 10 } });
// 修法前這裡會打 arcrun-wait.test-sub.workers.devSVC_WAIT 未綁時的 fallback),
// 那顆 worker 就是會燒到 1102 的那顆。
expect(fetchSpy).not.toHaveBeenCalled();
});
it('wait 仍在「執行期真的解析得動」的清單裡(/cypher/search 查得到)', async () => {
const { RUNTIME_NATIVE_COMPONENT_IDS } = await import('../src/lib/component-loader');
expect(RUNTIME_NATIVE_COMPONENT_IDS.has('wait')).toBe(true);
});
});
+4
View File
@@ -49,6 +49,10 @@ KBDB_BASE_URL = "https://kbdb.test"
CONSOLE_TENANT = "leo"
# portal-auth P3graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
KBDB_GRAPH_URL = "https://graph.test"
# Arcrun#100kbdb-graph-plugin 對 /triplets /graph /search /entities 掛 Bearer 閘。測試環境要有
# 這把(明顯的假字串、非真實金鑰)才驗得出「cypher 打 plugin 有沒有帶 token」——原本兩支
# /triplets/stats 漏帶 → 永遠 401 → 前端「三元組 0」。真實部署仍走 wrangler secret put。
KBDB_INTERNAL_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,同上方 CF_SECRETS_API_TOKEN 慣例
# D61ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
# Scripts secrets 管理 APIhttps://api.cloudflare.com/...),authStoreWritable() 只看這兩項
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
+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(
+168
View File
@@ -0,0 +1,168 @@
// 標庫 backfillArcrun#85 二次裁決,2026-08-11;相關票 Arcrun#87「藏書地圖是空的」)。
//
// 背景:leo 把向量化優先序講成一句話後又補一刀:「它是兩件事?其實是一件——沒有庫就
// 剩一個,但你把庫標好以後,原來的判定要修改對吧」。⇒ 判定標準(embed.ts 的
// SelectionCriteria)必須從第一天就同時容納時間與庫,本檔提供「庫」這一半真正的資料。
//
// base 的 write path 早就支援(`createEntry` 的 `metadata_json.$.library`t52:「庫由
// ingest 蓋章決定」)——既有的搜尋/embeddeprecate-by-library 全都讀這個欄位,
// **缺的不是機制,是既有資料沒被蓋章**(library-map.ts 檔頭 2026-07-19 對 prod 核實:
// 既有 entries 的 metadata.library 是空的)。「源頭寫入就貼標」是呼叫端(ingest)的事,
// base 這裡管不到、也不該猜(base 對內容語意無知的既有原則);triplet 的實際寫入更是
// 在另一個 repo(見 kbdb/src/index.ts 檔頭「triplet (separate repo)」)。
//
// 這個模組只做「補存量」那一半,且刻意設計成呼叫端驅動:
// - base 不猜「這筆該屬於哪個庫」——那是語意判斷。呼叫端給一個 target library 值
// +一組篩選條件,base 只負責把符合條件、目前未標記的 entries 安全、節流地蓋上這個值。
// - 篩選條件有兩種精度(2026-08-11 leo 定案的做法後補上):
// ① 精準比對 `page_names`(IN 清單)——leo 定的正解:「有 2 份原稿,在 gitea 和我的
// Mac……去 gitea 把每個庫有哪些的卡名列出,跑來遍歷應該就搞定了」。呼叫端(daemon/
// #87)從 Gitea repo 列出卡名,逐批把「這些卡名屬於庫 X」精準地寫進來,不必猜。
// ② `source_prefix``page_name_prefix` 前綴 fallback(同 library-map.ts
// recomputeLibraryMap 的 source_prefix 精神,只是這裡是寫入不是聚合)——沒有精準
// 清單時的過渡手段,精度不如①,兩者可並用(AND)縮小範圍。
// - 冪等:已標記的 entries 不會再入選(WHERE 帶「library 為空」)。
//
// D69 節流:與 reconcileEmbedGenerationembed.ts)共用 maintenance-quota.ts 的同一顆
// 每日 D1 寫入計數器——兩者都是「多筆 D1 row write、不打 AI」的背景維護操作,不共用
// 計數器的話,補存量時會把世代核對的閘繞過去(leo 2026-08-11 二次裁決原話:「每日上限
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
import type { Bindings } from '../types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
const MAX_PAGE_NAMES = 300;
export interface LibraryBackfillCriteria {
owner_id?: string;
entry_type?: string;
page_names?: string[]; // 精準比對 page_name(IN 清單)——leo 定案的正解:從 Gitea repo
// 列出卡名,逐批精準點名「這些卡名屬於庫 X」(見檔頭說明①)。
source_prefix?: string; // metadata_json.$.source LIKE prefix%(過渡 fallback,見檔頭②)
page_name_prefix?: string; // page_name LIKE prefix%(過渡 fallback,見檔頭②)
since?: number; // created_at >= sinceunix seconds
until?: number; // created_at < untilunix seconds
}
export interface LibraryBackfillResult {
library: string;
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
tagged: number; // 本次真的寫入 metadata_json.$.library 的筆數。
remaining: number; // 本次之後仍待補標(符合條件、仍未標記)的筆數,不受額度影響。
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與 reconcile 共用)。
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
quota_exceeded: boolean; // 本批是否因額度不足被截斷。
}
// 單次呼叫候選上限(避開 subrequest/CPU/timeout;一批只有 1 次 SELECT + 1 次 UPDATE
// 比 reconcile 多一次 Vectorize 呼叫的成本低,故上限可以放寬一些)。
const HARD_LIMIT_CAP = 500;
function criteriaPredicate(c: LibraryBackfillCriteria): { conds: string[]; params: unknown[] } {
// 冪等的核心:只選「目前沒有 library 值」的候選,已標記過的(含標成 'general' 的)不會再入選。
const conds: string[] = [
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')",
];
const params: unknown[] = [];
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 };
}
/**
* library entries target library
* + limit + budget reconcile D1
* ingest / Arcrun#87
* base #87 mindset §7
*
* `owner_id` **** LibraryBackfillCriteria
* 2026-08-11 leo owner
* `owner_id=bfezv28v` `owner_id='leo'` owner
* `deprecateEntriesByLibrary` library
* owner_id
* owner
*/
export async function backfillEntryLibraryTags(
db: D1Database,
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
opts: { library: string; owner_id: string; limit?: number } & Omit<LibraryBackfillCriteria, 'owner_id'>,
): Promise<LibraryBackfillResult> {
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(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
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<{ id: string }>();
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
// D69:額度截斷——每個候選最多 1 次 D1 write,與 reconcile 共用同一顆計數器。
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 {
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
}
const remRow = await db
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
.bind(...params)
.first<{ c: number }>();
return {
library,
scanned,
tagged,
remaining: remRow?.c ?? 0,
quota_limit: budget.limit,
quota_used_today: budget.used + tagged,
quota_exceeded: quotaExceeded,
};
}
/** 待補標統計(回報用):符合條件、目前未標記 library 的筆數。 */
export async function libraryBackfillStatus(
db: D1Database,
opts: LibraryBackfillCriteria = {},
): Promise<{ pending: number }> {
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<{ c: number }>();
return { pending: row?.c ?? 0 };
}
+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)
+106
View File
@@ -0,0 +1,106 @@
// 背景維護寫入的共用 D1 每日額度(Arcrun#85 D692026-08-11)。
//
// 為什麼需要這個模組(不是每個 caller 各自算):
// D68 已經替「補算向量」的 Workers AI 呼叫量設了每日軟上限(embed.ts
// DEFAULT_BACKFILL_DAILY_LIMIT),但 leo 逐行複核後指出還有一個沒堵的洞——
// 世代核對(reconcileEmbedGeneration**不打 AI,卻一樣逐筆寫 D1**(補標 content_hash
// 或重置 is_embedded),47 萬筆候選 ≈ 4.7 倍 D1 免費層 100,000 rows written/日,而它
// 當時零保護。2026-08-11 leo 補了第二刀:**標庫(library backfill)也是同一種操作**
// ——多筆 D1 row write、不打 AI——若各自設一顆獨立計數器,做標庫時會把 reconcile
// 的閘繞過去(兩者加起來還是可能燒穿同一顆 D1)。
// ⇒ 兩者必須共用同一顆「今天 D1 背景維護寫入還剩多少」計數器,這裡就是那顆計數器。
//
// 儲存精神完全比照 execution-log.ts checkUsageembed.ts getBackfillUsageToday:單一
// entries 列/日(entry_type='kbdb_maintenance_usage'),upsert,不新增表(D38)。
//
// 額度怎麼選(不是拍腦袋,比照 execution-log.ts DEFAULT_DAILY_LIMIT 的既有算法):
// D1 免費層 100,000 rows written/日。execution_log 自設 20%20,000)留給知識卡;
// 本模組管的是「背景維護」(reconcile + 標庫 backfill,兩者都是低優先、非使用者
// 當下等待的操作),同樣自設 20%(20,000/日)——不是硬性 Cloudflare 限制,是不讓
// 背景維護把當天寫入額度和知識卡片的正常寫入/execution_log 搶光的自我節制,
// 可用 env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT 覆寫。
import type { Bindings } from '../types';
export const DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 20000;
export function maintenanceDailyLimit(env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>): number {
const raw = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
}
function utcDay(): string {
return new Date().toISOString().slice(0, 10);
}
/** 額度計數器 entries id(單一列/日;不分租戶——D1 rows-written 額度是實例級,非租戶級)。 */
function maintenanceUsageId(): string {
return `kbdb-maintenance-usage:${utcDay()}`;
}
/**
* 0caller fail-open
* embed.ts getBackfillUsageToday
*/
export async function getMaintenanceUsageToday(db: D1Database): Promise<number> {
const row = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(maintenanceUsageId())
.first<{ metadata_json: string | null }>();
if (!row) return 0;
try {
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { writes?: number }) : {};
return Number(parsed.writes) || 0;
} catch {
return 0;
}
}
/** 今天背景維護額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
export async function addMaintenanceUsage(db: D1Database, by: number): Promise<void> {
if (by <= 0) return;
const id = maintenanceUsageId();
const existing = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(id)
.first<{ metadata_json: string | null }>();
let prev = 0;
if (existing) {
try {
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
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();
}
}
export interface MaintenanceBudget {
limit: number;
used: number;
remaining: number;
}
/** 今天還剩多少背景維護 D1 寫入額度(reconcile/標庫 backfill 呼叫前先問這個)。 */
export async function maintenanceBudgetToday(
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
db: D1Database,
): Promise<MaintenanceBudget> {
const limit = maintenanceDailyLimit(env);
let used = 0;
try {
used = await getMaintenanceUsageToday(db);
} catch {
used = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連背景維護都做不了
}
return { limit, used, remaining: Math.max(0, limit - used) };
}
+305 -13
View File
@@ -12,6 +12,7 @@
// base 只認這個通用旗標 → base 維持對內容語意無知。
import type { Bindings, Entry } from './types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './actions/maintenance-quota';
// ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)────────────────
//
@@ -132,7 +133,12 @@ export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean
},
]);
// 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。
await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(entry.id).run();
// content_hash 順手蓋成「這次嵌入用的模型」(世代戳記,見下方 reconcileEmbedGeneration 的
// 說明)——這裡是「新寫的立刻算」的路徑,寫入當下 model 必為現行 model,不會有世代落差。
await env.DB
.prepare('UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?')
.bind(embedModel(env), entry.id)
.run();
return true;
}
@@ -173,12 +179,134 @@ function parseMeta(json: string | null): Record<string, unknown> | null {
const BACKFILL_PREDICATE =
"is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
// ── 每日額度上限(D682026-08-11:leo「補算向量照時間新到舊、且每天有額度上限」)─────────
//
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日免費 10,000 neuronsUTC 午夜重置,
// 見頂層 wiki ops-facts.md「萃取與向量化吃同一份 Workers AI 額度」)。backfill 是背景低優先
// 動作,不該把當天額度燒光讓萃取/今天的新寫入整天卡死(embedOnWrite 不受此上限——「新寫的
// 立刻算」是 D68 三條之一,不能被 backfill 的節制連坐)。自設「軟上限」,非 Cloudflare 硬限制,
// 可用 env.EMBED_BACKFILL_DAILY_LIMIT 覆寫(精神比照 execution-log.ts 的 DEFAULT_DAILY_LIMIT)。
//
// 預設值怎麼選(不是拍腦袋,2026-08-11 查證 Cloudflare 官方定價後回推):
// bge-m3 定價:1,075 neurons / 1,000,000 input tokens(無輸出 token 成本,embedding 只有輸入)。
// 保守估計每筆中文知識卡片 ~800 tokens(寧可高估——CJK tokenizer 密度通常高於英文,
// 高估 token 數 ⇒ 算出的「每日可嵌筆數」偏保守,不會撞真的 CF 額度):
// 800 tokens × 1,075 / 1,000,000 ≈ 0.86 neurons/entry
// backfill 分到日配額 20%(比照 execution-log.ts「自我節制、留大部分給主流程」的既有慣例):
// 10,000 × 20% = 2,000 neurons/日
// 2,000 ÷ 0.86 ≈ 2,325 entries/日,再打八折留緩衝(token 估計誤差/其他背景消耗):
// 2,325 × 0.8 ≈ 1,860 → 取整數 1,800。
const DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
function backfillDailyLimit(env: Pick<Bindings, 'EMBED_BACKFILL_DAILY_LIMIT'>): number {
const raw = env.EMBED_BACKFILL_DAILY_LIMIT;
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
}
function utcDay(): string {
return new Date().toISOString().slice(0, 10);
}
/** 額度計數器 entries id(單一列/日,UTC 日期字串,換日自然歸零;不分租戶——Workers AI 額度是帳號級)。 */
function backfillUsageId(): string {
return `embed-backfill-usage:${utcDay()}`;
}
/**
* backfill execution-log.ts checkUsage entries /
* entry_type='embed_backfill_usage' metadata_json
* 0caller fail-open
*/
async function getBackfillUsageToday(db: D1Database): Promise<number> {
const row = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(backfillUsageId())
.first<{ metadata_json: string | null }>();
if (!row) return 0;
try {
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { embedded?: number }) : {};
return Number(parsed.embedded) || 0;
} catch {
return 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死額度機制
}
}
/** 今天 backfill 額度用量 +byupsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
async function addBackfillUsage(db: D1Database, by: number): Promise<void> {
if (by <= 0) return;
const id = backfillUsageId();
const existing = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(id)
.first<{ metadata_json: string | null }>();
let prev = 0;
if (existing) {
try {
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { embedded?: number }) : {};
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: utcDay(), 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: utcDay(), embedded: by }))
.run();
}
}
// ── 「挑哪一批」可以從外面指定(Arcrun#852026-08-11 leo 二度裁決)───────────────
//
// leo 的優先序不是「一律新到舊」的單一佇列,是**分層**:今天寫的立刻/這週在跑的先跑/
// 有查詢紀錄的庫優先/半年前的慢慢跑。分層要能實作,前提是「這次補哪一批」要能從外面
// (工作流)指定,不能只靠資料層自己決定的固定排序——策略要住在 leo 打得開的地方
// (工作流頁),不是焊死在這裡看不見也改不動。
//
// 這裡不預先幫 caller 決定「四層怎麼切」(那是策略,屬於呼叫端/工作流,見 Arcrun#85
// D70 段落的意圖草案),只提供**同一套篩選形狀**讓任何一層都能表達:
// - sinceuntil:時間窗(unix secondscreated_at 半開區間 [since, until))——時間分層
// (①今天/②本週/④半年前)都是同一個 since/until 參數,差別只在呼叫端傳的值。
// - library:依 metadata_json.$.library 過濾——一旦資料身上有庫這個資訊(Arcrun#87),
// 「有查詢紀錄的庫優先」這層可以直接用同一個參數,不必再改介面形狀。
// 三個操作(backfillEmbeddingsreconcileEmbedGenerationbackfillEntryLibraryTags
// 見 actions/library-backfill.ts)共用這個形狀,這就是「判定標準只有一份」的意思——
// 不是先做時間、之後為了庫再回頭改介面。
export interface SelectionCriteria {
owner_id?: string;
source?: string;
library?: string; // 精確比對 metadata_json.$.library(未標記的舊資料一律歸 'general',同 embedOnWrite 慣例)
since?: number; // created_at >= sinceunix seconds
until?: number; // created_at < untilunix seconds
}
function selectionCriteriaPredicate(opts: SelectionCriteria): { conds: string[]; params: unknown[] } {
const conds: string[] = [];
const params: unknown[] = [];
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
if (opts.source) { 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 };
}
export interface BackfillResult {
enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。
processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。
skipped: number; // 掃到但沒嵌(例如 embedText 回 null,或本批被額度擋下)的筆數。
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0,與額度無關——單純候選總量)。
scanned: number; // 本批掃出的候選筆數(受 limit 限制)。
quota_limit: number; // 今日 backfill 額度上限(env.EMBED_BACKFILL_DAILY_LIMIT 或預設值)。
quota_used_today: number; // 本次呼叫後,今日累積已消耗的 backfill 額度。
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有可嵌的候選但今天不再打 AI,等明天/調高上限)。
}
/**
@@ -193,9 +321,14 @@ export interface BackfillResult {
*/
export async function backfillEmbeddings(
env: Bindings,
opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {},
opts: SelectionCriteria & { limit?: number; reindex?: boolean; offset?: number } = {},
): Promise<BackfillResult> {
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
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);
@@ -210,21 +343,39 @@ export async function backfillEmbeddings(
// 🔴 2026-08-05**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。
// 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活,
// 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
const params: unknown[] = [];
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
// 「挑哪一批」(Arcrun#85):owner_id/source/library/since/until 全部走同一套
// selectionCriteriaPredicate,讓呼叫端(工作流)能表達時間分層與庫分層,不必等
// base 幫忙決定;本函式不預設任何一層,caller 傳什麼就篩什麼。
const sel = selectionCriteriaPredicate(opts);
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
const params: unknown[] = [...sel.params];
const where = conds.join(' AND ');
// D68:由新到舊——最可能被查到的最先補回來(見檔頭 DEFAULT_BACKFILL_DAILY_LIMIT 段的決策脈絡)。
const res = await env.DB
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`)
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
.bind(...params, limit, offset)
.all<Entry>();
const rows = res.results ?? [];
const scanned = rows.length;
// D68:每日額度上限。額度是「這次呼叫要不要打 AI」的唯一守門——reindex 一樣要打 AI.run
// 同樣受限(不因為是 reindex 就例外,會打 Workers AI 的動作都算)。
const dailyCap = backfillDailyLimit(env);
let usedToday = 0;
try {
usedToday = await getBackfillUsageToday(env.DB);
} catch {
usedToday = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連 backfill 都不做
}
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);
// 額度截斷:candidates 已按 created_at DESC 排序,取前 remainingQuota 筆=優先保留最新的。
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 })) as { data: number[][] };
@@ -246,8 +397,18 @@ export async function backfillEmbeddings(
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();
// content_hash 順手蓋成現行模型(世代戳記,見 reconcileEmbedGeneration)。
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 {
// fail-open:額度計數寫入失敗不影響已經完成的嵌入(別讓 bookkeeping 故障吞掉已做的工);
// 代價是下次呼叫可能少算一點用量——比「明明做了卻沒生效」安全(誠實限制,mindset §7)。
}
}
}
@@ -259,7 +420,16 @@ export async function backfillEmbeddings(
// 非 reindexpredicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。
// reindexpredicate 不含 is_embeddedCOUNT 恆等於總數 → 改用 offset 分頁計 remaining(否則永不終止)。
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,
};
}
/** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */
@@ -283,6 +453,128 @@ export async function backfillStatus(
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
}
export interface ReconcileResult {
enabled: boolean;
checked: number; // 本批「真的核對+寫回」的筆數(受下方 D1 額度截斷後的量)。
confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。
reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。
remaining: number; // 本次之後仍待核對的筆數(不受額度影響,可重複呼叫直到 0)。
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與標庫 backfill 共用,見 maintenance-quota.ts)。
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有候選但今天不再寫 D1,等明天/調高上限)。
}
/**
* Generation reconciliationD68 2026-08-11
*
* `is_embedded=1` Vectorize index ****
* index/ 2026-08-03 index index
* **退**768 `arcrun-kbdb-embed`
* `is_embedded=1` backfill `is_embedded=0`
* 1024 `arcrun-kbdb-embed-m3`西
*
* `is_embedded` Vectorize index id
* `env.VECTORIZE.getByIds`ground truth content_hash
* is_embedded=1 content_hash NULL
* Vectorize
* - index content_hash AI
*
* - index index is_embedded=0 content_hash
* backfill
*
* Workers AI AI.run D1 + Vectorize.getByIds + D1
*
* D69Arcrun#852026-08-11 leo ** AI D1**
* row write content_hash is_embedded
* 47 4.7 D1 100,000 rows written/ backfill D1
* write AI `actions/maintenance-quota.ts`
* D1
*
* owner_id/library/since/until SelectionCriteria backfillEmbeddings
* backfillEntryLibraryTags
*/
export async function reconcileEmbedGeneration(
env: Bindings,
opts: Pick<SelectionCriteria, 'owner_id' | 'library' | 'since' | 'until'> & { limit?: number } = {},
): Promise<ReconcileResult> {
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: unknown[] = [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<{ id: string }>();
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
// D69:額度截斷——每個候選最多 1 次 D1 write,直接照剩餘額度砍候選清單長度。
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<{ c: number }>();
const written = confirmed_current + reset_to_pending;
try {
await addMaintenanceUsage(env.DB, written);
} catch {
// fail-open:額度計數寫入失敗不影響已經完成的核對寫入(精神同 backfillEmbeddings 的
// addBackfillUsage 失敗處理——寧可下次呼叫少算一點用量,也不讓計數故障吞掉已做的工)。
}
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,
};
}
export interface SelfTestResult {
enabled: boolean; // embed 模組是否開(binding 都在)
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
+43 -2
View File
@@ -8,7 +8,7 @@
// base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed';
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest, reconcileEmbedGeneration } from '../embed';
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
@@ -16,11 +16,14 @@ const OFF_HINT =
'語義補嵌需先開 embed 模組(Vectorize+AI binding)。叫 CC「幫我開語義查詢」(設 kbdb_embed:true + redeploy 注入 binding)後再呼叫本端點。';
// POST /embed/backfill — batch-embed existing embeddable entries with is_embedded=0.
// body(皆選填):{ limit?:1-100(預設25, owner_id?, source?, reindex?, offset? }。
// body(皆選填):{ limit?:1-100(預設25, owner_id?, source?, library?, since?, until?, reindex?, offset? }。
// 冪等:重跑不會重複嵌(已 is_embedded=1 的不再入選;upsert 同 id 冪等)。
// 分批:單次最多 limit 筆;回傳 remaining>0 表示還有 → 重複呼叫直到 remaining=0。
// reindex:trueArcrun#11):改重推「所有 embeddable」既有向量(含 is_embedded=1),
// 讓事後建立的 Vectorize metadata index 收錄它們(否則帶過濾語意查詢回 0);配 offset 分頁。
// librarysinceuntilArcrun#852026-08-11):「挑哪一批」從外面指定——時間分層
// (今天/本週/半年前)與庫分層(有查詢紀錄的庫優先)共用同一套 SelectionCriteria
// 由呼叫端(工作流)決定這次要補的是哪一批,不是資料層焊死單一排序(見 embed.ts 檔頭說明)。
// 模組未開 → 409 + capability_hint(不假綠)。
embedRoutes.post('/backfill', async (c) => {
if (!embedEnabled(c.env)) {
@@ -33,6 +36,9 @@ embedRoutes.post('/backfill', async (c) => {
limit?: number | string;
owner_id?: string;
source?: string;
library?: string;
since?: number | string;
until?: number | string;
reindex?: boolean;
offset?: number | string;
};
@@ -40,6 +46,9 @@ embedRoutes.post('/backfill', async (c) => {
limit: body.limit !== undefined ? Number(body.limit) : undefined,
owner_id: body.owner_id || undefined,
source: body.source || undefined,
library: body.library || undefined,
since: body.since !== undefined ? Number(body.since) : undefined,
until: body.until !== undefined ? Number(body.until) : undefined,
// reindexArcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
reindex: body.reindex === true,
offset: body.offset !== undefined ? Number(body.offset) : undefined,
@@ -56,6 +65,38 @@ embedRoutes.get('/backfill/status', async (c) => {
return c.json({ success: true, ...status });
});
// POST /embed/reconcile — 世代核對(D68 配套修復,2026-08-11;D69 額度節流同日補上):
// 對「is_embedded=1 但 content_hash 非現行模型」的候選,問現行 Vectorize index 是否真的收錄;
// 真的在 → 補標 content_hash(不打 AI);不在 → 重置 is_embedded=0,回到正常 /embed/backfill 佇列。
// 解「從備份整批灌回、帶著對已退役索引的 is_embedded=1,永遠不被 backfill 碰到」這個坑。
// body(皆選填):{ limit?:1-200(預設50, owner_id?, library?, since?, until? }。重複呼叫直到 remaining=0。
// D69:每筆候選最多消耗一次 D1 row write,與 POST /entries/backfill-library 共用同一顆每日
// 「背景維護 D1 寫入」額度(見 actions/maintenance-quota.ts)——額度用完會誠實回
// quota_exceeded:true 並停手,不會把當天 D1 免費額度燒穿(2026-08-11 leo 逐行複核找到的破口)。
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(() => ({}))) as {
limit?: number | string;
owner_id?: string;
library?: string;
since?: number | string;
until?: number | string;
};
const result = await reconcileEmbedGeneration(c.env, {
limit: body.limit !== undefined ? Number(body.limit) : undefined,
owner_id: body.owner_id || undefined,
library: body.library || undefined,
since: body.since !== undefined ? Number(body.since) : undefined,
until: body.until !== undefined ? Number(body.until) : undefined,
});
return c.json({ success: true, ...result });
});
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
+59
View File
@@ -23,6 +23,7 @@ import {
EmbedQueryFailedError,
} from '../embed';
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../actions/library-backfill';
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
@@ -367,6 +368,64 @@ entryRoutes.patch('/deprecate-by-library', async (c) => {
return c.json({ success: true, deprecated_count: count, vectors_deleted });
});
// POST /entries/backfill-library — 標庫補存量(Arcrun#85 二次裁決/相關票 Arcrun#872026-08-11)。
// body(必填 library + owner_id):{ library, owner_id, page_names?string[],精準比對,
// leo 定案的正解——見 actions/library-backfill.ts 檔頭「拿原稿遍歷」), entry_type?,
// source_prefix?, page_name_prefix?(後兩者為過渡 fallback,精度不如 page_names,
// since?, until?, limit?(1-500,預設100) }。
// 冪等:只選「目前未標記 library」的候選;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
// budget:與 /embed/reconcile 共用同一顆每日 D1 寫入額度(見 actions/maintenance-quota.ts)——
// 兩者都是「多筆 D1 write、不打 AI」的背景維護操作,不共用額度的話補存量會把世代核對的閘繞過去。
// base 對內容語意無知:不猜「這批該貼哪個庫」,呼叫端(ingest/#87)決定 library 與篩選條件;
// owner_id 必填(同 /entries/deprecate-by-library 的既有防線——批次改一大片既有資料不准無租戶範圍地掃)。
// 此路由必須在 '/:id' 之前註冊,否則 'backfill-library' 會被當成 id 參數。
entryRoutes.post('/backfill-library', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
library?: string;
owner_id?: string;
entry_type?: string;
page_names?: string[];
source_prefix?: string;
page_name_prefix?: string;
since?: number | string;
until?: number | string;
limit?: number | string;
};
const library = String(body.library ?? '').trim();
const ownerId = String(body.owner_id ?? '').trim();
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
try {
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
library,
owner_id: ownerId,
entry_type: body.entry_type || undefined,
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : undefined,
source_prefix: body.source_prefix || undefined,
page_name_prefix: body.page_name_prefix || undefined,
since: body.since !== undefined ? Number(body.since) : undefined,
until: body.until !== undefined ? Number(body.until) : undefined,
limit: body.limit !== undefined ? Number(body.limit) : undefined,
});
return c.json({ success: true, ...result });
} catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
// GET /entries/backfill-library/status?owner_id=&entry_type=&source_prefix=&page_name_prefix=&since=&until=
// — 符合條件、目前未標記 library 的筆數(backfill 前後都能查,判斷還剩多少)。
entryRoutes.get('/backfill-library/status', async (c) => {
const status = await libraryBackfillStatus(c.env.DB, {
owner_id: c.req.query('owner_id') || undefined,
entry_type: c.req.query('entry_type') || undefined,
source_prefix: c.req.query('source_prefix') || undefined,
page_name_prefix: c.req.query('page_name_prefix') || undefined,
since: c.req.query('since') ? Number(c.req.query('since')) : undefined,
until: c.req.query('until') ? Number(c.req.query('until')) : undefined,
});
return c.json({ success: true, ...status });
});
// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
+15 -1
View File
@@ -24,6 +24,18 @@ export type Bindings = {
// kbdb/src/actions/execution-log.ts DEFAULT_DAILY_LIMIT 說明)。未設 → 20000
// D1 100,000 rows written/日的 20%,留 80% 給知識卡 entries)。
EXECUTION_LOG_DAILY_WRITE_LIMIT?: string;
// embed backfill 每日軟上限(D682026-08-11:補算向量照時間新到舊、且每天有額度上限)。
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日 10,000 免費 neurons(見頂層
// wiki ops-facts.md);backfill 是背景低優先動作,自設軟上限不把當天額度燒光。未設 → 見
// kbdb/src/embed.ts DEFAULT_BACKFILL_DAILY_LIMIT 說明(含選值算式,非拍腦袋)。
EMBED_BACKFILL_DAILY_LIMIT?: string;
// 背景維護寫入(reconcile 世代核對 + 標庫 backfill)共用的 D1 每日寫入軟上限
// Arcrun#85 D69 修法,2026-08-11:兩者都是「多筆 D1 row write、不打 AI」的操作,
// 各自不設防都會單獨燒穿 D1 100,000 rows/日免費額度——reconcile 47 萬筆 candidate
// ≈ 4.7 倍全日額度,已在票上實測;標庫 backfill 同樣是逐筆 D1 write,若各管各的,
// 補標庫時會把 reconcile 的閘繞過去。兩者共用同一顆「今天還剩多少」計數器。
// 未設 → 見 kbdb/src/actions/maintenance-quota.ts DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT。
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT?: string;
};
export type EntryType =
@@ -35,7 +47,9 @@ export type EntryType =
| 'workflow'
| 'recipe_stat'
| 'execution_log'
| 'execution_log_usage';
| 'execution_log_usage'
| 'embed_backfill_usage'
| 'kbdb_maintenance_usage';
export interface Entry {
id: string;
+410 -108
View File
@@ -1,130 +1,196 @@
// embed backfill — D682026-08-11 leo 拍板:補算向量照時間新到舊、且每天有額度上限)測試。
//
// 測試策略比照 execution-log.test.tslibrary-map.test.ts:真 SQLitenode:sqlite)套
// migrations/0001_base.sql 原檔,比手刻假 DB 更硬——驗的是真實 SQL 語意(ORDER BYWHERE
// JSON 函式),不是「以為 SQL 長這樣」。AI/VECTORIZE 仍是輕量假物件(Cloudflare binding
// 不是 SQL,沒有真 runtime 可套)。
//
// 覆蓋 D68 三條 + is_embedded 世代旗標坑,四項都要有實測輸出:
// 1. 由新到舊:造 created_at 跨時間的候選,證明先被處理的是最新那幾筆
// 2. 每日額度上限真的擋:cap 設小,跑到撞上限,證明它停手不再打 AI(不是繼續打)
// 3. 帶著舊世代旗標(is_embedded=1 但對應已退役索引)的列補得回來
// 4. 現有 idempotentbatchingreindex 行為不因本次改動而壞掉
//
// 本檔在 kbdb/tests/(牆外,非 kbdb/src|migrations),依 D38 kbdb-api-wall-guard 規則,
// 所有直接對 SQLite 治具下 SQL 的行都集中在下面幾個 helper(每行標 kbdb-sql-ok 留痕)——
// 這是**測試治具本身**node:sqlite→D1 shim,模擬 D1 binding),不是牆外業務邏輯繞過 API。
import { describe, it, expect } from 'vitest';
import { backfillEmbeddings, backfillStatus, embedEnabled } from '../src/embed';
import type { Bindings, Entry } from '../src/types';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import {
backfillEmbeddings,
backfillStatus,
embedEnabled,
reconcileEmbedGeneration,
} from '../src/embed';
import type { Bindings, Entry, EntryType } from '../src/types';
// ── Minimal in-memory fakes (no Workers runtime) ─────────────────────────────
// The fake DB interprets only the 3 statement shapes backfill issues, by keyword:
// SELECT * ... LIMIT ? OFFSET ? → candidate rows (embeddable & non-empty content;
// +is_embedded=0 for normal backfill, any for reindex)
// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids
// SELECT COUNT(*) → count of matching candidates
// embeddable = metadata.embed===true & non-empty contentreindex predicate)。
function isEmbeddable(e: Entry): boolean {
if (!e.content || e.content.trim() === '') return false;
try {
const m = JSON.parse(e.metadata_json ?? 'null');
return m?.embed === true;
} catch {
return false;
}
}
// normal backfill 額外要求 is_embedded=0(漏網補嵌)。
function isCandidate(e: Entry): boolean {
return e.is_embedded === 0 && isEmbeddable(e);
}
const CURRENT_MODEL = '@cf/baai/bge-m3'; // embed.ts DEFAULT_EMBED_MODEL(未 export,測試按文件字面核對)
function makeFakeDB(store: Entry[]) {
const prepare = (sql: string) => {
// reindex predicate 不含 "is_embedded = 0" → 依 SQL 判斷該用哪個 filter(對齊 embed.ts)。
const pred = /is_embedded = 0/.test(sql) ? isCandidate : isEmbeddable;
let bound: unknown[] = [];
const stmt = {
bind(...args: unknown[]) { bound = args; return stmt; },
async all<T>() {
// SELECT * ... LIMIT ? OFFSET ? (bound tail = [..., limit, offset])
const offset = Number(bound[bound.length - 1]);
const limit = Number(bound[bound.length - 2]);
const results = store.filter(pred).slice(offset, offset + limit) as unknown as T[];
return { results };
},
async first<T>() {
// SELECT COUNT(*) as c ...
const c = store.filter(pred).length;
return { c } as unknown as T;
},
// ── node:sqlite → D1 介面最小 adapter(同 execution-log.test.tslibrary-map.test.ts 手法)──
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() {
// UPDATE entries SET is_embedded = 1 WHERE id IN (...) → bound = ids
const ids = new Set(bound.map(String));
for (const e of store) if (ids.has(e.id)) e.is_embedded = 1;
return { success: true };
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
return stmt;
};
return { prepare } as unknown as D1Database;
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
function mkEntry(id: string, content: string | null, embed: boolean, is_embedded = 0): Entry {
return {
id, content, entry_type: 'workflow', owner_id: 'leo', parent_id: null, page_name: null,
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
confidence: null, metadata_json: JSON.stringify({ embed }), created_at: 1, updated_at: 1,
};
// ── 測試專用資料存取 helper:把所有直接下 SQL 的呼叫收斂到這裡(每行標記留痕)──────────
function insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料
e.id,
e.content === undefined ? 'x' : e.content, // 區分「沒提供」(undefined→預設'x') 與「顯式 null」(保留 null)
(e.entry_type ?? 'workflow') as EntryType,
e.owner_id ?? 'leo',
e.content_hash ?? null,
e.is_embedded ?? 0,
e.metadata_json ?? JSON.stringify({ embed: true }),
e.created_at,
e.created_at,
).run();
}
function makeEnv(store: Entry[], withBindings: boolean): Bindings {
async function getRow(db: D1Database, id: string): Promise<{ id: string; is_embedded: number; content_hash: string | null } | null> {
return db.prepare('SELECT id, is_embedded, content_hash FROM entries WHERE id = ?').bind(id).first(); // kbdb-sql-ok:測試治具讀回斷言用
}
async function listAllRows(db: D1Database): Promise<{ id: string; is_embedded: number; content_hash: string | null }[]> {
const res = await db.prepare('SELECT id, is_embedded, content_hash FROM entries').all<{ id: string; is_embedded: number; content_hash: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用
return res.results;
}
async function listEmbeddedIds(db: D1Database): Promise<string[]> {
const res = await db.prepare("SELECT id FROM entries WHERE is_embedded = 1").all<{ id: string }>(); // kbdb-sql-ok:測試治具讀回斷言用
return res.results.map((r) => r.id);
}
async function countUsageRows(db: D1Database): Promise<{ id: string; entry_type: string }[]> {
const res = await db.prepare("SELECT id, entry_type FROM entries WHERE entry_type = 'embed_backfill_usage'").all<{ id: string; entry_type: string }>(); // kbdb-sql-ok:測試治具驗證「不新增表、單列 upsert」
return res.results;
}
function makeEnv(db: D1Database, opts: { withBindings?: boolean; dailyLimit?: string; maintenanceLimit?: string } = {}): Bindings {
const withBindings = opts.withBindings ?? true;
const upserts: { id: string }[] = [];
const aiCalls: string[][] = [];
const getByIdsCalls: string[][] = [];
const vectorizeStore = new Set<string>(); // ids "present" in the current (fake) Vectorize index
const env = {
DB: makeFakeDB(store),
DB: db,
ENVIRONMENT: 'test',
EMBED_BACKFILL_DAILY_LIMIT: opts.dailyLimit,
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
...(withBindings
? {
AI: { async run(_m: string, i: { text: string[] }) { aiCalls.push(i.text); return { data: i.text.map(() => [0.1, 0.2, 0.3]) }; } },
VECTORIZE: { async upsert(v: { id: string }[]) { upserts.push(...v); return { count: v.length }; } },
AI: {
async run(_m: string, i: { text: string[] }) {
aiCalls.push(i.text);
return { data: i.text.map(() => [0.1, 0.2, 0.3]) };
},
},
VECTORIZE: {
async upsert(v: { id: string }[]) {
upserts.push(...v);
for (const x of v) vectorizeStore.add(x.id);
return { count: v.length };
},
async getByIds(ids: string[]) {
getByIdsCalls.push(ids);
return ids.filter((id) => vectorizeStore.has(id)).map((id) => ({ id, values: [0.1] }));
},
},
}
: {}),
} as unknown as Bindings;
(env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__upserts = upserts;
(env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__ai = aiCalls;
const bag = env as unknown as {
__upserts: unknown[]; __ai: unknown[]; __getByIds: unknown[];
__seedVectorized: (ids: string[]) => void;
};
bag.__upserts = upserts;
bag.__ai = aiCalls;
bag.__getByIds = getByIdsCalls;
bag.__seedVectorized = (ids: string[]) => { for (const id of ids) vectorizeStore.add(id); };
return env;
}
describe('backfillEmbeddings', () => {
it('module off → enabled:false, no-op (誠實不假綠)', async () => {
const store = [mkEntry('e1', 'hello', true)];
const env = makeEnv(store, false);
function aiCallsOf(env: Bindings): string[][] {
return (env as unknown as { __ai: string[][] }).__ai;
}
function upsertsOf(env: Bindings): { id: string }[] {
return (env as unknown as { __upserts: { id: string }[] }).__upserts;
}
function seedVectorized(env: Bindings, ids: string[]): void {
(env as unknown as { __seedVectorized: (ids: string[]) => void }).__seedVectorized(ids);
}
describe('backfillEmbeddings — 模組未開', () => {
it('誠實不假綠:no-op,含新增的 quota 欄位', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'e1', created_at: 1 });
const env = makeEnv(db, { withBindings: false });
expect(embedEnabled(env)).toBe(false);
const r = await backfillEmbeddings(env);
expect(r).toEqual({ enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 });
expect(store[0].is_embedded).toBe(0); // untouched
expect(r).toEqual({
enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0,
quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
});
});
});
it('embeds embeddable+is_embedded=0 entries, marks is_embedded=1, batches AI+upsert', async () => {
const store = [
mkEntry('e1', 'doorbell workflow', true),
mkEntry('e2', 'notify workflow', true),
mkEntry('e3', 'not tagged', false), // embed:false → not a candidate
mkEntry('e4', 'already done', true, 1), // is_embedded=1 → not a candidate
mkEntry('e5', ' ', true), // empty content → not embeddable
];
const env = makeEnv(store, true);
describe('backfillEmbeddings — 基本行為(沿用既有覆蓋,改動後仍要綠)', () => {
it('embeds embeddable+is_embedded=0 entries, marks is_embedded=1 + content_hash,批次 AI+upsert', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'e1', content: 'doorbell workflow', created_at: 1 });
insertEntry(db, { id: 'e2', content: 'notify workflow', created_at: 2 });
insertEntry(db, { id: 'e3', content: 'not tagged', created_at: 3, metadata_json: JSON.stringify({ embed: false }) });
insertEntry(db, { id: 'e4', content: 'already done', created_at: 4, is_embedded: 1 });
insertEntry(db, { id: 'e5', content: null, created_at: 5 }); // NULL content → 排除,非本次改動範圍的既有行為
const env = makeEnv(db);
const r = await backfillEmbeddings(env, { limit: 100 });
expect(r.enabled).toBe(true);
expect(r.processed).toBe(2); // only e1,e2
expect(r.remaining).toBe(0); // nothing left embeddable
expect(store.find((e) => e.id === 'e1')!.is_embedded).toBe(1);
expect(store.find((e) => e.id === 'e2')!.is_embedded).toBe(1);
expect(store.find((e) => e.id === 'e3')!.is_embedded).toBe(0);
const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts;
expect(upserts.map((u) => u.id).sort()).toEqual(['e1', 'e2']);
const ai = (env as unknown as { __ai: string[][] }).__ai;
expect(ai.length).toBe(1); // single batched AI.run for the whole batch
expect(ai[0].length).toBe(2);
expect(r.processed).toBe(2); // only e1, e2
expect(r.remaining).toBe(0);
const rows = await listAllRows(db);
const byId = Object.fromEntries(rows.map((x) => [x.id, x]));
expect(byId.e1.is_embedded).toBe(1);
expect(byId.e1.content_hash).toBe(CURRENT_MODEL); // 世代戳記有寫
expect(byId.e2.is_embedded).toBe(1);
expect(byId.e3.is_embedded).toBe(0);
expect(upsertsOf(env).map((u) => u.id).sort()).toEqual(['e1', 'e2']);
expect(aiCallsOf(env).length).toBe(1);
expect(aiCallsOf(env)[0].length).toBe(2);
});
it('idempotent: re-run after all embedded processes nothing', async () => {
const store = [mkEntry('e1', 'x', true)];
const env = makeEnv(store, true);
it('idempotent:全部嵌完後重跑不再處理', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'e1', content: 'x', created_at: 1 });
const env = makeEnv(db, { dailyLimit: '100' });
await backfillEmbeddings(env);
const r2 = await backfillEmbeddings(env);
expect(r2.processed).toBe(0);
expect(r2.remaining).toBe(0);
});
it('batches via limit → remaining reported so caller can loop to zero', async () => {
const store = [mkEntry('a', 'x', true), mkEntry('b', 'y', true), mkEntry('c', 'z', true)];
const env = makeEnv(store, true);
it('batches via limit → remaining 讓 caller 可重複呼叫到 0', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', content: 'x', created_at: 1 });
insertEntry(db, { id: 'b', content: 'y', created_at: 2 });
insertEntry(db, { id: 'c', content: 'z', created_at: 3 });
const env = makeEnv(db, { dailyLimit: '100' });
const r1 = await backfillEmbeddings(env, { limit: 2 });
expect(r1.processed).toBe(2);
expect(r1.remaining).toBe(1);
@@ -133,34 +199,270 @@ describe('backfillEmbeddings', () => {
expect(r2.remaining).toBe(0);
});
it('reindex: 重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0Arcrun#11', async () => {
// 三筆皆已 is_embedded=1(既有向量):正常 backfill 不會碰(pending=0),reindex 要全部重推
// 讓事後建立的 Vectorize metadata index 收錄。
const store = [
mkEntry('a', 'x', true, 1), mkEntry('b', 'y', true, 1), mkEntry('c', 'z', true, 1),
];
const env = makeEnv(store, true);
// 正常 backfill:沒有 is_embedded=0 → 什麼都不做(證明「不重推就補不到」)。
it('reindex重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0Arcrun#11', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', content: 'x', created_at: 1, is_embedded: 1 });
insertEntry(db, { id: 'b', content: 'y', created_at: 2, is_embedded: 1 });
insertEntry(db, { id: 'c', content: 'z', created_at: 3, is_embedded: 1 });
const env = makeEnv(db, { dailyLimit: '100' });
const normal = await backfillEmbeddings(env, { limit: 100 });
expect(normal.processed).toBe(0);
// reindex 分頁:第一批 2 筆、remaining=1;第二批 1 筆、remaining=0。
expect(normal.processed).toBe(0); // 沒有 is_embedded=0 → 什麼都不做
const r1 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 0 });
expect(r1.processed).toBe(2);
expect(r1.remaining).toBe(1);
const r2 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 2 });
expect(r2.processed).toBe(1);
expect(r2.remaining).toBe(0);
const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts;
expect(upserts.map((u) => u.id).sort()).toEqual(['a', 'b', 'c']);
expect(upsertsOf(env).map((u) => u.id).sort()).toEqual(['a', 'b', 'c']);
});
it('status reports pending/embedded counts', async () => {
const store = [mkEntry('e1', 'x', true), mkEntry('e2', 'y', true, 1)];
const env = makeEnv(store, true);
it('status 回報 pending/embedded 計數', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'e1', content: 'x', created_at: 1 });
insertEntry(db, { id: 'e2', content: 'y', created_at: 2, is_embedded: 1 });
const env = makeEnv(db);
const s = await backfillStatus(env);
// fake first() returns candidate count for pending; embedded query also runs through
// the same COUNT fake, so this asserts the call path works (enabled:true).
expect(s.enabled).toBe(true);
expect(typeof s.pending).toBe('number');
});
});
describe('D68①:由新到舊排序(實測,不是推論)', () => {
it('候選跨時間分佈時,先被嵌入的是 created_at 最新的那幾筆', async () => {
const db = makeSqliteD1();
// 刻意亂序插入,證明排序看的是 created_at 不是插入順序 / id 字母序
insertEntry(db, { id: 'old-2024', content: 'half year ago', created_at: 1_000 });
insertEntry(db, { id: 'today', content: 'written today', created_at: 100_000 });
insertEntry(db, { id: 'mid-2025', content: 'a few months ago', created_at: 50_000 });
const env = makeEnv(db, { dailyLimit: '100' });
// limit=1:一次只能處理一筆,若排序正確,該筆必須是 'today'created_at 最大)
const r = await backfillEmbeddings(env, { limit: 1 });
expect(r.processed).toBe(1);
const embedded = await listEmbeddedIds(db);
expect(embedded).toEqual(['today']);
expect(aiCallsOf(env)[0]).toEqual(['written today']);
});
});
describe('D68②:每日額度上限真的擋(把上限設小,跑到撞上限)', () => {
it('額度耗盡後停手,不再繼續打 AI;未耗盡的仍優先保留最新的(額度截斷 + 排序疊加)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'e-oldest', content: 'c1', created_at: 1 });
insertEntry(db, { id: 'e-old', content: 'c2', created_at: 2 });
insertEntry(db, { id: 'e-new', content: 'c3', created_at: 3 });
insertEntry(db, { id: 'e-newest', content: 'c4', created_at: 4 });
const env = makeEnv(db, { dailyLimit: '2' }); // 上限設得比候選數(4)小
const r = await backfillEmbeddings(env, { limit: 100 });
// 停手,不是繼續打:AI 只被叫過一次,且只帶 2 筆文字(不是全部 4 筆)
expect(aiCallsOf(env).length).toBe(1);
expect(aiCallsOf(env)[0].length).toBe(2);
expect(r.processed).toBe(2);
expect(r.quota_limit).toBe(2);
expect(r.quota_used_today).toBe(2);
expect(r.quota_exceeded).toBe(true); // 還有候選但今天不再打 AI
// 被留下處理的兩筆是最新的(e-newest, e-new),不是隨機或最舊的
const embedded = await listEmbeddedIds(db);
expect(embedded.sort()).toEqual(['e-new', 'e-newest']);
// 再跑一次(同一天):額度已用完,processed=0,AI 呼叫次數仍是 1(沒有再打)
const r2 = await backfillEmbeddings(env, { limit: 100 });
expect(r2.processed).toBe(0);
expect(r2.quota_exceeded).toBe(true);
expect(aiCallsOf(env).length).toBe(1); // 沒有新增呼叫
});
it('額度上限被拿掉時本測試會變紅(反向驗證:測試真的在測東西,不是恆真)', async () => {
const db = makeSqliteD1();
for (let i = 1; i <= 5; i++) insertEntry(db, { id: `e${i}`, content: `c${i}`, created_at: i });
// 不設 dailyLimit(用預設 1800,遠大於 5)→ 全部應被處理,模擬「上限被拿掉」的行為
const env = makeEnv(db);
const r = await backfillEmbeddings(env, { limit: 100 });
expect(r.processed).toBe(5);
expect(r.quota_exceeded).toBe(false);
// 對照組:把上限設到比候選數小,行為必須不同(證明上一組「額度=2」的測試不是巧合)
const db2 = makeSqliteD1();
for (let i = 1; i <= 5; i++) insertEntry(db2, { id: `e${i}`, content: `c${i}`, created_at: i });
const env2 = makeEnv(db2, { dailyLimit: '2' });
const r2 = await backfillEmbeddings(env2, { limit: 100 });
expect(r2.processed).toBe(2);
expect(r2.processed).not.toBe(r.processed); // 有 cap vs 沒 cap 必須不同,否則 cap 沒在起作用
});
it('額度計數跨呼叫累加,換日字串變動即歸零(不新增表,entries 單列 upsert', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'e1', content: 'c1', created_at: 1 });
insertEntry(db, { id: 'e2', content: 'c2', created_at: 2 });
const env = makeEnv(db, { dailyLimit: '10' });
const r1 = await backfillEmbeddings(env, { limit: 1 });
expect(r1.quota_used_today).toBe(1);
const r2 = await backfillEmbeddings(env, { limit: 1 });
expect(r2.quota_used_today).toBe(2); // 累加,不是每次重算成當批數
// 驗證只有一列計數器,且落在既有三表(entries),沒有新表
const usageRows = await countUsageRows(db);
expect(usageRows.length).toBe(1);
expect(usageRows[0].id).toMatch(/^embed-backfill-usage:\d{4}-\d{2}-\d{2}$/);
});
});
describe('D68③:leo21c 資料還原情境——is_embedded=1 但對應已退役索引的列補得回來', () => {
it('reconcile:確認在現行 index 的只補 content_hash,不打 AI', async () => {
const db = makeSqliteD1();
// 模擬「這次修復之前」就已經正確嵌入現行 index 的資料:is_embedded=1、content_hash 從未寫過(NULL
insertEntry(db, { id: 'ok-legacy', content: 'x', created_at: 1, is_embedded: 1, content_hash: null });
const env = makeEnv(db);
seedVectorized(env, ['ok-legacy']); // 現行 index 真的有它
const r = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r.checked).toBe(1);
expect(r.confirmed_current).toBe(1);
expect(r.reset_to_pending).toBe(0);
expect(aiCallsOf(env).length).toBe(0); // 沒有打 AI
const row = await getRow(db, 'ok-legacy');
expect(row!.is_embedded).toBe(1); // 沒被誤重置
expect(row!.content_hash).toBe(CURRENT_MODEL); // 補標記
});
it('reconcile 揪出真正對舊索引的殘留 → 重置回 pending → 正常 backfill 真的把它補回來(端到端)', async () => {
const db = makeSqliteD1();
// leo21c 情境:從備份整批灌回,is_embedded=1 但這是對已退役 768 維索引說的;
// 現行(1024 維)Vectorize index 裡沒有這個向量(不呼叫 seedVectorized)。
insertEntry(db, { id: 'restored-stale', content: '從備份還原的舊卡片', created_at: 999, is_embedded: 1, content_hash: null });
const env = makeEnv(db);
// step 1reconcile 應該發現它不在現行 index,重置成 pending
const r1 = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r1.checked).toBe(1);
expect(r1.confirmed_current).toBe(0);
expect(r1.reset_to_pending).toBe(1);
const midRow = await getRow(db, 'restored-stale');
expect(midRow!.is_embedded).toBe(0);
expect(midRow!.content_hash).toBe(null);
expect(r1.remaining).toBe(0); // 處理完,沒有更多待核對的了
// step 2:正常 backfill 現在會撿到它(因為 is_embedded=0 了),真的打 AI 補回來
const r2 = await backfillEmbeddings(env, { limit: 100 });
expect(r2.processed).toBe(1);
expect(aiCallsOf(env).length).toBe(1);
expect(aiCallsOf(env)[0]).toEqual(['從備份還原的舊卡片']);
const finalRow = await getRow(db, 'restored-stale');
expect(finalRow!.is_embedded).toBe(1); // 補回來了
expect(finalRow!.content_hash).toBe(CURRENT_MODEL); // 蓋上現行世代戳記,下次 reconcile 不會再選到它
});
it('已經是現行世代(content_hash 等於現行模型)的列不會被 reconcile 重複選中', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'fresh', content: 'x', created_at: 1, is_embedded: 1, content_hash: CURRENT_MODEL });
const env = makeEnv(db);
const r = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r.checked).toBe(0);
expect(r.remaining).toBe(0);
});
it('模組未開 → 誠實回 enabled:false,不假裝', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'e1', content: 'x', created_at: 1, is_embedded: 1 });
const env = makeEnv(db, { withBindings: false });
const r = await reconcileEmbedGeneration(env);
expect(r).toEqual({
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,
});
});
});
describe('Arcrun#85 D69reconcile 的 D1 寫入額度(與標庫 backfill 共用的計數器)', () => {
it('額度耗盡後 reconcile 停手:不再寫 D1quota_exceeded=true', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'r1', content: 'c1', created_at: 1, is_embedded: 1, content_hash: null });
insertEntry(db, { id: 'r2', content: 'c2', created_at: 2, is_embedded: 1, content_hash: null });
insertEntry(db, { id: 'r3', content: 'c3', created_at: 3, is_embedded: 1, content_hash: null });
const env = makeEnv(db, { maintenanceLimit: '2' }); // 上限比候選數(3)小
seedVectorized(env, ['r1', 'r2', 'r3']); // 全在現行 indexconfirmed_current 路徑,仍是 D1 write
const r = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r.scanned).toBe(3); // 掃到 3 筆候選
expect(r.checked).toBe(2); // 但只處理了額度允許的 2 筆
expect(r.confirmed_current).toBe(2);
expect(r.quota_limit).toBe(2);
expect(r.quota_used_today).toBe(2);
expect(r.quota_exceeded).toBe(true);
// 只有 2 筆真的被寫回 content_hash(最新的兩筆,ORDER BY created_at DESC
const rows = await listAllRows(db);
const byId = Object.fromEntries(rows.map((x) => [x.id, x]));
expect(byId.r3.content_hash).toBe(CURRENT_MODEL);
expect(byId.r2.content_hash).toBe(CURRENT_MODEL);
expect(byId.r1.content_hash).toBe(null); // 額度用完,沒輪到它
// 再跑一次(同一天):額度已用完,checked=0
const r2 = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r2.checked).toBe(0);
expect(r2.quota_exceeded).toBe(true);
});
it('額度上限被拿掉時本測試會變紅(反向驗證,同 D68② 手法)', async () => {
const db = makeSqliteD1();
for (let i = 1; i <= 5; i++) insertEntry(db, { id: `r${i}`, content: `c${i}`, created_at: i, is_embedded: 1, content_hash: null });
const env = makeEnv(db); // 不設 maintenanceLimit → 用預設 20000,遠大於 5,全部應被處理
seedVectorized(env, ['r1', 'r2', 'r3', 'r4', 'r5']);
const r = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r.checked).toBe(5);
expect(r.quota_exceeded).toBe(false);
const db2 = makeSqliteD1();
for (let i = 1; i <= 5; i++) insertEntry(db2, { id: `r${i}`, content: `c${i}`, created_at: i, is_embedded: 1, content_hash: null });
const env2 = makeEnv(db2, { maintenanceLimit: '2' });
seedVectorized(env2, ['r1', 'r2', 'r3', 'r4', 'r5']);
const r2 = await reconcileEmbedGeneration(env2, { limit: 100 });
expect(r2.checked).toBe(2);
expect(r2.checked).not.toBe(r.checked); // 有 cap vs 沒 cap 必須不同,否則 cap 沒在作用
});
});
describe('Arcrun#85:「挑哪一批」可以從外面指定(SelectionCriteriasince/until/library', () => {
it('backfillEmbeddings 帶 since/until 只補時間窗內的候選', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'too-old', content: 'x', created_at: 100 });
insertEntry(db, { id: 'in-window-1', content: 'y', created_at: 500 });
insertEntry(db, { id: 'in-window-2', content: 'z', created_at: 800 });
insertEntry(db, { id: 'too-new', content: 'w', created_at: 1500 });
const env = makeEnv(db, { dailyLimit: '100' });
const r = await backfillEmbeddings(env, { limit: 100, since: 400, until: 1000 });
expect(r.processed).toBe(2);
expect((await listEmbeddedIds(db)).sort()).toEqual(['in-window-1', 'in-window-2']);
});
it('backfillEmbeddings 帶 library 只補該庫的候選(未標記歸 general)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'finance-1', content: 'x', created_at: 1, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
insertEntry(db, { id: 'hr-1', content: 'y', created_at: 2, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
insertEntry(db, { id: 'untagged', content: 'z', created_at: 3 }); // 無 library → general
const env = makeEnv(db, { dailyLimit: '100' });
const r = await backfillEmbeddings(env, { limit: 100, library: 'finance' });
expect(r.processed).toBe(1);
expect(await listEmbeddedIds(db)).toEqual(['finance-1']);
const r2 = await backfillEmbeddings(env, { limit: 100, library: 'general' });
expect(r2.processed).toBe(1);
expect((await listEmbeddedIds(db)).sort()).toEqual(['finance-1', 'untagged']);
});
it('reconcile 帶 since/until/library 同樣受篩選(同一套 SelectionCriteria,非獨立實作)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'old', content: 'x', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
insertEntry(db, { id: 'new', content: 'y', created_at: 100, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
insertEntry(db, { id: 'other-lib', content: 'z', created_at: 100, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
const env = makeEnv(db);
seedVectorized(env, ['old', 'new', 'other-lib']);
const r = await reconcileEmbedGeneration(env, { limit: 100, library: 'finance', since: 50 });
expect(r.checked).toBe(1);
const row = await getRow(db, 'new');
expect(row!.content_hash).toBe(CURRENT_MODEL);
const oldRow = await getRow(db, 'old');
expect(oldRow!.content_hash).toBe(null); // 在時間窗外,沒被動到
});
});
+230
View File
@@ -0,0 +1,230 @@
// 標庫 backfillArcrun#85 二次裁決,2026-08-11)測試。
//
// 測試策略比照 embed-backfill.test.ts:真 SQLitenode:sqlite)套 migrations/0001_base.sql
// 原檔,驗真實 SQL 語意(json_setWHERELIMIT),不是「以為 SQL 長這樣」。
//
// 覆蓋:
// 1. 只補「符合條件、目前未標記 library」的候選;已標記的不動(冪等)
// 2. owner_id 必填(缺了要拋錯,防「補錯 owner 等於白做」——2026-08-11 leo 直令)
// 3. source_prefixpage_name_prefixsinceuntil 篩選條件真的在篩
// 4. 與 reconcileEmbedGeneration 共用同一顆每日 D1 寫入額度(D69 的核心訴求:
// 補標不能把世代核對的閘繞過去,反之亦然)
//
// 本檔在 kbdb/tests/(牆外),依 D38 kbdb-api-wall-guard 規則,直接對 SQLite 治具下 SQL
// 的行集中在 helper(測試治具本身,非牆外業務邏輯繞過 API,每行標 kbdb-sql-ok 留痕)。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../src/actions/library-backfill';
import { reconcileEmbedGeneration } from '../src/embed';
import type { Bindings, Entry, EntryType } from '../src/types';
// ── node:sqlite → D1 介面最小 adapter(同 embed-backfill.test.ts 手法)──────────────
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() {
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
function insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, page_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料
e.id,
e.content === undefined ? 'x' : e.content,
(e.entry_type ?? 'block') as EntryType,
e.owner_id ?? 'bfezv28v',
e.content_hash ?? null,
e.is_embedded ?? 0,
e.metadata_json === undefined ? null : e.metadata_json,
e.page_name ?? null,
e.created_at,
e.created_at,
).run();
}
async function getLibrary(db: D1Database, id: string): Promise<string | null> {
const row = await db.prepare("SELECT json_extract(metadata_json, '$.library') AS library FROM entries WHERE id = ?").bind(id).first<{ library: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用
return row?.library ?? null;
}
function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
return {
DB: db,
ENVIRONMENT: 'test',
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
} as unknown as Bindings;
}
describe('backfillEntryLibraryTags — 基本行為', () => {
it('只標記符合條件、目前未標記 library 的候選;已標記的不動', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1 }); // 無 metadata_json → 未標記
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({}) }); // 有 metadata_json 但無 library
insertEntry(db, { id: 'c', created_at: 3, metadata_json: JSON.stringify({ library: 'hr' }) }); // 已標記,不該被動
const env = makeEnv(db);
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
expect(r.tagged).toBe(2);
expect(r.remaining).toBe(0);
expect(await getLibrary(db, 'a')).toBe('finance');
expect(await getLibrary(db, 'b')).toBe('finance');
expect(await getLibrary(db, 'c')).toBe('hr'); // 未被覆寫
});
it('冪等:全部標記完後重跑不再處理', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1 });
const env = makeEnv(db);
await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
const r2 = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
expect(r2.tagged).toBe(0);
expect(r2.remaining).toBe(0);
});
it('owner_id 缺了要拋錯(防補錯 owner 等於白做,2026-08-11 leo 直令)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1 });
const env = makeEnv(db);
await expect(
backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: '' }),
).rejects.toThrow(/owner_id/);
});
it('library 缺了要拋錯', async () => {
const db = makeSqliteD1();
const env = makeEnv(db);
await expect(
backfillEntryLibraryTags(db, env, { library: '', owner_id: 'bfezv28v' }),
).rejects.toThrow(/library/);
});
it('owner_id 篩選:只動指定租戶的資料,其他租戶不受影響(跨租戶隔離)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'mine', created_at: 1, owner_id: 'bfezv28v' });
insertEntry(db, { id: 'theirs', created_at: 2, owner_id: 'someone-else' });
const env = makeEnv(db);
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
expect(r.tagged).toBe(1);
expect(await getLibrary(db, 'mine')).toBe('finance');
expect(await getLibrary(db, 'theirs')).toBe(null); // 別的租戶完全沒被動到
});
it('source_prefixpage_name_prefixsinceuntil 篩選真的在篩', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'match-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/kb/foo.md' }) });
insertEntry(db, { id: 'other-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/other/bar.md' }) });
const r1 = await backfillEntryLibraryTags(db, makeEnv(db), {
library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea://Leo/kb/',
});
expect(r1.tagged).toBe(1);
expect(await getLibrary(db, 'match-source')).toBe('kb');
expect(await getLibrary(db, 'other-source')).toBe(null);
const db2 = makeSqliteD1();
insertEntry(db2, { id: 'in-window', created_at: 500, page_name: 'wiki/foo' });
insertEntry(db2, { id: 'out-window', created_at: 5000, page_name: 'wiki/bar' });
const r2 = await backfillEntryLibraryTags(db2, makeEnv(db2), {
library: 'wiki', owner_id: 'bfezv28v', page_name_prefix: 'wiki/', since: 0, until: 1000,
});
expect(r2.tagged).toBe(1);
expect(await getLibrary(db2, 'in-window')).toBe('wiki');
expect(await getLibrary(db2, 'out-window')).toBe(null);
});
it('page_names 精準比對(leo 定案的正解:拿 Gitea 原稿卡名逐批遍歷點名)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1, page_name: 'card-alpha' });
insertEntry(db, { id: 'b', created_at: 2, page_name: 'card-beta' });
insertEntry(db, { id: 'c', created_at: 3, page_name: 'card-gamma' }); // 不在點名清單內
const r = await backfillEntryLibraryTags(db, makeEnv(db), {
library: 'kb', owner_id: 'bfezv28v', page_names: ['card-alpha', 'card-beta'],
});
expect(r.tagged).toBe(2);
expect(await getLibrary(db, 'a')).toBe('kb');
expect(await getLibrary(db, 'b')).toBe('kb');
expect(await getLibrary(db, 'c')).toBe(null); // 沒被點名,不動
});
it('libraryBackfillStatus 回報待補標筆數', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1 });
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({ library: 'hr' }) });
const s = await libraryBackfillStatus(db, { owner_id: 'bfezv28v' });
expect(s.pending).toBe(1); // 只有 'a' 未標記
});
});
describe('Arcrun#85 D69:標庫 backfill 與 reconcile 共用同一顆 D1 每日寫入額度', () => {
it('reconcile 先消耗額度 → 標庫 backfill 看到的剩餘額度真的變少', async () => {
const db = makeSqliteD1();
// reconcile 的候選:is_embedded=1 且 content_hash 非現行世代
// library 已標記('hr')→ 不會被下面的標庫 backfill 選中,讓兩種候選池互不重疊,
// 才能單純驗證「額度共用」本身,不被「標庫候選也吃到 reconcile 資料」干擾。
insertEntry(db, { id: 'reconcile-1', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
insertEntry(db, { id: 'reconcile-2', created_at: 2, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
// 標庫的候選:未標記 library
insertEntry(db, { id: 'tag-1', created_at: 3 });
insertEntry(db, { id: 'tag-2', created_at: 4 });
insertEntry(db, { id: 'tag-3', created_at: 5 });
const maintenanceLimit = '3'; // 5 個候選(2 reconcile + 3 tag),額度只夠 3 個
const reconcileEnv = {
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
AI: { async run() { return { data: [] }; } },
VECTORIZE: {
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); }, // 全部視為現行 index 已有
async upsert() { return { count: 0 }; },
},
} as unknown as Bindings;
// 先跑 reconcile:吃掉 2 筆額度(3 - 2 = 1 剩)
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
expect(rc.checked).toBe(2);
expect(rc.quota_used_today).toBe(2);
// 標庫 backfill 用同一顆 DB/同一個每日上限:只剩 1 筆額度可用,即使候選有 3 筆
const tagEnv = makeEnv(db, { maintenanceLimit });
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
expect(tagResult.scanned).toBe(3); // 3 筆候選都掃到了
expect(tagResult.tagged).toBe(1); // 但只剩 1 筆額度,只標了 1 筆
expect(tagResult.quota_exceeded).toBe(true);
expect(tagResult.quota_used_today).toBe(3); // 2reconcile+ 1(本次)= 3,額度用滿
});
it('反過來也一樣:標庫 backfill 先消耗額度 → reconcile 看到的剩餘額度真的變少', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'tag-1', created_at: 1 });
insertEntry(db, { id: 'tag-2', created_at: 2 });
insertEntry(db, { id: 'reconcile-1', created_at: 3, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true }) });
const maintenanceLimit = '2';
const tagEnv = makeEnv(db, { maintenanceLimit });
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
expect(tagResult.tagged).toBe(2); // 額度剛好夠標完兩筆
const reconcileEnv = {
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
AI: { async run() { return { data: [] }; } },
VECTORIZE: {
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); },
async upsert() { return { count: 0 }; },
},
} as unknown as Bindings;
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
expect(rc.scanned).toBe(1); // 有 1 筆候選
expect(rc.checked).toBe(0); // 但額度已被標庫 backfill 用光,reconcile 一筆都動不了
expect(rc.quota_exceeded).toBe(true);
});
});
+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
+29 -10
View File
@@ -30,6 +30,13 @@ import (
"unsafe"
)
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。
const (
hostOK uint32 = 0
hostError uint32 = 1
hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
)
// ── host function 宣告 ───────────────────────────────────────────────────────
//go:wasmimport u6u kv_get
@@ -320,9 +327,17 @@ func doRefresh(input Input, recipe AuthRecipe) (string, int64, bool) {
formBody := form.Encode()
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
respStr, ok2 := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
if !ok2 {
writeError("token endpoint HTTP 請求失敗")
respStr, code := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
if code == hostTooLarge {
writeError("token endpoint 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" +
"這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" +
"多半表示 " + cfg.TokenEndpoint + " 回的不是正常的 token JSON(例如回了一整頁 HTML 錯誤頁);" +
"請確認 auth recipe 的 token_endpoint 指向正確的 token 端點。")
return "", 0, false
}
if code != hostOK {
writeError("token endpoint 沒有拿到回應:引擎的 host function 回傳錯誤碼 " +
strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。")
return "", 0, false
}
@@ -387,7 +402,7 @@ func writeError(msg string) {
func kvGet(key string) (string, uint32) {
keyBytes := []byte(key)
outBuf := make([]byte, 65536)
var outLen uint32
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut
status := hostKvGet(
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
@@ -419,7 +434,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
return "", false
}
outBuf := make([]byte, 65536)
var outLen uint32
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
status := hostCryptoDecrypt(
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
@@ -432,18 +447,22 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
return string(outBuf[:outLen]), true
}
func httpRequest(reqURL, method, headersJSON, body string) (string, bool) {
// httpRequest 回傳 (回應原文, host function 回傳碼)。
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。
// 之所以不再只回 bool:bool 把「連不上」和「回應太大」混成同一句話,
// 使用者拿到 "token endpoint HTTP 請求失敗" 會往連線方向查,方向全錯(Arcrun#92)。
func httpRequest(reqURL, method, headersJSON, body string) (string, uint32) {
urlBytes := []byte(reqURL)
methodBytes := []byte(method)
headersBytes := []byte(headersJSON)
bodyBytes := []byte(body)
if len(urlBytes) == 0 {
return "", false
return "", hostError
}
outBuf := make([]byte, 65536)
var outLen uint32
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
var bodyPtr uintptr
if len(bodyBytes) > 0 {
@@ -462,9 +481,9 @@ func httpRequest(reqURL, method, headersJSON, body string) (string, bool) {
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", false
return "", status
}
return string(outBuf[:outLen]), true
return string(outBuf[:outLen]), hostOK
}
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
@@ -19,11 +19,19 @@ import (
"io"
"net/url"
"os"
"strconv"
"strings"
"time"
"unsafe"
)
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。
const (
hostOK uint32 = 0
hostError uint32 = 1
hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
)
// ── host function 宣告 ───────────────────────────────────────────────────────
//go:wasmimport u6u kv_get
@@ -267,9 +275,17 @@ func main() {
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
respStr, ok := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
if !ok {
writeError("token exchange HTTP 失敗")
respStr, code := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
if code == hostTooLarge {
writeError("token exchange 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" +
"這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" +
"多半表示 " + recipe.TokenExchange.Endpoint + " 回的不是正常的 token JSON" +
"(例如回了一整頁 HTML 錯誤頁);請確認 auth recipe 的 token_exchange.endpoint 正確。")
return
}
if code != hostOK {
writeError("token exchange 沒有拿到回應:引擎的 host function 回傳錯誤碼 " +
strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。")
return
}
@@ -344,11 +360,12 @@ func pemToPkcs8(pem string) ([]byte, error) {
return base64.StdEncoding.DecodeString(cleaned)
}
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
// kvGet 呼叫 host function,回傳 (value, status)。
// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBufArcrun#92
func kvGet(key string) (string, uint32) {
keyBytes := []byte(key)
outBuf := make([]byte, 65536)
var outLen uint32
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut
status := hostKvGet(
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
@@ -364,7 +381,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
encBytes := []byte(encB64)
ivBytes := []byte(ivB64)
outBuf := make([]byte, 65536)
var outLen uint32
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
if len(encBytes) == 0 || len(ivBytes) == 0 {
return "", false
@@ -387,7 +404,7 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
return nil, false
}
outBuf := make([]byte, 1024) // RSA-2048 簽章 = 256 bytes,1KB 綽綽有餘
var outLen uint32
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
status := hostCryptoSignRS256(
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
@@ -400,19 +417,21 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
return outBuf[:outLen], true
}
// httpRequest 呼叫 host,回傳 response body 字串(host 側把 status + body 串好)
func httpRequest(url, method, headersJSON, body string) (string, bool) {
// httpRequest 呼叫 host,回傳 (response body 字串, host function 回傳碼)。
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。
// 不再只回 bool 的理由(Arcrun#92):bool 把「連不上」與「回應太大」講成同一句話。
func httpRequest(url, method, headersJSON, body string) (string, uint32) {
urlBytes := []byte(url)
methodBytes := []byte(method)
headersBytes := []byte(headersJSON)
bodyBytes := []byte(body)
if len(urlBytes) == 0 {
return "", false
return "", hostError
}
outBuf := make([]byte, 65536)
var outLen uint32
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
// bodyBytes 可能為空(GET),host function 允許 len=0
var bodyPtr uintptr
@@ -432,9 +451,9 @@ func httpRequest(url, method, headersJSON, body string) (string, bool) {
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", false
return "", status
}
return string(outBuf[:outLen]), true
return string(outBuf[:outLen]), hostOK
}
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串。
+6 -3
View File
@@ -287,11 +287,14 @@ func writeError(msg string) {
os.Stdout.Write(out)
}
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
// kvGet 呼叫 host function,回傳 (value, status)。
// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBufArcrun#92
func kvGet(key string) (string, uint32) {
keyBytes := []byte(key)
outBuf := make([]byte, 65536)
var outLen uint32
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,host 才能在值塞不下時
// 回 status=3(值太大)而不是硬寫爆這塊記憶體。見 wasi-shim.ts writeOut。
outLen := uint32(len(outBuf))
status := hostKvGet(
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
@@ -309,7 +312,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
encBytes := []byte(encB64)
ivBytes := []byte(ivB64)
outBuf := make([]byte, 65536)
var outLen uint32
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
// 處理空字串的防呆(TinyGo 取 &[]byte{}[0] 會 panic)
if len(encBytes) == 0 || len(ivBytes) == 0 {
+17 -2
View File
@@ -15,9 +15,14 @@ import (
"encoding/json"
"io"
"os"
"strconv"
"unsafe"
)
// 與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組回傳碼。
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
const hostTooLarge uint32 = 3
//go:wasmimport u6u http_request
func hostHttpRequest(
urlPtr uintptr, urlLen uint32,
@@ -102,7 +107,9 @@ func main() {
methodBytes := []byte("POST")
outBuf := make([]byte, 1024*1024) // 1MB
var outLen uint32
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,塞不下時 host 會回一段
// 講明「回應太大 + 實際/上限大小 + 該怎麼辦」的 envelope,而不是硬寫爆記憶體。
outLen := uint32(len(outBuf))
urlPtr, urlLen := safePtr(urlBytes)
methodPtr, methodLen := safePtr(methodBytes)
@@ -117,8 +124,16 @@ func main() {
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區
if result == hostTooLarge {
writeError("Mira 的回應太大,裝不下:超過這個零件單次能接收的 1 MB 上限。" +
"這不是連線失敗,也不是 Mira 沒回應。" +
"做法:把 prompt 改成請 Mira 回短一點(或分段回),或改用 callback_url 走非同步取回。")
return
}
if result != 0 {
writeError("Mira daemon request failed (host_http_request returned non-zero)")
writeError("沒有拿到 Mira 的回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是 prompt 寫錯。")
return
}
+21 -2
View File
@@ -9,9 +9,14 @@ import (
"encoding/json"
"io"
"os"
"strconv"
"unsafe"
)
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組數字。
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92:以前這種情況會被說成 "HTTP request failed"
const hostTooLarge uint32 = 3
// host function 宣告(由 WASI shim 注入)
//
//go:wasmimport u6u http_request
@@ -86,7 +91,11 @@ func main() {
headersBytes := []byte(headersJSON)
bodyBytes := []byte(bodyStr)
outBuf := make([]byte, 65536) // 64KB output buffer
var outLen uint32
// 容量握手(Arcrun#92):呼叫前先把緩衝區大小告訴 host。
// hostcypher-executor/src/lib/wasi-shim.ts 的 writeOut)拿這個值當上限——
// 塞不下時不會硬寫爆這塊記憶體,而是改寫一段「回應太大 + 實際/上限大小 + 該怎麼辦」
// 的 error envelope 回來,由下面既有的 parsed["error"] 判定鏈原樣交給使用者。
outLen := uint32(len(outBuf))
urlPtr, urlLen := safePtr(urlBytes)
methodPtr, methodLen := safePtr(methodBytes)
@@ -101,8 +110,18 @@ func main() {
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
// host function 回傳碼(定義在 wasi-shim.ts):0=成功 1=host 端錯誤 3=回應塞不下緩衝區
if result == hostTooLarge {
// 走到這裡=連「回應太大」的說明本身都塞不進緩衝區(極端情況),
// 所以零件自己講。訊息一樣要講清楚真因,不能退回 "HTTP request failed"。
writeError("回應太大,裝不下:對方的回應超過這個零件單次能接收的 64 KB 上限。" +
"這不是連線失敗,資料也沒有被截掉一半。" +
"做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試。")
return
}
if result != 0 {
writeError("HTTP request failed")
writeError("沒有拿到回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是你的 workflow 參數寫錯。")
return
}
+19 -1
View File
@@ -1,5 +1,23 @@
// wait — 等待指定毫秒數後繼續(最多 30 秒)
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
//
// ⚠️ 已由引擎接手,這份 WASM 在 Cloudflare Workers 上跑不動(Arcrun#1012026-08-12)。
// 現行實作在 cypher-executor/src/lib/constants.ts 的 BUILTIN_COMPONENTS['wait']
// component-loader step 1 先命中,這顆 wasm 不會再被工作流呼叫到。
//
// 為什麼跑不動(不是「比較慢」,是「永遠不會結束」):
// 下面的 time.Sleep 在 TinyGo 走 WASI poll_oneoff,而 component worker 的 WASI shim
// 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get 自旋;
// Workers 的時鐘在無 I/O 的同步執行期間是凍結的 ⇒ 結束條件永遠不成立 ⇒ 一路燒到
// CPU 上限被砍(error 1102)。leo 實測 ms=3000/20000/30000 全在 ~35 秒後 503
// 死法與 ms 無關 —— 這正是「迴圈沒結束」而非「等待很貴」的證據。
//
// 原本的舊註解寫「改用 busy-wait 模擬」是錯的:這個檔從來沒有 busy-wait,
// 一直是 time.Sleep。那句話誤導了後來每一個讀這個檔的人。
//
// 本次刻意不改行為、只改註解:手邊沒有 TinyGo 工具鏈,改了 main.go 卻沒重編,
// 會讓 repo 內已 commit 的 .component-builds/wait/component.wasm 與原始碼漂移
// rule 05「WASM 來源」:那份 wasm 是 self-host 用戶的部署來源)。
// 要退役這顆零件(刪目錄/下架 wait.arcrun.dev)是另一個決定,需人拍板。
package main
import (
+91
View File
@@ -0,0 +1,91 @@
/**
* Arcrun#92 重現腳本 回應太大到底會讓使用者看到什麼訊息
*
* 為什麼要有這支http_request 零件的接收緩衝區是 64 KB回應超過這個大小時
* 舊版會硬把資料寫進零件記憶體寫爆或讓 host 丟例外回 1零件對外只講得出一句
* "HTTP request failed"使用者照那句去查連線防火牆URL方向全錯
* 這支腳本把那個情境真的做出來修之前 / 修之後的訊息可以並排比
*
* 用法本機不碰任何線上實例
*
* # 修之後工作區現在的 wasm
* node scripts/repro-oversize-response.mjs 200000
*
* # 修之前 main 上的舊 wasm 取出來當對照組 wasm 不做容量握手 走舊路徑
* git show origin/main:.component-builds/http_request/component.wasm > /tmp/old-http_request.wasm
* node scripts/repro-oversize-response.mjs 200000 /tmp/old-http_request.wasm
*
* # 對照沒超過上限時兩者都應該正常
* node scripts/repro-oversize-response.mjs 1024
*
* Node < 22.18 請加 --experimental-strip-types本檔會 import 一支 .ts
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..');
const { createWasiShim } = await import(
resolve(repoRoot, 'cypher-executor/src/lib/wasi-shim.ts')
);
const responseBytes = Number(process.argv[2] ?? 200_000);
const wasmPath = process.argv[3]
? resolve(process.argv[3])
: resolve(repoRoot, '.component-builds/http_request/component.wasm');
// 假裝遠端回了一包很大的 JSON2xxhost function 照原樣把 body 交給零件)
const filler = 'x'.repeat(Math.max(0, responseBytes - 14));
const remoteBody = JSON.stringify({ items: filler });
const shim = createWasiShim(
JSON.stringify({ url: 'https://example.com/big-list', method: 'GET' }),
{ http_request: async () => remoteBody },
);
const instance = await WebAssembly.instantiate(
await WebAssembly.compile(readFileSync(wasmPath)),
shim.imports,
);
shim.setMemory(instance.exports.memory);
let crashed = null;
try {
await shim.run(instance);
} catch (e) {
crashed = e instanceof Error ? e.message : String(e);
}
const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
console.log(`wasm : ${wasmPath}`);
console.log(`模擬回應大小 : ${remoteBody.length} bytes(零件緩衝區上限 65536 bytes`);
console.log('');
// 以下複刻 .component-builds/http_request/src/index.ts 的收尾,
// 印出「使用者真的會拿到的那一包」
if (stderr) console.log(`零件 stderr : ${stderr.slice(0, 300)}`);
if (crashed) console.log(`WASM 執行中止 : ${crashed}`);
if (!stdout) {
console.log('使用者看到 : HTTP 500 {"success":false,"error":"WASM component produced no output"}');
process.exit(0);
}
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (e) {
console.log(`使用者看到 : HTTP 500 {"success":false,"error":"${e.message}"}`);
process.exit(0);
}
console.log(`success : ${parsed.success}`);
console.log(`error : ${parsed.error ?? '(無)'}`);
if (parsed.success) {
const body = parsed.data?.body ?? '';
console.log(`data.body 長度 : ${String(body).length} bytes`);
}
@@ -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 後再清。
+1
View File
@@ -341,6 +341,7 @@ arcrun 不自管加密金鑰,`crypto_decrypt` host function 已成永遠回失
|------|--------|------|------|
| ~~**credential 注入 401**~~ | ✅ 已解 | **8.1-8.5 全完成(2026-06-25 確認)** | 機制(auth_static_key `resolve_credentials` + graph-executor `resolveCredentialRefs`)已端到端實證:2026-06-13 Notion `{{credential.notion_token}}` 真讀到資料(同等於 8.5 OpenAI 驗收,機制與服務無關)。tasks.md 8.5 已補 `[x]` |
| §8 P1/P2 recipe/workflow list 遷 D1 | 🔴 高 | 架構已拍板未動 code | 走 kbdb /entries HTTP 雙寫不加 binding;依賴 D1(現已可建)。另開 session 做 |
| 零件接收緩衝區有硬上限(http_request 64KBclaude_api 1MB | 🟡 中 | 訊息已誠實(Arcrun#92),**上限本身還在** | 回應超過上限=真的抓不回來。修的是「以前說成 HTTP request failed」,現在改說「回應太大+實際/上限大小+改用分頁」。host↔零件走**容量握手**(零件先把 outBuf 長度寫進 `*outLenPtr`host 塞不下回 `HOST_TOO_LARGE=3`,見 `wasi-shim.ts`)。要真的支援大回應得另外設計(分頁/串流),不是調大 buffer 就好 |
| 4 份 inline http_request host fn 抽共用 helper | 🟡 中 | 待 dedup | http_request/claude_api/kbdb_upsert_block/km_writer 各自複製貼上同段(這次假綠修也是逐份改) |
| `arcrun.dev/llms.txt` 404 | 🟡 中 | 未 serve | landing/public 缺檔;GitHub repo 內正常(test/5 走 GitHub 不阻擋) |
| MCP account-source | 🟡 中 | 記錄中 | self-hosted MCP 指官方不指自己(§5.2 已知) |