Compare commits

...

27 Commits

Author SHA1 Message Date
uncle6me-web 4aacd79e00 feat(component): 新增 hash 零件——出貨線版本號機制不必再用 code 腹語術(Arcrun#91)
出貨線的版本號由內容指紋算出(內容一變版本必變),build 站核對官方成品指紋
也要它。此前 registry 裡只有 code 節點提到 hash,沒有專門零件——純計算本該是
一顆零件,不是一段腳本(D70)。

- registry/components/hash/:TinyGo 實作,sha256/sha1/md5,hex/base64 輸出
- .component-builds/hash/:部署包(component.wasm 已 commit,比照 rule 05)
- cypher-executor/src/lib/component-loader.ts:WASM_HTTP_RUNNER_IDS 白名單加
  'hash',否則 `component: hash` 在任何實例上都會落到 step 8「找不到零件」
  (與 #29 的 code 缺口同形狀);對應單元測試
  cypher-executor/tests/component-loader-hash.test.ts
- pending-human-gate/README.md:hash 部分已落地移除,#89 維持原狀

驗證(見 PR 說明附完整輸出):
- wasmtime 本地跑六種 gherkin 情境,結果與系統 shasum/md5 逐位元一致
- 部署到 youlin 測試場(arcrun-hash.youlin-hsieh-dev.workers.dev),POST 直打
  六種情境同樣一致
- 在真實工作流(hash-e2e-test-91,youlin 測試場觸發)跑過,含 component-loader
  白名單修法的單元測試(vitest + cloudflare:test,本地綠燈)

查證「零件投稿要人工互動解閘」一事:scripts/component-arm.sh、
.claude/hooks/component-guard.sh 均不存在;pre-write-guard.sh 對
registry/components/ 的限制只擋 TS 檔與 auth_* 命名放錯目錄,不擋本次改動。
沒有那個「只有人做得到」的具體物件,故未卡關直接走完。

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

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

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

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

agent 誠實標的殘項(不擋併):
  · kbdb_get_map 回 1851、Portal 總圖畫面、部分授權帳號實測——都要部署 leo21c,紅線沒碰
  · 想把閘也接進 .claude/hooks/pre-write-guard.sh(寫入前就擋),該檔受保護改不動;
    檢查器已備妥 --stdin 模式,總管代接
2026-08-12 16:24:45 +00:00
uncle6me-web cac874601f chore(builds): 重編 tier2 成品——把 #108 放進執行檔(出貨路徑 A 的第 0 步)
原始碼修好不等於使用者拿得到:`.worker-builds/` 是安裝器與 `acr update`
實際部署的那份執行檔。本次一併重編,repo_dirty=false、repo_head=b223a698。

  arcrun-cypher-executor  sha256=e0026a23792f  source=b223a698 ← #108
  arcrun-kbdb             sha256=9c6d41895d78  source=f87d0e92(未變)
  arcrun-http-request     sha256=9a9dcb71879a  source=1e85dfb4(未變)
  arcrun-code             sha256=285a7406ec69  source=621cb8d9(未變)
  arcrun-mcp              sha256=3ebc0d441bc0  source=10d150ac(未變)

其餘四顆的 js 位元組有變是 esbuild 版本/相依重裝造成的重編,source commit 未動。
本次起這條路徑多一道閘:編成品前先跑租戶來源檢查,違規直接「建置中止」
(見同 PR 的 scripts/build-worker-artifacts.mjs)——實跑輸出貼在 PR。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:16:55 +08:00
uncle6me-web b223a69884 fix(portal): 藏書地圖看得到自己的知識——租戶字串改從「寫入端」來,不再拿環境變數預設值(Arcrun#108)
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。

病根(不是資料掉了,是讀寫兩端各拿一個來源):
  寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
    leo = `bfezv28v`)
  讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
    ——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
  ⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。

與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。

租戶字串該從哪裡來(本票的核心判斷):
  **從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
  不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
  帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
  #105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。

修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
   - `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
     丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
     偽裝成「你沒有資料」的元凶。
   - `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
     自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
   - 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
   但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
   都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
   ——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
   未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
   scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
   「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
   ——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。

留下的閘(規則存在但沒機制驗證=會再犯第三次):
  · 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
  · 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
  · 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
    逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
  規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。

沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。

驗證:
  cypher   vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
           tsc 5 個既有錯誤,零新增
  cli      node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
  閘       壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
  端到端   ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:58 +08:00
uncle6me-web fad5da0e17 rules/07 §3.6:自舉例外——能力只實作一次,但不一定要是 HTTP API
代補 PR #111 的 agent 想加但改不動的段落(該檔受保護)。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

驗證:cli 58/58 綠(含新增的兩條路一致性 fixture + 三種情境),tsc --noEmit 乾淨。
2026-08-12 23:37:01 +08:00
uncle6me-web e05518a2b4 chore(builds): 重編成品——把 #107 的版本標籤修法放進執行檔
出貨線第 1 站的 #93 新鮮度閘擋下:cypher-executor 成品記 10d150a,
而 HEAD 已是 2129356(#107 改了 routes/health.ts 與 types.ts)。

這是該閘今晚第三次擋對——沒有它,1.4.42 會送出一個「版本標籤修法只在源碼裡」的成品。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:22:57 +08:00
Leo 21293568d5 Merge PR #107: 更新完還看得到版本號——CLI 重部署不再把版本標籤洗掉(Arcrun#106)
總管複驗(不聽自述,自己重跑並與 base 逐條比對):
  cli      main 是 0 pass / 3 fail(測試根本跑不起來)→ PR 49 pass / 0 fail
           ⇒ agent 那句「#97 的守衛測試一直沒在跑」是真的,又一個假綠
  cypher   14 failed / 402 passed(base 14 / 400);失敗清單 diff 無輸出=零回歸

設計比我建議的對:版本標籤每趟重烙、其他 vars 才沿用。
我原本建議「一律沿用」會讓版本號永遠停在安裝當時。

殘項(agent 誠實標的,不擋併):沒在真實例上跑過、沒有真 Portal 截圖
(它的環境指向 leo21c 紅線、無 youlin 憑證、瀏覽器未授權)。總管接手補這段。
2026-08-12 14:05:57 +00:00
uncle6me-web 53b05c6d3d fix(cli): 更新完還看得到版本號——CLI 重部署不再把版本標籤(和你的設定)洗掉
leo 08-12 實撞:更新完 leo21c,Portal 設定頁的「版本」變成
「無法讀取目前版本(知識庫服務可能正在啟動)」。版本號是 leo 唯一的驗收介面,
看不到就等於他無法自己確認任何一次更新有沒有生效。

根因(Arcrun#106):`bundle_version` 來自部署時注入的 plain_text var
`ARCRUN_BUNDLE_VERSION`,而**只有安裝器會注入**。wrangler deploy 是整份覆蓋,
toml 沒寫的 var 直接消失 ⇒ CLI 更新那條路每跑一次就把標籤洗掉一次。
#97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),沒修「櫃子上的標籤」。

修法(兩種 var 走相反的規則,這是本次的判斷):
· 設定類 var = 使用者實例的事實 → **沿用**(讀綁定時同一份回應就帶回來,不多打 API)
  ——把 #97「已部署的 worker 上綁著什麼就是事實」原封不動套用到 plain_text var。
· 版本標籤 = 這份成品的屬性 → **每趟重烙,絕不沿用舊值**。
  沿用舊值會得到一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
  因為它會讓人以為驗收過了。
  版號取部署當下發行頻道公告的 release(Portal/daemon 就是拿它當「最新版」比),
  另外把**真正部署的 commit** 一起烙上去(/health 多吐 `bundle_commit`)→ 漂掉查得出來。
  查不到 release 就誠實退成 `YYYY-MM-DD+<commit7>`,不掰一個 semver 假裝已是最新。

順帶(都是同一條路上的東西):
· ref 先解析成 commit sha 再用 sha 下載 archive——不可變,順手解掉 branch tarball 被快取的老病
· Portal 版本行接受帶 build metadata 的 semver(`1.4.41+d61` 這種先前一律被當成「較舊版本」)
· cli 測試在 node 22 上本來一支都跑不起來(.js→.ts 解析 + parameter property),補上 resolve hook
  ——#97 那份「使用者的東西還在不在」的迴歸守衛也在其中,跑不起來的守衛等於沒有守衛
· types.ts 的 ARCRUN_BUNDLE_VERSION 重複宣告(TS2300)併回一處

驗證見 PR:cli 49/49 綠、cypher health 4/4 綠、Portal 版本行原始碼實跑五種情境、
對真實已部署 worker 的唯讀 dry-run。**未做**:真實實例上的 acr update 端到端
(本機唯一有憑證的帳號是 leo21c=紅線禁碰,youlin 無憑證)。

Refs: Leo/Arcrun#106, #97, #95

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:58:43 +08:00
uncle6me-web f87d0e92f4 fix(migrations): 0005/0006 從來沒進過版控——被 *.sql 規則吃掉,每個用戶都收到「部署物缺」
leo 更新 leo21c 時撞到(他問「部分失敗?」):
  ✗ D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql
  ✗ D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql
(四顆 worker cypher/registry/kbdb/mcp 全部 ✓,失敗的只有這兩個檔)

根因不是誰忘了推:.gitignore:53 的 `*.sql` 是為了擋 D1 匯出備份(整庫全量=機敏),
但它連 migration 一起吃掉。0001-0004 還在,只因為它們在該規則之前就 commit 了
(gitignore 不影響已追蹤檔案)⇒ 0005/0006 從產生那天起就不在任何 clone 裡。

⇒ 這不是 leo 一台的事:更新指令從 Gitea 抓 main,那兩個檔不在那裡
   ⇒ **任何人裝/更新都會收到同一組失敗**,包含全新安裝。

修法照 rules/05-deploy-convention.md「WASM 來源」段已有的慣例
(`.component-builds/**/component.wasm` 就是用否定規則放行的):
  !kbdb/migrations/*.sql

範圍實測(沒開太大):
  kbdb/migrations/0005、0006      → 放行
  backup-2026.sql / kbdb/backup-x.sql / dump.sql / cypher-executor/export.sql → 仍被擋

進版控前確認過無機敏值:grep 命中的 token/secret/api_key 全是欄位名
(api_key、secret_ref)與註解;無 >=20 位英數的疑似真值。

殘項:leo21c 實查 templates 9 個、credential 不在其中 ⇒ 0005 從未套用,
那台仍停在 D38 之前(credentials 走 0002 的獨立表)。要補套需另跑一次更新。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:10:12 +08:00
uncle6me-web ba152bc83a chore(builds): 重編 tier2 成品——把 #105 放進執行檔(出貨路徑 A 的第 0 步)
leo 選 A(把出貨線推到能出一版含 #105 的 bundle)。查證後發現最前面還有一層:

  cypher 源碼最後動 10d150a(#105)  / 成品最後動 a24f291(更早)
  mcp    源碼最後動 10d150a(#105)  / 成品最後動 8e10f1d(更早)

⇒ #105 改了 cypher 與 mcp 兩邊源碼,但沒有重編成品。
   這正是 Arcrun#93 那道「源碼比執行檔新就停下來」的閘要擋的狀態。

用官方唯一編譯點 scripts/build-worker-artifacts.mjs(Arcrun#80 的機制,已存在)
重編五顆,全部 5/5:
  arcrun-cypher-executor  571KB  source=10d150ac
  arcrun-kbdb             146KB  source=10d150ac
  arcrun-mcp             1152KB  source=10d150ac
  arcrun-http-request      78KB  source=1e85dfb4(未變)
  arcrun-code             150KB  source=621cb8d9(未變)

交叉驗證(不只信它自記的 commit):/portal/data/ 這條 #105 才有的路徑
在 arcrun-mcp 成品裡出現 8 次。

下一步(等 leo 解閘):把修好的引擎部署到 geek6688 當出貨機
(ARCRUN_SHIP_BASE 可覆寫,預設是 leo21c——arcrun-rag#79 要搬離的正是這個),
再從那台跑出貨線,第 17 站 purge 的 wait 節點才有帶修法的引擎可跑。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:03:30 +08:00
Leo 89b80ff90e Merge PR #105: MCP 用登入者的身分查詢,不再去找服務內部金鑰
總管複驗(不聽自述,自己重跑,並與 base a24f291 逐條比對):
  mcp     tsc 乾淨;vitest 113/113 綠
  cypher  14 紅與 base 逐字元相同(diff 無輸出);passed 386→400,新增 14 條全過
  kbdb    5 紅與 base 相同
  安全邊界 11 條全綠(跨租戶 404/越庫 404/owner_id 由 server 定死)

殘項(已記,不擋併):
  · kbdb 的 owner_id 欄位改動沒有新測試守著(213 總數未變)
  · 工作流面仍靠 MCP_OWNER_NAMESPACE || leo 那個巧合,本 PR 刻意沒動(拔了 arcrun_* 會全面失效)
  · 端到端  未驗:要部署到 leo21c,那道閘要 leo 親手解
2026-08-12 11:42:03 +00:00
uncle6me-web 10d150ac2b fix(mcp): MCP 用登入者的身分查詢,不再去找一把服務內部金鑰
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。

病根(不是金鑰沒同步,是身分沒接住):
  oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
  namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
  只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
  而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
  所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。

修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
   display_name/role/libraries 存進 authorization code → access token。
   /portal/login 補回 session_expires_in,access_token TTL 夾成
   min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
   cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
   session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
   kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
   藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
   分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
   服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。

新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
  GET  /portal/data/map、/portal/data/map/:library
  GET  /portal/data/templates、POST /portal/data/templates
  GET  /portal/data/records/by-template/:t、GET /portal/data/records/:id
  POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。

KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。

沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。

驗證:
  mcp        tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
  cypher     vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
  kbdb       vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
  端到端     ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:33:12 +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
91 changed files with 9847 additions and 762 deletions
+31
View File
@@ -142,6 +142,37 @@ SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/`
---
---
## 第六類:租戶字串來源(Arcrun#108#105 同族)
### 6.1 靜態租戶字串不得用於資料面過濾
**知識資料面的 `owner_id`(三元組/entriesrecords/藏書地圖/工作流 KV)必須與寫入端同源。**
寫入端只有一個真相源=使用者 `~/.arcrun/config.yaml``api_key`(=實例 namespace
CLI push/小幫手上傳/MCP 都用它)。讀取端拿另一份手抄的環境變數預設值 → 全被過濾掉。
實害:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` 讓 leo 的 **1854 條三元組被過濾成 0 個庫**
#108);前一天 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` 是同一句話(#105)。
**規則**
1. `cypher-executor/src/lib/tenant.ts` 是租戶字串的**唯一產地**。
`CONSOLE_TENANT` / `ARCRUN_NAMESPACE` 只能在該檔被讀取。
2. 知識資料面用 `knowledgeOwner(env)`(回 `TenantId`),過濾一律經
`ownerQuery()` / `ownerField()`——它們只吃 `TenantId``tsc` 就擋掉「隨手一個 string」。
3. 帳號層用 `accountTenant(env)`(回 `string`**刻意不是 TenantId**):帳號子 namespace
`{tenant}::portal` 與 cypher 自己寫的設定用它,型別上不可能流進知識資料面。
4. 身分解析路徑上**不准有字面預設值**。解析不到 → 丟 `TenantUnresolvedError`
誠實回「讀不到」(不是「你沒有」,#100 同一條)。
**機械強制**(規則存在但沒機制驗證=它會再犯第三次):
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編 tier2 成品前先掃,違規 → **編不出成品**
- 本機自查:`cd cypher-executor && npm run check:tenant``npm test` 也會先跑它)。
- 規則本體:`cypher-executor/scripts/tenant-source-rules.mjs`(純函式);
閘自己的測試:`cypher-executor/tests/tenant-gate.test.ts`(壞例子會擋+合法寫法零誤攔)。
> 尚未接上 PreToolUse hook`.claude/hooks/` 為受保護檔案,需人類加入)。
> 要加的話:檢查器已備妥 `--stdin <相對路徑>` 模式,可在寫入前擋。
## Hook Block 訊息格式
當 hook 擋住一個操作時,訊息格式統一為:
+32
View File
@@ -99,6 +99,38 @@ CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」
---
## 3.6 自舉例外:能力該「只實作一次」,但不一定要是 HTTP API2026-08-12 立)
> 立這條的原因:`Arcrun#97`(更新把使用者的工作流與登入弄不見)的修法一開始寫在
> `cli/src/lib/resource-resolver.ts` ——**能力住在介面層,違反 §0**。
> 後果不是理論:**安裝器(arcrun-rag)拿不到它,於是同一個 bug 只修了一半**,
> 走 `acr` 的人有保護、走 `install.arcrun.dev` 的人沒有——**而所有真實用戶走後者**。
> leo 2026-08-12:「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
修法(PR #111)把它搬到 **`shared/resource-rule/`:一份零依賴 ESM**
`acr` 與安裝器共用。**它刻意不是 cypher 的 API 端點**,三個理由:
| 為什麼不放 API | 說明 |
|---|---|
| **自舉** | 這條規則要在「決定怎麼裝」的當下用得到,而安裝器的工作正是把 cypher 生出來。放進 cypher = 要先有雞才能有蛋。 |
| **輸入是使用者自己的帳號狀態** | 判斷依據是使用者 CF 帳號上的綁定。送去平台託管的 worker 換答案 ⇒ ①「能不能安裝」綁在平台是否活著 ②使用者的帳號拓撲交給第三方。 |
| **它根本不需要是服務** | 這是**純函式**,唯一的 IO 由呼叫端注入。**§0 要求「能力只實作一次」,不是「能力一定要是 HTTP」。** |
🔴 **所以本檔 §0 的正確讀法是**:能力**只准有一份**,且**不准住在任何單一介面裡**。
「放 API」是達成它的**常見手段**,不是唯一手段。
**判準仍然是那句口訣**:「這段邏輯換一個介面要不要重寫?」要 → 它是能力。
📌 **給下一個人**:看到 `shared/` 底下的純函式**不要「修正」成 API 端點**——
先讀 `shared/resource-rule/README.md §2`,那裡記著評估過並否決的其他形態
(共用 npm 套件=自舉問題換位置;做成零件=要用 TinyGo 重寫一次,那才是第二份實作)。
📌 **打包例外**`acr` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒
`cli/` 下必須有一份**逐位元組副本**。那不是第二份實作——
`scripts/sync-resource-rule.mjs --check` 一有漂移就 exit 1,且 `build``test` 都會先跑它
(同 `cli/harness/` 的既有慣例)。**手改副本 = build 紅 publish 擋下。**
---
## 4. 統一帳號來源(薄殼共用同一身份)
所有薄殼讀**同一份**身份設定:
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
{
"name": "arcrun-component-worker-template",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
+917
View File
@@ -0,0 +1,917 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.13.1
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250408.0
version: 4.20260702.1
typescript:
specifier: ^5.4.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.121.0(@cloudflare/workers-types@4.20260702.1)
packages:
'@cloudflare/kv-asset-handler@0.5.0':
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
engines: {node: '>=22.0.0'}
'@cloudflare/unenv-preset@2.16.1':
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: '>1.20260305.0 <2.0.0-0'
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260804.1':
resolution: {integrity: sha512-191/PPEFicRK2wK69eXzSjnLgHL79k7zR2VUpyIr8rhFgGns1b5bTHgnBuUrgU2LPbEtwbE5eL2hJ0uhLAFEow==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260804.1':
resolution: {integrity: sha512-aI2cAFLsrNkSz3kLSQrgrO9ICTfY7jb2h0jgaWDE9mQLQQDfpXeYrKWt07tTgMmeNP79o9w3NZe3aqtt8mTGHQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260804.1':
resolution: {integrity: sha512-KBCjxBIlN2jucfQGaTK4EgmPsWzQgYR/zYhPfi3mkWwdoTyG1dgrt2aizKps/SYse85ci/SOxojKk0/K7vstPw==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260804.1':
resolution: {integrity: sha512-7lswfarBZ7xkHpTFrNb74ExwOltIalVaASc+GOYfCNtnwFxK/JZSVByfm/hnZEBtrHU7fMY2z7dYT64rwS0pHg==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260804.1':
resolution: {integrity: sha512-GOgRWYtxISN5rAWfx3K7zubt3xEn0/ZPFrbcLL+GwT4ouCKyNoHqfT1cPNB6Nx7t8BHEjnuTG7gkHO4Wd5ORdg==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260702.1':
resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.2':
resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.2':
resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.2':
resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.1':
resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.1':
resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.1':
resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.1':
resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.1':
resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.1':
resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.1':
resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.1':
resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.2':
resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.2':
resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.2':
resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.2':
resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.2':
resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.2':
resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.2':
resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.2':
resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.2':
resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.2':
resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.2':
resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.2':
resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.2':
resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.24':
resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.13.1:
resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@5.20260804.1-alpha:
resolution: {integrity: sha512-J0QBHEj+d75TyFE9VhH+2dXgvdYt/pfyDlm+9IiYUFocQvqYy9iuW1DIqNawh1N7Kr6iMrDzVkgDSdt6pA75uA==}
engines: {node: '>=22.0.0'}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.35.2:
resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==}
engines: {node: '>=20.9.0'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260804.1:
resolution: {integrity: sha512-b0P38g5/ssemwWxd/mafNYggEZ0ere7PCiUH6RCHkgqjRhhXTP45nDiL2L3iIvi8uF2IjNrGpVgMXEunCaeY/w==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.121.0:
resolution: {integrity: sha512-dcARWk6CyaD0vJBSLjJn4K2yo+mYko73hzryq+t/9DXnyAq877RWIMl7uOMcDKs5dDXdoickNhZTKxBYT9XKsA==}
engines: {node: '>=22.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^5.20260804.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260804.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260804.1
'@cloudflare/workerd-darwin-64@1.20260804.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260804.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260804.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260804.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260804.1':
optional: true
'@cloudflare/workers-types@4.20260702.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.28.1':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.1
optional: true
'@img/sharp-darwin-x64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.1
optional: true
'@img/sharp-freebsd-wasm32@0.35.2':
dependencies:
'@img/sharp-wasm32': 0.35.2
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.1':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.1':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.1':
optional: true
'@img/sharp-libvips-linux-arm@1.3.1':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.1':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.1':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.1':
optional: true
'@img/sharp-libvips-linux-x64@1.3.1':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
optional: true
'@img/sharp-linux-arm64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.1
optional: true
'@img/sharp-linux-arm@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.1
optional: true
'@img/sharp-linux-ppc64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.1
optional: true
'@img/sharp-linux-riscv64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.1
optional: true
'@img/sharp-linux-s390x@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.1
optional: true
'@img/sharp-linux-x64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.1
optional: true
'@img/sharp-linuxmusl-arm64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
optional: true
'@img/sharp-linuxmusl-x64@0.35.2':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
optional: true
'@img/sharp-wasm32@0.35.2':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-webcontainers-wasm32@0.35.2':
dependencies:
'@img/sharp-wasm32': 0.35.2
optional: true
'@img/sharp-win32-arm64@0.35.2':
optional: true
'@img/sharp-win32-ia32@0.35.2':
optional: true
'@img/sharp-win32-x64@0.35.2':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.24': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
'@esbuild/android-arm': 0.28.1
'@esbuild/android-arm64': 0.28.1
'@esbuild/android-x64': 0.28.1
'@esbuild/darwin-arm64': 0.28.1
'@esbuild/darwin-x64': 0.28.1
'@esbuild/freebsd-arm64': 0.28.1
'@esbuild/freebsd-x64': 0.28.1
'@esbuild/linux-arm': 0.28.1
'@esbuild/linux-arm64': 0.28.1
'@esbuild/linux-ia32': 0.28.1
'@esbuild/linux-loong64': 0.28.1
'@esbuild/linux-mips64el': 0.28.1
'@esbuild/linux-ppc64': 0.28.1
'@esbuild/linux-riscv64': 0.28.1
'@esbuild/linux-s390x': 0.28.1
'@esbuild/linux-x64': 0.28.1
'@esbuild/netbsd-arm64': 0.28.1
'@esbuild/netbsd-x64': 0.28.1
'@esbuild/openbsd-arm64': 0.28.1
'@esbuild/openbsd-x64': 0.28.1
'@esbuild/openharmony-arm64': 0.28.1
'@esbuild/sunos-x64': 0.28.1
'@esbuild/win32-arm64': 0.28.1
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
fsevents@2.3.3:
optional: true
hono@4.13.1: {}
kleur@4.1.5: {}
miniflare@5.20260804.1-alpha:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.35.2
undici: 7.29.0
workerd: 1.20260804.1
ws: 8.21.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.8.5: {}
sharp@0.35.2:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.2
'@img/sharp-darwin-x64': 0.35.2
'@img/sharp-freebsd-wasm32': 0.35.2
'@img/sharp-libvips-darwin-arm64': 1.3.1
'@img/sharp-libvips-darwin-x64': 1.3.1
'@img/sharp-libvips-linux-arm': 1.3.1
'@img/sharp-libvips-linux-arm64': 1.3.1
'@img/sharp-libvips-linux-ppc64': 1.3.1
'@img/sharp-libvips-linux-riscv64': 1.3.1
'@img/sharp-libvips-linux-s390x': 1.3.1
'@img/sharp-libvips-linux-x64': 1.3.1
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
'@img/sharp-linux-arm': 0.35.2
'@img/sharp-linux-arm64': 0.35.2
'@img/sharp-linux-ppc64': 0.35.2
'@img/sharp-linux-riscv64': 0.35.2
'@img/sharp-linux-s390x': 0.35.2
'@img/sharp-linux-x64': 0.35.2
'@img/sharp-linuxmusl-arm64': 0.35.2
'@img/sharp-linuxmusl-x64': 0.35.2
'@img/sharp-webcontainers-wasm32': 0.35.2
'@img/sharp-win32-arm64': 0.35.2
'@img/sharp-win32-ia32': 0.35.2
'@img/sharp-win32-x64': 0.35.2
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.29.0: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260804.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260804.1
'@cloudflare/workerd-darwin-arm64': 1.20260804.1
'@cloudflare/workerd-linux-64': 1.20260804.1
'@cloudflare/workerd-linux-arm64': 1.20260804.1
'@cloudflare/workerd-windows-64': 1.20260804.1
wrangler@4.121.0(@cloudflare/workers-types@4.20260702.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260804.1)
blake3-wasm: 2.1.5
esbuild: 0.28.1
miniflare: 5.20260804.1-alpha
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260804.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260702.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.21.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.24
cookie: 1.1.1
youch-core: 0.3.3
+142
View File
@@ -0,0 +1,142 @@
/**
* arcrun logic component Worker
*
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
*
* WASM is statically bundled at build time via wrangler.toml [[wasm_modules]].
* Each logic component gets its own Worker at {name}.arcrun.dev.
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: COMPONENT_ID }));
app.post('/', async (c) => {
let input: unknown;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
try {
const result = await runWasm(componentWasm, input);
return c.json(result);
} catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 500);
}
});
export default app;
// ── WASM runner (WASI preview1 stdin/stdout) ─────────────────────────────────
declare const COMPONENT_ID: string; // injected via [vars] in wrangler.toml
async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<unknown> {
const stdinBytes = new TextEncoder().encode(JSON.stringify(input));
let stdinOffset = 0;
const stdoutChunks: Uint8Array[] = [];
let memory: WebAssembly.Memory | null = null;
const getView = () => new DataView(memory!.buffer);
const wasi: WebAssembly.Imports = {
wasi_snapshot_preview1: {
fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number {
if (fd !== 1 && fd !== 2) return 76; // ENOSYS
const view = getView();
let total = 0;
for (let i = 0; i < iovs_len; i++) {
const base = view.getUint32(iovs + i * 8, true);
const len = view.getUint32(iovs + i * 8 + 4, true);
if (len === 0) continue;
const chunk = new Uint8Array(memory!.buffer, base, len);
const copy = new Uint8Array(len);
copy.set(chunk);
if (fd === 1) stdoutChunks.push(copy);
total += len;
}
view.setUint32(nwritten_ptr, total, true);
return 0;
},
fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number {
if (fd !== 0) return 76;
const view = getView();
let total = 0;
for (let i = 0; i < iovs_len; i++) {
const base = view.getUint32(iovs + i * 8, true);
const len = view.getUint32(iovs + i * 8 + 4, true);
const remaining = stdinBytes.length - stdinOffset;
if (remaining <= 0) break;
const toCopy = Math.min(len, remaining);
new Uint8Array(memory!.buffer, base, toCopy).set(
stdinBytes.subarray(stdinOffset, stdinOffset + toCopy)
);
stdinOffset += toCopy;
total += toCopy;
}
view.setUint32(nread_ptr, total, true);
return 0;
},
proc_exit(code: number): never { throw new Error(`wasm exit: ${code}`); },
random_get(ptr: number, len: number): number {
crypto.getRandomValues(new Uint8Array(memory!.buffer, ptr, len));
return 0;
},
fd_seek: () => 76, fd_close: () => 0,
fd_fdstat_get: () => 76, fd_prestat_get: () => 76,
fd_prestat_dir_name: () => 76, environ_get: () => 0,
environ_sizes_get: (cp: number, sp: number) => {
if (memory) { const v = getView(); v.setUint32(cp,0,true); v.setUint32(sp,0,true); }
return 0;
},
args_get: () => 0,
args_sizes_get: (ap: number, bp: number) => {
if (memory) { const v = getView(); v.setUint32(ap,0,true); v.setUint32(bp,0,true); }
return 0;
},
clock_time_get: (_id: number, _prec: bigint, tp: number) => {
if (memory) getView().setBigUint64(tp, BigInt(Date.now()) * 1_000_000n, true);
return 0;
},
clock_res_get: () => 76, poll_oneoff: () => 76, sched_yield: () => 0,
proc_raise: () => 76, sock_accept: () => 76, sock_recv: () => 76,
sock_send: () => 76, sock_shutdown: () => 76,
path_open: () => 76, path_create_directory: () => 76,
path_remove_directory: () => 76, path_rename: () => 76,
path_unlink_file: () => 76, path_filestat_get: () => 76,
path_readlink: () => 76, path_symlink: () => 76, path_link: () => 76,
},
// u6u host functions (no-op for pure logic components)
u6u: { http_request: () => 1 },
};
const instance = await WebAssembly.instantiate(wasmModule, wasi);
memory = instance.exports.memory as WebAssembly.Memory;
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
try { start(); } catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const decoder = new TextDecoder();
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const chunk of stdoutChunks) { merged.set(chunk, off); off += chunk.length; }
const stdout = decoder.decode(merged).trim();
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
+8
View File
@@ -52,6 +52,14 @@ scripts/__pycache__/
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
*.sql
backup-*.sql
# 🔴 但 migration 不是備份,它是**要出貨的程式碼**(2026-08-12 實撞):
# 上面那條 `*.sql` 的用意是擋 D1 匯出(整庫全量資料=機敏),卻連 migration 一起吃掉。
# 後果:0001-0004 因為在該規則之前就 commit 所以還在,**0005/0006 從此沒進過版控**
# ⇒ 更新指令從 Gitea 抓 main,那兩個檔根本不在那裡 ⇒ 每個用戶都會收到
# 「✗ D1 migration: 部署物缺 kbdb/migrations/0005…」——**不是誰忘了推,是規則吃掉的**。
# ⇒ 與 `.component-builds/**/component.wasm` 同慣例(見 rules/05-deploy-convention.md
# 「WASM 來源」段),用否定規則放行。備份檔仍由 `backup-*.sql` 與目錄位置擋住。
!kbdb/migrations/*.sql
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
.github-public/
+417 -50
View File
@@ -2650,7 +2650,7 @@ var init_recipes = __esm({
});
// cypher-executor/src/lib/constants.ts
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, BUILTIN_COMPONENTS;
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, WAIT_MAX_MS, BUILTIN_COMPONENTS;
var init_constants3 = __esm({
"cypher-executor/src/lib/constants.ts"() {
"use strict";
@@ -2698,6 +2698,7 @@ var init_constants3 = __esm({
"CLICK": "ON_CLICK",
"SUBFLOW": "CALLS_SUBFLOW"
};
WAIT_MAX_MS = 3e4;
BUILTIN_COMPONENTS = /* @__PURE__ */ new Map([
["comp_passthrough", (ctx) => ctx],
["comp_uppercase", (ctx) => {
@@ -2707,6 +2708,54 @@ var init_constants3 = __esm({
["comp_counter", (ctx) => {
const c = ctx;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
["wait", async (ctx) => {
const c = ctx && typeof ctx === "object" ? ctx : {};
const requested = typeof c.ms === "number" ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: "ms \u5FC5\u9808\u5927\u65BC 0" };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
await new Promise((resolve) => setTimeout(resolve, ms));
const passthrough = c.context && typeof c.context === "object" && !Array.isArray(c.context) ? c.context : {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}]
]);
}
@@ -3060,7 +3109,12 @@ var init_component_loader = __esm({
filter: "SVC_FILTER",
merge: "SVC_MERGE",
try_catch: "SVC_TRY_CATCH",
wait: "SVC_WAIT",
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: "SVC_SET",
array_ops: "SVC_ARRAY_OPS",
string_ops: "SVC_STRING_OPS",
@@ -3145,6 +3199,11 @@ function graphBase(env) {
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, "");
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
function graphHeaders(env) {
const headers = {};
if (env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return headers;
}
var kbdbProxyRouter, NEED_KEY;
var init_kbdb_proxy = __esm({
"cypher-executor/src/routes/kbdb-proxy.ts"() {
@@ -3278,8 +3337,7 @@ var init_kbdb_proxy = __esm({
kbdbProxyRouter.get("/kbdb/graph/neighbors/:name", async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param("name"))}`, { headers });
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
@@ -9283,9 +9341,11 @@ function authStoreStatus(env) {
var healthRouter = new Hono2();
healthRouter.get("/health", (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
return c.json({
ok: true,
...bundleVersion ? { bundle_version: bundleVersion } : {},
...bundleCommit ? { bundle_commit: bundleCommit } : {},
auth_store: authStoreStatus(c.env),
// arcrun-rag#38/#69/#252026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
@@ -12497,6 +12557,45 @@ init_kbdb_proxy();
// cypher-executor/src/routes/console-auth.ts
init_dist();
// cypher-executor/src/lib/tenant.ts
var TenantUnresolvedError = class extends Error {
constructor(message) {
super(message);
this.name = "TenantUnresolvedError";
}
};
function knowledgeOwner(env) {
const injected = (env.ARCRUN_NAMESPACE ?? "").trim();
if (injected) return injected;
const legacy = (env.CONSOLE_TENANT ?? "").trim();
if (legacy) return legacy;
throw new TenantUnresolvedError(
"\u9019\u500B\u90E8\u7F72\u6C92\u6709\u77E5\u8B58\u547D\u540D\u7A7A\u9593\uFF08ARCRUN_NAMESPACE / CONSOLE_TENANT \u90FD\u6C92\u8A2D\uFF09\u2014\u2014\u4E0D\u77E5\u9053\u8981\u53BB\u54EA\u4E00\u683C\u627E\u8CC7\u6599\u3002\u8ACB\u8DD1 `acr update` \u8B93\u5B83\u5F9E\u4F60\u7684 ~/.arcrun/config.yaml \u6CE8\u5165\u3002"
);
}
function tenantFromApiKey(apiKey) {
const key = (apiKey ?? "").trim();
if (!key) throw new TenantUnresolvedError("\u7F3A\u5C11 X-Arcrun-API-Key\uFF0C\u7121\u6CD5\u6C7A\u5B9A\u67E5\u8A62\u7BC4\u570D");
return key;
}
function accountTenant(env) {
return env.CONSOLE_TENANT || "leo";
}
function ownerQuery(tenant2) {
return `owner_id=${encodeURIComponent(tenant2)}`;
}
function ownerField(tenant2) {
return tenant2;
}
function censusQueryAllTenants() {
return "owner_id=";
}
function isOwnedBy(value, tenant2) {
return typeof value === "string" && value === tenant2;
}
// cypher-executor/src/routes/console-auth.ts
var consoleAuthRouter = new Hono2();
var CREDS_KEY = "console:credentials";
var SESSION_PREFIX = "console_sess:";
@@ -12523,7 +12622,7 @@ async function hashPassword(password, salt) {
return h;
}
function tenantOf(c) {
return c.env.CONSOLE_TENANT || "leo";
return knowledgeOwner(c.env);
}
async function loadCredentials(env) {
let fromStore = readAuthStore(env).console;
@@ -12794,10 +12893,10 @@ var DEFAULT_SESSION_TTL = 604800;
var USER_TEMPLATE = "portal_user";
var LIBRARY_TEMPLATE = "portal_library";
function portalTenant(env) {
return env.CONSOLE_TENANT || "leo";
return accountTenant(env);
}
function portalNamespace(env) {
return `${portalTenant(env)}::portal`;
return `${accountTenant(env)}::portal`;
}
function sessionTtl(env) {
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10);
@@ -12826,6 +12925,9 @@ async function run(c, fn) {
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `\u8A8D\u8B49\u5132\u5B58\u5BEB\u5165\u5931\u6557\uFF1A${e.message}`, code: "auth_store_not_writable" }, 502);
}
if (e instanceof TenantUnresolvedError) {
return c.json({ error: e.message, code: "tenant_unresolved" }, 500);
}
if (e instanceof KbdbError) return c.json({ error: `KBDB \u4E0D\u53EF\u9054\u6216\u56DE\u932F\uFF1A${e.message}` }, 502);
throw e;
}
@@ -13227,7 +13329,12 @@ portalRouter.post(
session_token: token,
display_name: rec.values.display_name ?? "",
role: rec.values.role ?? "user",
libraries: parseLibraries(rec.values.libraries)
libraries: parseLibraries(rec.values.libraries),
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
session_expires_in: sessionTtl(c.env)
// 絕不回租戶字串(design §3.3portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*
});
})
@@ -13703,10 +13810,9 @@ portalRouter.post(
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
}
await clearLoginFail(c.env, email);
const tenant2 = portalTenant(c.env);
const daemonCfg = {
cypher_url: new URL(c.req.url).origin,
namespace: tenant2,
namespace: knowledgeOwner(c.env),
library: "kb",
email,
instance_name: String(rec.values.display_name ?? "")
@@ -13722,7 +13828,7 @@ portalRouter.post(
const body = await c.req.json().catch(() => null);
const key = String(body?.key ?? "").trim();
if (!key) return c.json({ error: "\u8ACB\u8CBC\u4E0A\u4F60\u7684 Google AI \u91D1\u9470" }, 400);
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const kvKey2 = `${tenant2}:wf:rag_chat`;
const raw2 = await c.env.WEBHOOKS.get(kvKey2, "text");
if (!raw2) return c.json({ error: "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u5B89\u88DD AI \u554F\u7B54\u5DE5\u4F5C\u6D41" }, 404);
@@ -13774,8 +13880,8 @@ portalRouter.get(
});
const known = new Set(out.map((l) => l.name));
try {
const tenant2 = portalTenant(c.env);
const ownerParam = `owner_id=${encodeURIComponent(tenant2)}`;
const tenant2 = knowledgeOwner(c.env);
const ownerParam = ownerQuery(tenant2);
const [autoRes, cardRes, tripletRes] = await Promise.all([
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
@@ -13924,8 +14030,8 @@ portalRouter.get(
(c) => run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const ownerId = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
if (!res.ok) throw new KbdbError(`GET /execution-log/retention \u2192 ${res.status}`);
const data = await res.json();
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
@@ -13941,10 +14047,10 @@ portalRouter.put(
if (days !== null && days !== void 0 && (typeof days !== "number" || !Number.isFinite(days) || days <= 0)) {
return c.json({ error: "retention_days \u5FC5\u9808\u662F\u6B63\u6574\u6578\uFF0C\u6216 null\uFF08\u4EE3\u8868\u4E0D\u522A\u9664\uFF09" }, 400);
}
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, "/execution-log/retention", {
method: "PUT",
body: JSON.stringify({ owner_id: ownerId, retention_days: days === void 0 ? null : days })
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === void 0 ? null : days })
});
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention \u2192 ${res.status}`);
const data = await res.json();
@@ -13961,10 +14067,10 @@ portalRouter.delete(
const confirm = String(body?.confirm ?? "").trim();
if (!confirm) return c.json({ error: 'body \u9808\u5E36 { confirm: "<\u5EAB\u540D>" } \u624D\u57F7\u884C\uFF08\u79FB\u9664\u6703\u5F71\u97FF\u8CC7\u6599\u53EF\u641C\u6027\uFF09' }, 400);
if (confirm !== name) return c.json({ error: `confirm \u503C\u300C${confirm}\u300D\u8207\u5EAB\u540D\u300C${name}\u300D\u4E0D\u7B26` }, 400);
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, "/entries/deprecate-by-library", {
method: "PATCH",
body: JSON.stringify({ owner_id: ownerId, library: name })
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name })
});
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library \u2192 ${res.status}`);
const data = await res.json();
@@ -14020,8 +14126,8 @@ async function buildDiagnostics(env, tenant2) {
let embedding = { checked: false };
try {
const [statusRes, selftestRes] = await Promise.all([
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant2 }).toString()}`),
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant2 }).toString()}`)
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant2)}`),
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant2)}`)
]);
const statusBody = await statusRes.json().catch(() => null);
const selftestBody = await selftestRes.json().catch(() => null);
@@ -14043,7 +14149,7 @@ async function buildDiagnostics(env, tenant2) {
}
let library_count = 0;
let triplet_count = 0;
const ownerParam = new URLSearchParams({ owner_id: tenant2 }).toString();
const ownerParam = ownerQuery(tenant2);
try {
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
@@ -14067,7 +14173,7 @@ async function buildDiagnostics(env, tenant2) {
let library_scope_check = { ran: false };
if (library_count === 0 && triplet_count === 0) {
try {
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant2, limit: "1" }).toString()}`);
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant2), limit: "1" }).toString()}`);
const probeBody = await probeRes.json().catch(() => null);
const total = probeBody?.total ?? 0;
library_scope_check = {
@@ -14090,7 +14196,7 @@ portalRouter.get(
(c) => run(c, async () => {
const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim();
if (!apiKey) return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401);
const core = await buildDiagnostics(c.env, apiKey);
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
return c.json({
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
instance_url: new URL(c.req.url).origin,
@@ -14537,6 +14643,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" });
@@ -14631,7 +14751,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri
return { ...fresh, cache: "miss" };
}
consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const tenant2 = knowledgeOwner(c.env);
const now2 = Date.now();
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
@@ -14644,6 +14764,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal
@@ -14655,7 +14776,13 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)),
fetchJson(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson(`${graphUrl}/triplets/stats`),
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
fetchJson(
`${graphUrl}/triplets/stats`,
graphHeaders(c.env)
),
fetchTripletTotal(c.env, tenant2),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant2 }),
fetchEntryTotal(c.env, { entry_type: "wiki_card", owner_id: tenant2 }),
@@ -14763,36 +14890,37 @@ 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()
});
});
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const tenant2 = knowledgeOwner(c.env);
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()
@@ -14810,7 +14938,7 @@ consoleDashboardRouter.get("/console/settings-data", (c) => {
consoleDashboardRouter.get("/console/triage-data", async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header("authorization"));
if (!ok) return c.json({ error: "\u9700\u8981\u767B\u5165\uFF08console session\uFF09" }, 401);
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const tenant2 = knowledgeOwner(c.env);
const [todoEntries, inboxEntries] = await Promise.all([
fetchEntries(c.env, tenant2, "todo", 500),
fetchEntries(c.env, tenant2, "inbox", 200)
@@ -14825,7 +14953,7 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => {
const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : "";
if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400);
const action = body?.action === "restore" ? "restore" : "check";
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const tenant2 = knowledgeOwner(c.env);
const { base, headers } = kbdbBase(c.env);
const got = await fetchJson(
`${base}/entries/${encodeURIComponent(entryId)}`,
@@ -14853,7 +14981,7 @@ init_kbdb_proxy();
init_webhook_handlers();
var portalDataRouter = new Hono2();
async function getTenantWorkflowGraph(env, name) {
const raw2 = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, "text");
const raw2 = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, "text");
if (!raw2) return null;
try {
const rec = JSON.parse(raw2);
@@ -14944,9 +15072,30 @@ 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 === null ? censusQueryAllTenants() : ownerQuery(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, null) };
}
async function fuzzyFindNode(env, tenant2, searchTerm) {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant2)}`);
if (!res.ok) return null;
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.records)) return null;
@@ -14974,7 +15123,7 @@ portalDataRouter.get(
if (libraries.length === 0) {
return c.json({ success: true, entries: [], count: 0, mode: "keyword", note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" });
}
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
if (!libraries.includes("*")) params.set("library", libraries.join(","));
if (c.req.query("mode") === "semantic") {
params.set("mode", "semantic");
@@ -15010,7 +15159,7 @@ portalDataRouter.get(
const body = await res.json();
const entry = body.entry;
if (!entry) return notFound(c);
if ((entry.owner_id ?? "") !== portalTenant(c.env)) return notFound(c);
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
return c.json({ success: true, entry });
})
@@ -15025,7 +15174,7 @@ portalDataRouter.get(
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
}
const nodeName = normalizeCjkQuery(c.req.param("name"));
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors");
if (wfGraph) {
const depthRaw = c.req.query("depth") ?? "";
@@ -15045,8 +15194,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) {
@@ -15080,13 +15228,20 @@ portalDataRouter.get(
if (!await hasGraphAccess(c.env, libraries)) {
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 tenant2 = knowledgeOwner(c.env);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant2)}&limit=500`),
tripletCensus(c.env, tenant2)
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
const body = await res.json().catch(() => null);
const records = body && Array.isArray(body.records) ? body.records : [];
if (!body || !Array.isArray(body.records)) {
return c.json({ error: "\u4E09\u5143\u7D44\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = /* @__PURE__ */ new Set();
const edges = [];
@@ -15112,7 +15267,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(
@@ -15129,7 +15301,7 @@ portalDataRouter.get(
wfGraph,
{ question },
"rag_chat",
portalTenant(c.env),
knowledgeOwner(c.env),
c.executionCtx
);
if (!result.success) {
@@ -15205,7 +15377,7 @@ portalDataRouter.get(
if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) {
return c.json({ error: "\u9700\u8981 admin \u6B0A\u9650" }, 403);
}
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const prefix = `${tenant2}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = await Promise.all(
@@ -15227,7 +15399,7 @@ portalDataRouter.get(
let last_execution = null;
const execRes = await kbdbFetch(
c.env,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant2 }).toString()}`
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant2) }).toString()}`
);
const execBody = await execRes.json().catch(() => null);
if (execRes.ok && execBody?.success && execBody.execution) {
@@ -15239,12 +15411,207 @@ portalDataRouter.get(
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
})
);
function recordLibrary(values) {
const lib = values?.library;
return typeof lib === "string" && lib.trim() ? lib.trim() : null;
}
function canReadRecord(rec, tenant2, libraries) {
if (!isOwnedBy(rec.owner_id, tenant2)) return false;
const lib = recordLibrary(rec.values);
return lib === null || canReadLibrary(libraries, lib);
}
portalDataRouter.get(
"/portal/data/map",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: true,
empty_reason: "no_library_grant",
note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002"
});
}
const tenant2 = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant2)}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.libraries)) {
return c.json({ error: "\u85CF\u66F8\u5730\u5716\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 libraries \u6E05\u55AE" }, 502);
}
const allowed = body.libraries.filter(
(l) => typeof l?.library === "string" && canReadLibrary(libraries, l.library)
);
if (allowed.length > 0) {
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
}
if (body.libraries.length > 0) {
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: true,
empty_reason: "filtered_out",
note: "\u9019\u500B\u5E33\u865F\u76EE\u524D\u6C92\u6709\u4EFB\u4F55\u77E5\u8B58\u5EAB\u7684\u6AA2\u8996\u6B0A\u9650\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u958B\u901A\u3002"
});
}
const census = await tripletCensus(c.env, tenant2);
if (census.owned === null || census.owned === 0 && census.any === null) {
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: false,
empty_reason: "unreadable",
note: "\u8B80\u4E0D\u5230\u77E5\u8B58\u5EAB\u7684\u7D71\u8A08\uFF0C\u7121\u6CD5\u78BA\u8A8D\u5EAB\u88E1\u6709\u6C92\u6709\u6771\u897F\u2014\u2014\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u662F\u9019\u6B21\u8B80\u53D6\u5931\u6557\u3002\u8ACB\u7A0D\u5F8C\u91CD\u6574\u6216\u901A\u77E5\u7BA1\u7406\u54E1\u3002"
});
}
if (census.owned === 0 && (census.any ?? 0) > 0) {
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: false,
empty_reason: "scope_mismatch",
instance_triplet_count: census.any,
note: `\u8B80\u4E0D\u5230\u4F60\u9019\u500B\u5E33\u865F\u7BC4\u570D\u5167\u7684\u85CF\u66F8\u2014\u2014\u4F46\u9019\u53F0\u5BE6\u4F8B\u88E1\u6709 ${census.any} \u689D\u77E5\u8B58\u95DC\u806F\u3002\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u4E0D\u7528\u53BB\u91CD\u65B0\u4E0A\u50B3\uFF1B\u6BD4\u8F03\u50CF\u77E5\u8B58\u7684\u6B78\u5C6C\u547D\u540D\u7A7A\u9593\u5C0D\u4E0D\u4E0A\u3002\u8ACB\u901A\u77E5\u7BA1\u7406\u54E1\u8DD1\u4E00\u6B21 \`acr update\`\uFF08\u6703\u628A\u4F60\u5B89\u88DD\u6642\u7684\u547D\u540D\u7A7A\u9593\u540C\u6B65\u7D66\u96F2\u7AEF\uFF09\uFF0C\u6216\u6AA2\u67E5 ARCRUN_NAMESPACE \u8A2D\u5B9A\u3002`
});
}
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: true,
empty_reason: "confirmed_empty",
note: "\u77E5\u8B58\u5EAB\u9084\u6C92\u6709\u4EFB\u4F55\u5167\u5BB9\u2014\u2014\u4E0A\u50B3\u6587\u4EF6\u5F8C\u5C31\u6703\u51FA\u73FE\u5728\u9019\u88E1\u3002"
});
})
);
portalDataRouter.get(
"/portal/data/map/:library",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
const library = c.req.param("library");
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
})
);
portalDataRouter.get(
"/portal/data/templates",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const res = await kbdbFetch(c.env, "/templates");
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
})
);
portalDataRouter.post(
"/portal/data/templates",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null);
if (!body || typeof body.name !== "string" || !body.name.trim() || !Array.isArray(body.slots)) {
return c.json({ error: "name \u8207 slots[] \u5FC5\u586B" }, 400);
}
const res = await kbdbFetch(c.env, "/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: body.name,
slots: body.slots,
description: typeof body.description === "string" ? body.description : void 0,
created_by: knowledgeOwner(c.env)
})
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
})
);
portalDataRouter.get(
"/portal/data/records/by-template/:template",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
const tenant2 = knowledgeOwner(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?${ownerQuery(tenant2)}`
);
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.records)) {
return c.json({ error: "record \u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
}
const records = body.records.filter((r) => canReadRecord(r, tenant2, libraries));
return c.json({ success: true, records, count: records.length });
})
);
portalDataRouter.get(
"/portal/data/records/:recordId",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param("recordId"))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
const body = await res.json().catch(() => null);
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
})
);
portalDataRouter.post(
"/portal/data/records",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ error: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u7121\u6CD5\u5BEB\u5165" }, 403);
}
const body = await c.req.json().catch(() => null);
if (!body || typeof body.template !== "string" || !body.template.trim() || !body.values || typeof body.values !== "object") {
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
}
const values = body.values;
const targetLib = recordLibrary(values);
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
return c.json({ error: `\u7121\u300C${targetLib}\u300D\u5EAB\u7684\u6B0A\u9650\uFF0C\u4E0D\u80FD\u5BEB\u5165\u8A72\u5EAB` }, 403);
}
const res = await kbdbFetch(c.env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) })
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
})
);
portalDataRouter.get(
"/portal/data/diagnostics",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const core = await buildDiagnostics(c.env, tenant2);
return c.json({
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
+25 -16
View File
@@ -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 };
}
@@ -3274,7 +3281,7 @@ async function createRecord(db, input) {
});
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, tpl.id, slot, entry.id).run();
}
return { record_id: recordId, template_id: tpl.id, values: input.values };
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
}
async function updateRecord(db, recordId, values) {
const evRes = await db.prepare(
@@ -3305,7 +3312,7 @@ async function updateRecord(db, recordId, values) {
}
async function getRecord(db, recordId) {
const res = await db.prepare(
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id = ?`
).bind(recordId).all();
@@ -3313,7 +3320,8 @@ async function getRecord(db, recordId) {
if (rows.length === 0) return null;
const values = {};
for (const r of rows) values[r.slot] = r.content;
return { record_id: recordId, template_id: rows[0].template_id, values };
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
}
async function searchByTemplate(db, template, owner_id, limit = 100) {
const tpl = await getTemplate(db, template);
@@ -3332,17 +3340,18 @@ async function searchByTemplate(db, template, owner_id, limit = 100) {
const chunk = ids.slice(i, i + 90);
const placeholders = chunk.map(() => "?").join(",");
const evRes = await db.prepare(
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id IN (${placeholders})`
).bind(...chunk).all();
for (const r of evRes.results ?? []) {
let rec = byId.get(r.record_id);
if (!rec) {
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
byId.set(r.record_id, rec);
}
rec.values[r.slot] = r.content;
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
}
}
return ids.map((id) => byId.get(id)).filter((r) => !!r);
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T01:50:15.065Z",
"repo_head": "3eb8b31f2bfa029e15a8119e229082fbafb8d2b1",
"generated_at": "2026-08-12T16:25:19.210Z",
"repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 570290,
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
"js_bytes": 588587,
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -58,11 +58,11 @@
{
"name": "arcrun-kbdb",
"source_dir": "kbdb",
"source_commit": "3eb8b31f2bfa029e15a8119e229082fbafb8d2b1",
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 149234,
"content_sha256": "7bc666568f453a2fc5fc9339fc997c12d3927fb8a293439f2c80695be8da9767",
"js_bytes": 149797,
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -148,11 +148,11 @@
{
"name": "arcrun-mcp",
"source_dir": "mcp",
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1165388,
"content_sha256": "c5ff10f9b9d5a77217be343af12d2be3ee8f9792d3e1e091e48e5e6c8d24ca9d",
"js_bytes": 1179487,
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
+3 -2
View File
@@ -8,11 +8,12 @@
"main": "./dist/index.js",
"type": "module",
"scripts": {
"build": "npm run build:harness && npm run check:harness && tsc",
"build": "npm run build:harness && npm run check:harness && npm run check:rule && tsc",
"build:harness": "node scripts/build-harness-skill.mjs",
"check:harness": "node scripts/check-harness-generation.mjs",
"check:rule": "node ../scripts/sync-resource-rule.mjs --check",
"dev": "tsc --watch",
"test": "node --test \"tests/**/*.test.ts\"",
"test": "npm run check:rule && node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
"prepublishOnly": "npm run build && chmod +x dist/index.js"
},
"dependencies": {
+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'));
+44 -36
View File
@@ -14,11 +14,10 @@
import chalk from 'chalk';
import { loadConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
wranglerAvailable,
downloadAndDeploy,
REQUIRED_KV_NAMESPACES,
namespaceHasKnowledge,
type DeployContext,
} from '../lib/deploy.js';
@@ -44,43 +43,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 +64,44 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
kbdbEmbed: config.kbdb_embed !== false,
};
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
// Arcrun#108:把「你的知識住在哪個命名空間」同步給雲端——但**先驗再寫**。
//
// 病灶:你 push 工作流、小幫手上傳知識、MCP 查詢,用的都是 config 的 `api_key`
// 而 cypher 讀藏書地圖/搜尋/工作流時,過濾用的 owner_id 來自 worker 的環境變數
// repo toml 帶的官方預設 `CONSOLE_TENANT = "leo"`)。兩個來源對不上 ⇒ 你的東西全被濾掉。
//
// 為什麼不無條件寫:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。
// 無條件蓋成本機 api_key,會把一台**原本正常**的實例指向空的那一格
// ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」。所以查得到才寫,查不到就不碰。
if (config.api_key && config.cypher_executor_url) {
process.stdout.write(chalk.gray(' → 核對雲端要用哪個知識命名空間...'));
const hasKnowledge = await namespaceHasKnowledge(config.cypher_executor_url, config.api_key);
if (hasKnowledge === true) {
ctx.knowledgeNamespace = config.api_key;
console.log(chalk.green(' ✓'));
console.log(chalk.gray(` ARCRUN_NAMESPACE = ${config.api_key}(這個命名空間底下查得到你的知識庫)`));
} else if (hasKnowledge === false) {
console.log(chalk.yellow(' ⚠'));
console.log(chalk.gray(` ${config.api_key} 底下目前查不到任何知識庫 → 這趟不動雲端的命名空間設定`));
console.log(chalk.gray(' (若藏書地圖是空的,請把這行連同 acr update 的輸出一起回報)'));
} else {
console.log(chalk.yellow(' ⚠'));
console.log(chalk.gray(' 問不到實例(可能正在啟動或版本較舊)→ 這趟不動雲端的命名空間設定'));
}
}
// 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 失敗:✗ ...」)——必須印出來,
+59 -58
View File
@@ -3,6 +3,9 @@
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
*/
import { createCloudflareResourceApi } from './resource-rule/cf-resource-api.mjs';
import type { ResourceApi, ScriptBindings } from './resource-resolver.js';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
export interface CfKvClientOptions {
@@ -83,31 +86,26 @@ export class CfKvClient {
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
*/
export class CfAccountClient {
private accountBase: string;
private headers: Record<string, string>;
export class CfAccountClient implements ResourceApi {
/**
* `ResourceApi` 的七個方法**全部委派**給共用規則附的那支 client
* `shared/resource-rule/cf-resource-api.mjs`)。
*
* 🔴 為什麼不是在這裡自己實作一份:判斷一致還不夠,**看到的東西**也要一致。
* 兩條路各自寫一份 CF client,只要有一邊把 404 當錯誤、漏了 per_page、少認一種
* 欄位名,那一邊就會「看不到既有綁定」——而看不到既有綁定的下一步,依規則就是新建。
* Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。
*/
private readonly rule: ReturnType<typeof createCloudflareResourceApi>;
constructor(accountId: string, apiToken: string) {
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
this.headers = {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
this.rule = createCloudflareResourceApi({ accountId, apiToken });
}
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${this.accountBase}${path}`, {
...init,
headers: { ...this.headers, ...(init?.headers ?? {}) },
});
const data = await res.json().catch(() => null) as
| { success: boolean; result: T; errors?: Array<{ message: string }> }
| null;
if (!res.ok || !data?.success) {
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
throw new Error(`CF API ${path} 失敗:${msg}`);
}
return data.result;
const { ok, status, result, error } = await this.rule.cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result as T;
}
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
@@ -116,51 +114,54 @@ export class CfAccountClient {
await this.cf<{ id: string; name: string }>('');
}
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
async listKvNamespaces(): Promise<Map<string, string>> {
const result = await this.cf<Array<{ id: string; title: string }>>(
'/storage/kv/namespaces?per_page=100',
);
const map = new Map<string, string>();
for (const ns of result) map.set(ns.title, ns.id);
return map;
}
/** 建立 KV namespace(若同名已存在則回傳既有 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;
const result = await this.cf<{ id: string; title: string }>(
'/storage/kv/namespaces',
{ method: 'POST', body: JSON.stringify({ title }) },
);
return result.id;
}
/** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
return result.subdomain;
}
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified).
async listD1Databases(): Promise<Map<string, string>> {
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
const map = new Map<string, string>();
for (const db of result) map.set(db.name, db.uuid);
return map;
// ── 以下七支=`ResourceApi`,一律委派共用規則,**這個檔案不得自己實作** ────────────
// `shared/resource-rule/cf-resource-api.mjs`;委派而非複製的理由見本 class 開頭)
/** 讀一顆已部署 worker 現在綁著哪些資源——使用者那側的事實(Arcrun#97 的唯一真相源)。 */
getScriptBindings(script: string): Promise<ScriptBindings> {
return this.rule.getScriptBindings(script);
}
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;
const result = await this.cf<{ uuid: string; name: string }>(
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
/** 帳號上現有的 KV namespacetitle → id)。判斷「綁著的那顆還在不在」用。 */
listKvNamespaces(): Promise<Map<string, string>> {
return this.rule.listKvNamespaces();
}
/** 帳號上現有的 D1name → uuid)。 */
listD1Databases(): Promise<Map<string, string>> {
return this.rule.listD1Databases();
}
/** 帳號上現有的 Vectorize index 名單。 */
listVectorizeIndexes(): Promise<string[]> {
return this.rule.listVectorizeIndexes();
}
/**
* 無條件新建一顆 KV namespace。
*
* 🔴 Arcrun#97:**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路。
* 要不要建,一律先經過 planResources;那裡只有在「確定沒有任何已部署的 worker
* 綁過這個 binding」時才會排進 create。
*/
createKvNamespace(title: string): Promise<string> {
return this.rule.createKvNamespace(title);
}
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */
createD1Database(name: string): Promise<string> {
return this.rule.createD1Database(name);
}
/** 新建 KBDB embed 用的 Vectorize index。沒有 ensure 版本,理由同上(Arcrun#97)。 */
createVectorizeIndex(name: string): Promise<string> {
return this.rule.createVectorizeIndex(name);
}
}
+542 -70
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 成功後重跑仍全部
@@ -85,6 +98,119 @@ function giteaToken(): string | undefined {
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
}
/**
* 版本標籤的「發行頻道」來源(Arcrun#106)。
*
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
*/
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
export const CLI_MANAGED_VARS = [
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
'MULTI_TENANT', // 由 selfHosted 注入
'KBDB_BASE_URL', // 由 workerSubdomain 組
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp
'ARCRUN_BUNDLE_COMMIT',
] as const;
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
/** 這趟部署要烙上去的版本標籤。 */
export interface BundleStamp {
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
version: string;
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
commit?: string;
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
note: string;
}
/**
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
*
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
*
* 誠實邊界(mindset §7,這段要留著):
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
* `ARCRUN_BUNDLE_COMMIT``/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
*/
export async function resolveBundleStamp(
ref: string,
commit?: string,
fetchImpl: typeof fetch = fetch,
): Promise<BundleStamp> {
const short = commit ? commit.slice(0, 7) : ref;
const today = new Date().toISOString().slice(0, 10);
try {
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { release?: string } | null;
const release = String(body?.release ?? '').trim();
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver${release || '空'}`);
return {
version: release,
commit,
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''}`,
};
} catch (e) {
const version = `${today}+${short}`;
return {
version,
commit,
note:
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)}` +
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
};
}
}
/**
* 把 `ref`branch / tag / sha)解析成確切的 commit shaArcrun#106)。
*
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
*/
export async function resolveGiteaCommit(
ref: string,
fetchImpl: typeof fetch = fetch,
): Promise<string | undefined> {
const headers = buildDownloadHeaders();
const tryUrls = [
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
];
for (const url of tryUrls) {
try {
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
if (!res.ok) continue;
const body = (await res.json()) as
| { commit?: { id?: string } }
| Array<{ sha?: string }>
| null;
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
} catch {
/* 換下一種問法;全都問不到就回 undefined */
}
}
return undefined;
}
/**
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
* Gitea archive API`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
@@ -107,7 +233,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/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
@@ -151,8 +285,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 多租戶,行為不變)。
@@ -161,6 +298,44 @@ export interface DeployContext {
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
// base 維持 LIKE keywordfree-tier 友善)。
kbdbEmbed?: boolean;
/**
* Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`),
* 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。
*
* **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)——
* 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。
*/
knowledgeNamespace?: string;
}
/**
* 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」)
*
* 打的是實例自己的 `GET /kbdb/map?owner_id=<ns>`cypher 既有的純轉發端點,CLI 平常就在用
* 這條路 + `X-Arcrun-API-Key`)。回傳:
* true 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
* false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空)
* null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事
*
* 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
*/
export async function namespaceHasKnowledge(
cypherUrl: string,
namespace: string,
): Promise<boolean | null> {
if (!cypherUrl || !namespace) return null;
try {
const res = await fetch(
`${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
{ headers: { 'X-Arcrun-API-Key': namespace } },
);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
if (!body || !Array.isArray(body.libraries)) return null;
return body.libraries.length > 0;
} catch {
return null;
}
}
/**
@@ -190,6 +365,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,12 +399,17 @@ 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
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
const commit = await resolveGiteaCommit(ref);
let root: string;
try {
root = await downloadRepoTarball(ref);
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
} catch (e) {
return {
implemented: true,
@@ -262,28 +447,184 @@ 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 → 注入前的原文
const dirScript = new Map<string, string>(); // dir → worker script 名(#106var 沿用要逐顆對號)
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 不該存在;跳過而非亂猜
dirScript.set(dir, parsed.script);
for (const b of parsed.bindings) {
requirements.push({ ...b, worker: parsed.script });
}
}
let resolved = new Map<string, ResolvedResource>();
let liveVars = new Map<string, Record<string, string>>();
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——你現在的實例維持原樣。`,
};
}
liveVars = plan.liveVars;
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)}`);
}
}
// ── 2.8 varplain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
//
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
//
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
//
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
const extraVarsByDir = new Map<string, Record<string, string>>();
let stamp: BundleStamp | undefined;
if (dirScript.size > 0) {
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
if (needStamp) {
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
stamp = await resolveBundleStamp(ref, commit);
console.log(chalk.green(' ✓'));
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
}
const preservedTotal: string[] = [];
for (const [dir, script] of dirScript) {
const raw = tomlPreviews.get(dir);
if (!raw) continue;
const keep = preservedVars(liveVars.get(script), raw);
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
const vars: Record<string, string> = { ...keep };
if (stamp && script === VERSION_STAMP_WORKER) {
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
}
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
//
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
//
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
}
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
}
if (preservedTotal.length > 0) {
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
}
}
// 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 +637,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), extraVarsByDir.get(dir));
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
const hash = dirContentHash(dir, ctx.accountId);
if (manifest[label] === hash) {
@@ -434,35 +775,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 +783,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 exists)視為成功。async 生效(建立後才 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',
@@ -499,11 +814,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void>
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
*
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
async function downloadRepoTarball(ref: string): Promise<string> {
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const url = buildArchiveUrl(ref, bust);
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 1030 秒,視網速)...`));
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
const label = fromRef ? `${fromRef}${ref.slice(0, 7)}` : ref;
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 1030 秒,視網速)...`));
const res = await fetch(url, {
signal: AbortSignal.timeout(120_000),
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball#13 P2 假綠根因)。
@@ -596,21 +913,115 @@ 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,
extraVars: Record<string, string> = {},
): void {
if (!existsSync(tomlPath)) return;
let toml = readFileSync(tomlPath, 'utf8');
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
const toml = original ?? readFileSync(tomlPath, 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), '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`);
/**
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text varArcrun#106
*
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
* 沿用等於拿舊值蓋掉正解。
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
*
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"``WORKER_SUBDOMAIN` 之類是**官方 prod 的值**
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
*/
export function preservedVars(
live: Record<string, string> | undefined,
toml: string,
): Record<string, string> {
const out: Record<string, string> = {};
if (!live) return out;
const managed = new Set<string>(CLI_MANAGED_VARS);
for (const key of Object.keys(live).sort()) {
if (managed.has(key)) continue;
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
out[key] = live[key];
}
return out;
}
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
function readVar(toml: string, key: string): string | undefined {
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
return m?.[1];
}
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
function tomlEscape(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/**
* 把一組 var 寫進 toml 的 `[vars]`Arcrun#106)。純函式。
*
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
* 1. 已有未註解的同名行 → 換值
* 2. 只有被註解掉的同名行 → 取消註解並填值
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
*/
export function applyVars(toml: string, vars: Record<string, string>): string {
let out = toml;
for (const key of Object.keys(vars).sort()) {
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
const value = tomlEscape(vars[key]);
// 🔴 一律用「函式版 replace」:值裡若有 `$&``$1` 這種字元,字串版 replace 會把它當成
// 反向參照展開,寫出來的就不是使用者那個值了。
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
out = out.replace(
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
(_m, head: string, tail: string) => `${head}${value}${tail}`,
);
continue;
}
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
out = out.replace(
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
);
continue;
}
if (/^\s*\[vars\]\s*$/m.test(out)) {
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
continue;
}
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
}
return out;
}
/**
* 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
*
* 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
*
* `extraVars`Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
*/
export function renderWranglerToml(
toml: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
extraVars: Record<string, string> = {},
): string {
// cypher-executor 的 WORKER_SUBDOMAINvars)換成用戶帳號 subdomain
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
toml = toml.replace(
@@ -629,14 +1040,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 +1071,76 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
}
writeFileSync(tomlPath, toml, 'utf8');
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
toml = applyVars(toml, extraVars);
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
// 空 map = 預覽模式,這步什麼也不做。
return applyResolvedBindings(toml, resolved);
}
/**
* 把解析好的資源 id 寫進對應的 binding 區塊。
*
* 逐個 `[[table]]` 區塊掃:先在區塊內找 `binding = "X"`,再改同一區塊裡的值欄位
* KV→`id`、D1→`database_id`、Vectorize→`index_name`)。
* 🔴 刻意**不用**「全檔第一個 database_id」這種寫法:cypher`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 });
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* resource-resolver.ts — **這裡沒有邏輯**,只是把共用規則接到 CLI 的既有 import 路徑上。
*
* 「這個實例該用哪些資源」的規則住在 `shared/resource-rule/`repo 根目錄),
* 那是**唯一一份人手維護的實作**`./resource-rule/` 是該目錄的逐位元組鏡射
* `scripts/sync-resource-rule.mjs` 產生,`npm run build` / `npm test` 會跑 `--check` 擋漂移)。
* 之所以要有這份鏡射:`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案。
*
* 為什麼規則不在 CLIleo 2026-08-12):
* 「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
* ——`acr` 有這條規則、安裝器沒有,結果就是 Arcrun#97:
* 安裝器照名字找、找不到就建一顆空的綁上去,使用者的工作流與登入狀態整片消失。
* 規則搬到共用層之後,安裝器直接 import 同一份原稿,**不再有第二種答案**。
*
* 🔴 不要把任何判斷寫回這個檔案。要改規則 → 改 `shared/resource-rule/rule.mjs`。
*/
export {
planResources,
applyResourcePlan,
parseWranglerRequirements,
normalizeLiveBindings,
normalizeLiveVars,
bindingKey,
ResourcePlanBlocked,
KIND_LABEL,
TABLE_KIND,
} from './resource-rule/rule.mjs';
export type {
ResourceKind,
LiveBinding,
ScriptBindings,
ResourceApi,
BindingRequirement,
PlannedAdopt,
PlannedCreate,
ResourcePlan,
ResolvedResource,
WranglerRequirements,
RawWorkerBinding,
} from './resource-rule/rule.mjs';
@@ -0,0 +1,202 @@
// @ts-check
/**
* cf-resource-api.mjs — 規則的**眼睛與手**:對 Cloudflare 帳號的那七個動作,也只有一份。
*
* `rule.mjs` 是純判斷,IO 由呼叫端注入(`ResourceApi`)。本檔就是那個注入物的正貨:
* 用 CF REST API 實作 `ResourceApi`,零依賴、只用 global `fetch`
* ⇒ Node 18+ 與 Cloudflare Workers runtime 都能直接跑。
*
* 【為什麼連這層也要共用】
* 判斷一致還不夠——**看到的東西**也要一致。
* 「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的;
* 如果兩條路各自寫一份 client,隨便一個差異(打錯端點、把 404 當錯誤、漏了 per_page、
* 少認一種欄位名)都會讓其中一條路「看不到既有綁定」——而看不到既有綁定的下一步,
* 依規則就是**新建**。Arcrun#97 的災情不需要規則寫錯,只要眼睛不一樣就會重演。
*
* 這裡**故意只有 `ResourceApi` 那七個方法**。verifyAccess / 查 subdomain / KV 讀寫
* 這些跟「該用哪些資源」無關的帳號操作留在各自的呼叫端,不往共用層堆。
*
* 🔴 除了同目錄的 `./rule.mjs`,這支不准 import 任何東西——共用層的價值在於
* 「整個目錄複製到哪個 runtime 都能直接跑」,多一個外部依賴就少一條路吃得到。
*/
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
*/
/**
* @typedef {object} CfResourceApiOptions
* @property {string} accountId
* @property {string} apiToken
* @property {typeof globalThis.fetch} [fetch]
* 注入用(離線測試餵假帳號、或宿主要用自己的 fetch)。預設 global fetch。
*/
/**
* 建一個打真實 Cloudflare 的 `ResourceApi`。
*
* @param {CfResourceApiOptions} options
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
*/
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
const doFetch = fetchImpl ?? globalThis.fetch;
if (typeof doFetch !== 'function') {
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
}
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
const headers = {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
/**
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error:
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
}
/**
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<any>}
*/
async function cf(path, init) {
const { ok, status, result, error } = await cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result;
}
return {
cfRaw,
/**
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
*
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
* 把查不到當成不存在,就是 #97 的根因。
*
* @param {string} script
* @returns {Promise<ScriptBindings>}
*/
async getScriptBindings(script) {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await cfRaw(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
/** @type {RawWorkerBinding[]} */
const raw = res.result?.bindings ?? [];
return {
deployed: true,
bindings: normalizeLiveBindings(raw),
vars: normalizeLiveVars(raw),
};
},
/** @returns {Promise<Map<string, string>>} title → id */
async listKvNamespaces() {
/** @type {Array<{id: string, title: string}>} */
const result = await cf('/storage/kv/namespaces?per_page=100');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
},
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
},
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
},
/**
* 無條件新建一顆 KV namespace。
*
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
* (安裝器取的名字跟 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
* 要不要建一律先過 `planResources`。
*
* @param {string} title
* @returns {Promise<string>}
*/
async createKvNamespace(title) {
const result = await cf('/storage/kv/namespaces', {
method: 'POST',
body: JSON.stringify({ title }),
});
return result.id;
},
/**
* 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。
* @param {string} name
* @returns {Promise<string>}
*/
async createD1Database(name) {
const result = await cf('/d1/database', {
method: 'POST',
body: JSON.stringify({ name }),
});
return result.uuid;
},
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 維 / cosine**)。
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。
* 沒有 ensure 版本:「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
*
* @param {string} name
* @returns {Promise<string>}
*/
async createVectorizeIndex(name) {
const res = await cfRaw('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
},
};
}
@@ -0,0 +1,100 @@
// @ts-check
/**
* installer-entry.mjs — 安裝器那條路的**唯一入口**。
*
* 安裝器(arcrun-rag `installer/oauth-prototype/worker.js`)不必、也不准自己判斷
* 「該建哪些資源」——它只要呼叫這一支,拿回「每個 binding 該用哪顆資源」。
*
* ```js
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
*
* const r = await resolveInstanceResources({
* accountId, apiToken,
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
* mode: isUpdate ? 'update' : 'init',
* });
* if (r.blocked) {
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
* return showAndStop(r.blockers);
* }
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
* ```
*
* 為什麼安裝器不需要副本:安裝器本來就會下載本 repo 的 archive 當部署來源
* (見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
* `shared/resource-rule/` 就在那份 archive 裡,直接 import 即可——
* **不必再編一次、不必貼一份、也就不會有第二種答案。**
*/
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
/**
* @typedef {object} ResolveOptions
* @property {string} accountId
* @property {string} apiToken
* @property {string[]} wranglerTomls 各 worker 的 wrangler.toml **內容**(不是路徑)。
* @property {'update' | 'init'} mode 這台照定義裝過了沒。
* @property {typeof globalThis.fetch} [fetch] 注入用(測試/宿主自帶 fetch)。
*/
/**
* @typedef {object} ResolveResult
* @property {boolean} blocked true = 什麼都沒建、什麼都不該部署。
* @property {string[]} blockers blocked 時的原因原文(要原樣轉給使用者)。
* @property {Record<string, string>} bindings `${kind}:${binding}` → 資源 idindex 名。
* @property {Record<string, 'adopted'|'created'>} origin 同上 key → 這顆是沿用還是新建。
* @property {Record<string, Record<string, string>>} liveVars script → 現有 plain_text var#106)。
*/
/**
* 決定這台實例每個 binding 該用哪顆資源;照規則沿用既有、只在確定沒人綁過時才新建。
*
* @param {ResolveOptions} options
* @returns {Promise<ResolveResult>}
*/
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
/** @type {import('./rule.mjs').BindingRequirement[]} */
const requirements = [];
for (const toml of wranglerTomls) {
const parsed = parseWranglerRequirements(toml);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
/** @param {string[]} blockers @returns {ResolveResult} */
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
if (requirements.length === 0) {
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
}
let plan;
try {
plan = await planResources(api, requirements, mode);
} catch (e) {
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
}
if (plan.blockers.length > 0) return stop(plan.blockers);
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
let resolved;
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
}
/** @type {Record<string, string>} */
const bindings = {};
/** @type {Record<string, 'adopted'|'created'>} */
const origin = {};
for (const [key, r] of resolved) {
bindings[key] = r.value;
origin[key] = r.origin;
}
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
}
+570
View File
@@ -0,0 +1,570 @@
// @ts-check
/**
* rule.mjs — 「這個實例該用哪些資源」的**唯一一份**規則。
*
* ─────────────────────────────────────────────────────────────────────────────
* 這份檔案為什麼在這裡(`shared/`),不在 `cli/`
* ─────────────────────────────────────────────────────────────────────────────
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
*
* `.claude/rules/07-thin-shell.md` 的判準口訣:
* 「這段邏輯換一個介面要不要重寫?」要重寫 → 它是能力,該在共用層。
*
* 「該沿用哪幾顆資源」換到安裝器就得重寫一次 ⇒ 它是**能力**,不是薄殼的事。
* 而它原本住在 `cli/src/lib/resource-resolver.ts` ⇒ 那本身就是違規,
* 後果也真的發生了:`acr` 那條有這條規則、安裝器那條沒有,於是安裝器照名字找、
* 找不到就建新的空的 ⇒ Arcrun#97「我按了更新,工作流和登入全不見了」。
*
* ── 為什麼不是 cypher-executor 的 API 端點(薄殼原則的標準答案)────────────
* **自舉**:這條規則要在「決定怎麼裝/怎麼更新」的當下就用得到,而那個當下
* cypher 可能還不存在(安裝器的工作正是把它生出來),或正要被覆蓋。
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**——
* 把它送去一顆平台託管的 worker 換一個答案,等於①讓「能不能安裝」綁在平台是否活著,
* ②把使用者的帳號拓撲交給第三方。兩件都不該為了形式上的漂亮而做。
*
* 薄殼原則要求的是「能力只實作一次」,不是「能力一定要是 HTTP」。
* 這條規則是**純函式**(唯一的 IO 由呼叫端注入 `ResourceApi`),
* 所以它用不著變成服務——一份零依賴的 ESM 就能讓每條路吃到同一份判斷。
*
* ── 怎麼讓兩條路吃到「同一份」而不是各留一份 ───────────────────────────────
* 本檔是**唯一被人手維護的實作**,零依賴、不吃任何 node 內建、Workers runtime 可直接跑。
* · `acr``cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**,
* 由 `scripts/sync-resource-rule.mjs` 產生(CLI 要能單獨 npm publish
* 套件目錄外的檔案打不進 tarball,故必須有這一份)。
* `npm run build` / `npm test` 都會跑 `--check`,內容一漂就紅。
* ——同 `cli/harness/`(產生物+世代閘)的既有慣例。
* · 安裝器 / 任何 Worker:安裝器本來就會下載本 repo 的 archive(部署來源,
* 見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可,**不需要再編一次、也不留副本**。
* 用法見同目錄 README.md。
*
* ─────────────────────────────────────────────────────────────────────────────
* 規則本身(leo 的兩句話)
* ─────────────────────────────────────────────────────────────────────────────
* 「如果你沒有裝,就是新的;如果你已經有,原來叫什麼名字就繼續用下去。」
*
* 判準是「**這顆 worker 現在綁著誰**」,不是「有沒有叫這個名字的資源」:
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
*
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
*
* 🔴 這份檔案沒有 import、也不准有。任何依賴都會讓某一條路吃不到它。
*/
/**
* 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
*/
/**
* 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。
* @typedef {object} LiveBinding
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
*/
/**
* @typedef {object} ScriptBindings
* @property {boolean} deployed
* false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。
* @property {LiveBinding[]} bindings
* @property {Record<string, string>} [vars]
* 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。
*
* 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize),
* plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)——
* 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。
* **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。
*/
/**
* 規則需要的 CF 能力(收窄成介面,方便離線測試餵假帳號,也讓安裝器用自己的 fetch 實作)。
* @typedef {object} ResourceApi
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
* @property {() => Promise<Map<string, string>>} listKvNamespaces title → id
* @property {() => Promise<Map<string, string>>} listD1Databases name → uuid
* @property {() => Promise<string[]>} listVectorizeIndexes
* @property {(title: string) => Promise<string>} createKvNamespace
* @property {(name: string) => Promise<string>} createD1Database
* @property {(name: string) => Promise<string>} createVectorizeIndex
*/
/**
* 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。
* @typedef {object} BindingRequirement
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
* @property {string} createName
*/
/**
* @typedef {object} PlannedAdopt
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
*/
/**
* @typedef {object} PlannedCreate
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} createName
* @property {string[]} wantedBy
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。
*/
/**
* @typedef {object} ResourcePlan
* @property {PlannedAdopt[]} adopt
* @property {PlannedCreate[]} create
* @property {string[]} blockers 非空 = 整趟停手。applyResourcePlan 會拒絕執行。
* @property {Map<string, Record<string, string>>} liveVars
* 每顆**已部署** worker 現在掛著的 plain_text varscript → 名/值)。未部署的不在裡面。
*
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
*/
/**
* @typedef {object} ResolvedResource
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* @property {string} [from]
*/
/**
* @typedef {object} WranglerRequirements
* @property {string} script worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
*/
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
/** @param {string[]} blockers */
constructor(blockers) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
/** @type {string[]} */
this.blockers = blockers;
}
}
/**
* @param {ResourceKind} kind
* @param {string} binding
* @returns {string}
*/
export function bindingKey(kind, binding) {
return `${kind}:${binding}`;
}
/** @type {Record<ResourceKind, string>} */
export const KIND_LABEL = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
/**
* @param {unknown} e
* @returns {string}
*/
function msg(e) {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
*
* @param {ResourceApi} api
* @param {readonly BindingRequirement[]} requirements
* @param {'update' | 'init'} mode
* 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
* @returns {Promise<ResourcePlan>}
*/
export async function planResources(api, requirements, mode) {
/** @type {string[]} */
const blockers = [];
/** @type {PlannedAdopt[]} */
const adopt = [];
/** @type {PlannedCreate[]} */
const create = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
/** @type {Map<string, LiveBinding[]>} */
const live = new Map();
/** @type {Map<string, Record<string, string>>} */
const liveVars = new Map();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) {
live.set(script, res.bindings);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
/** @type {Map<string, BindingRequirement[]>} */
const byKey = new Map();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
/** @type {Map<ResourceKind, Set<string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = async (kind) => {
const hit = existingCache.get(kind);
if (hit) return hit;
/** @type {Set<string>} */
let set;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
/** @type {Array<{value: string, script: string}>} */
const found = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Set<string>} */
let existing;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
}
/**
* 收斂「不同 binding 其實是同一顆資源」的情況。
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`),不是使用者那側的資源名——
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
*
* 沒有這一步會出兩種錯:
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
*
* @param {PlannedAdopt[]} adopt
* @param {PlannedCreate[]} create
* @param {Map<string, BindingRequirement[]>} byKey
* @returns {PlannedCreate[]}
*/
function shareSameResource(adopt, create, byKey) {
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
const declaredName = (kind, binding) =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
/** @type {PlannedCreate[]} */
const out = [];
/** @type {Map<string, PlannedCreate>} */
const groups = new Map();
for (const c of create) {
const groupKey = `${c.kind} ${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* 照 plan 動手:沿用的原樣帶出來,該建的才建。
* 有任何 blocker 直接丟 ResourcePlanBlocked**一顆都不建**。
*
* @param {ResourceApi} api
* @param {ResourcePlan} plan
* @returns {Promise<Map<string, ResolvedResource>>}
*/
export async function applyResourcePlan(api, plan) {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
/** @type {Map<string, ResolvedResource>} */
const out = new Map();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
/** @type {string[]} */
const madeSoFar = [];
for (const c of plan.create) {
/** @type {string} */
let value;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
/**
* wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。
* @type {Record<string, ResourceKind>}
*/
export const TABLE_KIND = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
*
* 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作,
* 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
* kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。
*
* 也是「零依賴」的一部分:不引 TOML parser ⇒ 安裝器 import 這支不必多裝任何東西。
*
* @param {string} toml
* @returns {WranglerRequirements}
*/
export function parseWranglerRequirements(toml) {
let script = '';
let seenTable = false;
/** @type {WranglerRequirements['bindings']} */
const bindings = [];
/** @type {ResourceKind | null} */
let kind = null;
let binding = '';
let createName = '';
const flush = () => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
// ─────────────────────────────────────────────────────────────────────────────
/**
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
* (同一種資源在不同 API 版本欄位名不一,故全都收)。
*
* @typedef {object} RawWorkerBinding
* @property {string} [type]
* @property {string} [name]
* @property {string} [namespace_id]
* @property {string} [id]
* @property {string} [database_id]
* @property {string} [index_name]
* @property {string} [text] `plain_text` 綁定的值(#106secret_text 不會回值,本來就讀不到,也不該讀)。
*/
/**
* 把 CF 的 binding 陣列收斂成規則認得的三種資源。不認得的型別直接略過。
*
* 🔴 這支**刻意放在規則裡**,不留在各自的 CF client
* 「什麼才算『這顆 worker 綁著某顆資源』」是規則的一部分。
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來(例如一邊認 `namespace_id`、
* 另一邊只認 `id`,於是一邊看得到綁定、另一邊看不到 → 後者又去新建了)。
*
* @param {RawWorkerBinding[]} raw
* @returns {LiveBinding[]}
*/
export function normalizeLiveBindings(raw) {
/** @type {LiveBinding[]} */
const out = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
/**
* 抽出已部署 worker 上的 `plain_text` var#106)。
*
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被搬來搬去;
* wrangler deploy 不會動 secret,它們自己會留著)。
*
* @param {RawWorkerBinding[]} raw
* @returns {Record<string, string>}
*/
export function normalizeLiveVars(raw) {
/** @type {Record<string, string>} */
const out = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Arcrun#108 迴歸守衛 —— 「雲端讀資料用的命名空間,要跟你寫資料用的那個一致」
*
* 2026-08-12 實害:leo 的藏書地圖回 0 個庫,實際有 1854 條三元組。
* 根因:你 push 工作流、小幫手上傳知識、MCP 查詢都用 `~/.arcrun/config.yaml` 的 `api_key`
* leo `bfezv28v`),但 cypher 讀取時的 owner_id 來自 worker 環境變數
* ——而那個變數是 repo toml 帶的**官方 prod 值** `CONSOLE_TENANT = "leo"`。
* 寫在 A、讀在 B,全被過濾掉。
*
* 這份測試守兩件相反的事(本次的核心判斷):
* · 驗得到知識 → **寫** `ARCRUN_NAMESPACE`,讓讀寫兩端對齊
* · 驗不到 / 問不到 → **一個字都不動**,既有值原封保留
* (無條件覆蓋會把一台「知識本來就寫在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
* ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟)
*
* 全部離線跑:真的 wrangler.toml + 真的 render 程式碼,fetch 用假的,不碰任何實例。
*/
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 {
renderWranglerToml,
preservedVars,
namespaceHasKnowledge,
VERSION_STAMP_WORKER,
type DeployContext,
} from '../src/lib/deploy.ts';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
const LEO_NS = 'bfezv28v';
const CTX: DeployContext = {
accountId: 'acc-user-123',
apiToken: 'token',
workerSubdomain: 'user-sub',
selfHosted: true,
kbdbEmbed: true,
};
function readVars(toml: string): Record<string, string> {
const out: Record<string, string> = {};
let inVars = false;
for (const line of toml.split('\n')) {
if (/^\s*\[vars\]/.test(line)) { inVars = true; continue; }
if (/^\s*\[/.test(line)) { inVars = false; continue; }
if (!inVars) continue;
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (m) out[m[1]] = m[2];
}
return out;
}
/** 模擬 downloadAndDeploy 那段:沿用既有 var,再疊上這趟 CLI 算出來的值。 */
function deployedVars(ctx: DeployContext, live: Record<string, string>): Record<string, string> {
const keep = preservedVars(live, CYPHER_TOML);
const extra: Record<string, string> = { ...keep };
if (ctx.knowledgeNamespace) extra.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
return readVars(renderWranglerToml(CYPHER_TOML, ctx, new Map(), extra));
}
// ── ① 驗得到知識 → 寫進去 ────────────────────────────────────────────────────
test('#108 給了 knowledgeNamespace → cypher [vars] 出現 ARCRUN_NAMESPACE(讀寫兩端終於同一個值)', () => {
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, {});
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
// CONSOLE_TENANT 一個字都不能動——它同時是帳號子 namespace 的組成,改了舊實例登不進去
assert.equal(vars.CONSOLE_TENANT, 'leo');
});
test('#108 蓋得過 worker 上的舊值(改名/搬遷後 acr update 要能修正,不是永遠沿用第一次那個)', () => {
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, { ARCRUN_NAMESPACE: 'stale-ns' });
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
});
// ── ② 驗不到 → 什麼都不動(比 bug 更糟的是把人家原本正常的實例弄空)──────────────
test('#108 沒給 knowledgeNamespace → 既有的 ARCRUN_NAMESPACE 原封保留(不因為這趟驗不到就洗掉)', () => {
const vars = deployedVars(CTX, { ARCRUN_NAMESPACE: 'user-existing-ns' });
assert.equal(vars.ARCRUN_NAMESPACE, 'user-existing-ns');
});
test('#108 沒給、worker 上也沒有 → 不注入(回退 CONSOLE_TENANT,舊實例行為一字不變)', () => {
const vars = deployedVars(CTX, {});
assert.equal(vars.ARCRUN_NAMESPACE, undefined);
assert.equal(vars.CONSOLE_TENANT, 'leo');
});
test('#108 ARCRUN_NAMESPACE 不在 CLI_MANAGED_VARS:它「不是每趟重算」而是「驗到才寫」,' +
'列進去會讓驗不到的那趟把既有值一起洗掉', async () => {
const { CLI_MANAGED_VARS } = await import('../src/lib/deploy.ts');
assert.equal((CLI_MANAGED_VARS as readonly string[]).includes('ARCRUN_NAMESPACE'), false);
});
test('#108 只烙在 cypher 這顆 worker(其他 worker 不需要知識命名空間)', () => {
assert.equal(VERSION_STAMP_WORKER, 'arcrun-cypher-executor');
});
// ── ③ 「先驗再寫」那支探針的三態 ───────────────────────────────────────────────
test('namespaceHasKnowledge:這個命名空間底下查得到庫 → true(可以安全寫進去)', async () => {
const calls: string[] = [];
const orig = globalThis.fetch;
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
calls.push(String(url));
assert.equal((init?.headers as Record<string, string>)['X-Arcrun-API-Key'], LEO_NS);
return new Response(JSON.stringify({ success: true, libraries: [{ library: 'kb' }], count: 1 }), { status: 200 });
}) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), true);
assert.equal(calls[0], `https://cypher.example.dev/kbdb/map?owner_id=${LEO_NS}`);
} finally {
globalThis.fetch = orig;
}
});
test('namespaceHasKnowledge:查得到但是空的 → false(知識可能在別的命名空間,不准蓋)', async () => {
const orig = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: true, libraries: [], count: 0 }), { status: 200 })) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), false);
} finally {
globalThis.fetch = orig;
}
});
test('namespaceHasKnowledge:問不到(實例沒起來/舊版沒這條路/網路斷)→ null,不宣稱任何事', async () => {
const orig = globalThis.fetch;
globalThis.fetch = (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
} finally {
globalThis.fetch = orig;
}
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
} finally {
globalThis.fetch = orig;
}
});
test('namespaceHasKnowledge:回應形狀不對 → null(讀不出來 ≠ 沒有資料,禁假綠)', async () => {
const orig = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: true }), { status: 200 })) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
} finally {
globalThis.fetch = orig;
}
});
test('namespaceHasKnowledge:缺 url 或缺 namespace → null(不打任何請求)', async () => {
const orig = globalThis.fetch;
globalThis.fetch = (async () => { throw new Error('不該被呼叫'); }) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('', LEO_NS), null);
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', ''), null);
} finally {
globalThis.fetch = orig;
}
});
+4
View File
@@ -0,0 +1,4 @@
/** `node --import ./tests/register-ts-hooks.mjs --test ...` 的進入點:註冊 ts-hooks.mjs。 */
import { register } from 'node:module';
register('./ts-hooks.mjs', import.meta.url);
+534
View File
@@ -0,0 +1,534 @@
/**
* 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: [], vars: {} });
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' },
]);
// #106plain_text 也要收下來(service 這種不認得的仍略過)。
assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
} finally {
globalThis.fetch = orig;
}
});
+111
View File
@@ -0,0 +1,111 @@
/**
* 「只有一份」的機械證明。
*
* leo 的驗收條件:「改完之後,`grep` 得出『決定用哪些資源』的邏輯**只有一個地方**。
* 兩個以上呼叫端各自有一份 ⇒ 不算完成。」
*
* 這份測試就是把那個 grep 寫成會紅的東西:
* ① 規則的每一支函式,全 repo 只有 `shared/resource-rule/` 有實作
* `cli/src/lib/resource-rule/` 是它的逐位元組鏡射,由 sync 腳本產生並看守,不算第二份)
* ② 鏡射與原稿逐位元組相同(sync --check 的同一道閘,這裡再測一次讓 `npm test` 也擋得住)
* ③ 共用層不准長出依賴——有依賴就會有某條路吃不到它
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
const MIRROR_DIR = join(REPO, 'cli/src/lib/resource-rule');
/** 規則的實作特徵:這些**宣告**只准出現在原稿目錄(與它的鏡射)裡。 */
const RULE_DECLARATIONS = [
'function planResources',
'function applyResourcePlan',
'function shareSameResource',
'function parseWranglerRequirements',
'function normalizeLiveBindings',
'function normalizeLiveVars',
'function createCloudflareResourceApi',
];
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', '.wrangler', '.worker-builds', '.component-builds',
'.github-public', 'coverage',
]);
/** 只掃「人會寫程式的地方」;產生物與二進位不掃。 */
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const abs = join(dir, name);
const st = statSync(abs);
if (st.isDirectory()) walk(abs, out);
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) out.push(abs);
}
return out;
}
const sha256 = (b: Buffer): string => createHash('sha256').update(b).digest('hex');
test('① 規則的實作全 repo 只有一份(原稿目錄 + 它的鏡射,沒有第三處)', () => {
const files = walk(REPO);
const offenders: string[] = [];
for (const abs of files) {
const rel = relative(REPO, abs);
// 原稿與鏡射本來就該有;測試檔在講規則、不是實作規則
if (rel.startsWith('shared/resource-rule/')) continue;
if (rel.startsWith('cli/src/lib/resource-rule/')) continue;
if (rel.startsWith('cli/tests/')) continue;
if (rel === 'scripts/sync-resource-rule.mjs') continue;
const src = readFileSync(abs, 'utf8');
for (const decl of RULE_DECLARATIONS) {
if (src.includes(decl)) offenders.push(`${rel}${decl}`);
}
}
assert.deepEqual(offenders, [],
'「決定用哪些資源」的實作出現在共用層之外——這正是本票要消滅的東西:\n' +
offenders.map((o) => `${o}`).join('\n') +
'\n要改規則就改 shared/resource-rule/,呼叫端只准 import。');
console.log(`\n ① 掃過 ${files.length} 個原始碼檔,${RULE_DECLARATIONS.length} 支規則函式的實作` +
' 全部只出現在 shared/resource-rule/(+機械鏡射)');
});
test('② CLI 帶的那份與原稿逐位元組相同(漂移=第二份實作偷偷長出來)', () => {
const files = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
assert.ok(files.length > 0, 'shared/resource-rule/ 裡沒有任何 .mjs 原稿');
const mirrored = readdirSync(MIRROR_DIR).filter((f) => f.endsWith('.mjs')).sort();
assert.deepEqual(mirrored, files, '鏡射目錄的檔案清單與原稿不一致');
for (const f of files) {
const a = sha256(readFileSync(join(SOURCE_DIR, f)));
const b = sha256(readFileSync(join(MIRROR_DIR, f)));
assert.equal(b, a, `cli/src/lib/resource-rule/${f} 與原稿不一致——不要手改產生物,` +
'改 shared/resource-rule/ 後跑 node scripts/sync-resource-rule.mjs');
console.log(`${f.padEnd(24)} sha256 ${a.slice(0, 16)} 原稿 = 鏡射`);
}
});
test('③ 共用層零外部依賴(只准 import 同目錄的兄弟檔)', () => {
for (const f of readdirSync(SOURCE_DIR).filter((x) => x.endsWith('.mjs'))) {
const src = readFileSync(join(SOURCE_DIR, f), 'utf8');
const imports = [...src.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1]);
for (const spec of imports) {
assert.ok(spec.startsWith('./'),
`shared/resource-rule/${f} import 了 "${spec}"——共用層一旦有外部依賴,` +
'就會有某條路(Workers runtime/安裝器)吃不到它。');
}
assert.doesNotMatch(src, /require\(|from\s+['"]node:/,
`shared/resource-rule/${f} 用到 node 專屬 API——Cloudflare Workers 上跑不起來。`);
console.log(`${f.padEnd(24)} import: ${imports.length ? imports.join(', ') : '(無)'}`);
}
});
+21
View File
@@ -0,0 +1,21 @@
/**
* 測試用 resolve hook:把 `./x.js` 這種 import 指回同名的 `./x.ts`Arcrun#106 附帶修復)。
*
* 為什麼需要:`src/` 內部的 import 一律寫成 `.js`NodeNext 慣例,編譯後才會有那個檔),
* 但測試是**直接載入 `src/**\/*.ts`**、不經過 tsc`outDir: dist`,所以 `src/` 底下永遠不會有 .js)。
* Node 的型別剝離不會自己把 `.js` 對回 `.ts` ⇒ 三份測試在 node 22 上**一支都跑不起來**
* `ERR_MODULE_NOT_FOUND: .../src/lib/cf-api.js`)——包含 #97 那份「使用者的東西還在不在」的迴歸守衛。
* 跑不起來的守衛等於沒有守衛,所以這裡補上。
*
* 只在「預設解析失敗」時才動作,且只換副檔名 → 對本來就解析得到的環境(新版 node / 已編譯)零影響。
*/
export async function resolve(specifier, context, next) {
try {
return await next(specifier, context);
} catch (err) {
if (typeof specifier === 'string' && specifier.endsWith('.js')) {
return next(specifier.slice(0, -3) + '.ts', context);
}
throw err;
}
}
+159
View File
@@ -0,0 +1,159 @@
/**
* 兩條路必須得出同一個答案 —— 本票的核心驗收。
*
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
*
* 後果已經真的發生過:`acr` 那條有 Arcrun#97 的修法、安裝器那條沒有,
* 於是安裝器照名字找、找不到就建一顆空的綁上去 ⇒ 使用者的工作流與登入狀態整片消失。
*
* 這份測試把**同一個帳號狀態**餵給兩條路:
* A. `acr` 那條:`CfAccountClient` + `resource-resolver`CLI 真正跑的 import 鏈)
* B. 安裝器那條:只 import `shared/resource-rule/`(安裝器唯一該碰的入口)
* 然後比對它們選出的 **resource id 必須相同**。
*
* 假的是 `fetch`,不是 `ResourceApi`——所以兩條路都真的走完 HTTP → 解析 → 判斷整條鏈。
* 只測判斷會漏掉「怎麼把 CF 回應讀成事實」,而 #97 的重演只要眼睛不一樣就夠了。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
// ── A:acr 那條(CLI 真正用的東西)
import { CfAccountClient } from '../src/lib/cf-api.ts';
import { planResources, applyResourcePlan, bindingKey } from '../src/lib/resource-resolver.ts';
import type { BindingRequirement } from '../src/lib/resource-resolver.ts';
// ── B:安裝器那條(只碰 shared/)
import { resolveInstanceResources } from '../../shared/resource-rule/installer-entry.mjs';
// ── 共用 fixture
import {
makeAccount,
requirements,
SCENARIOS,
WORKER_NEEDS,
type Scenario,
} from '../../shared/resource-rule/tests/fixture-account.mjs';
const ACCOUNT = 'acct-fixture';
const TOKEN = 'tok-fixture';
/** 把 fixture 的需求組成安裝器吃的 wrangler.toml 文字(它的入口是從 toml 讀需求的)。 */
function tomlsFor(): string[] {
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
for (const d of need.d1) {
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
}
return t;
});
}
/** A:跑 acr 那條。CfAccountClient 走 global fetch,所以這裡把它換成 fixture。 */
async function runAcrPath(scenario: Scenario, mode: 'update' | 'init') {
const account = makeAccount(scenario);
const realFetch = globalThis.fetch;
globalThis.fetch = account.fetch;
try {
const api = new CfAccountClient(ACCOUNT, TOKEN);
const plan = await planResources(api, requirements() as BindingRequirement[], mode);
if (plan.blockers.length > 0) {
return { blocked: true, blockers: plan.blockers, bindings: {} as Record<string, string>, account };
}
const resolved = await applyResourcePlan(api, plan);
const bindings: Record<string, string> = {};
for (const [k, r] of resolved) bindings[k] = r.value;
return { blocked: false, blockers: [] as string[], bindings, account };
} finally {
globalThis.fetch = realFetch;
}
}
/** B:跑安裝器那條。只用 shared/ 的入口,fetch 直接注入。 */
async function runInstallerPath(scenario: Scenario, mode: 'update' | 'init') {
const account = makeAccount(scenario);
const r = await resolveInstanceResources({
accountId: ACCOUNT,
apiToken: TOKEN,
wranglerTomls: tomlsFor(),
mode,
fetch: account.fetch,
});
return { blocked: r.blocked, blockers: r.blockers, bindings: r.bindings, account };
}
/** 把兩邊的決定印出來——PR 要貼的就是這張對照表。 */
function report(scenario: Scenario, a: Record<string, string>, b: Record<string, string>): void {
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
console.log(`\n ── ${scenario}${SCENARIOS[scenario].label}`);
console.log(` ${'binding'.padEnd(34)} ${'acr 選的'.padEnd(24)} 安裝器選的 一致?`);
for (const k of keys) {
const same = a[k] === b[k] ? '✓' : '✗';
console.log(` ${k.padEnd(34)} ${(a[k] ?? '—').padEnd(24)} ${(b[k] ?? '—').padEnd(18)} ${same}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
for (const scenario of ['fresh', 'installed', 'renamed'] as const) {
const mode = scenario === 'fresh' ? 'init' : 'update';
test(`兩條路一致 — ${scenario}${SCENARIOS[scenario].label}`, async () => {
const a = await runAcrPath(scenario, mode);
const b = await runInstallerPath(scenario, mode);
assert.equal(a.blocked, b.blocked, '一邊停手、一邊照做 = 最危險的分歧');
assert.deepEqual(a.blockers, b.blockers, '停手的理由也要一樣');
report(scenario, a.bindings, b.bindings);
assert.deepEqual(
a.bindings,
b.bindings,
`${scenario}:兩條路選出的 resource id 不同——這就是 Arcrun#97 的形狀`,
);
// 建立行為也要一致(一邊沿用、一邊新建 = 使用者的東西在其中一條路上會消失)
assert.deepEqual(a.account.created, b.account.created, '兩條路「建了什麼」必須一樣');
});
}
// ── 三種情境各自該有的行為(不只是「兩邊一樣」,還要「一樣地對」)─────────────
test('情境① 沒裝過 → 正常建新的(不能為了沿用而變成永遠不建)', async () => {
const { blocked, bindings, account } = await runInstallerPath('fresh', 'init');
assert.equal(blocked, false, '全新帳號要裝得起來');
assert.equal(account.created.kv.length, 9, `應新建 9 顆 KV,實際 ${account.created.kv.length}`);
assert.equal(account.created.d1.length, 1, `應新建 1 顆 D1,實際 ${account.created.d1.length}`);
// cypher 的 CREDENTIALS_DB 與 kbdb 的 DB 宣告同一個 database_name → 只該建一顆,兩邊共用
assert.equal(bindings['d1:CREDENTIALS_DB'], bindings['d1:DB'], '同一顆 D1 不該被建成兩顆');
console.log(`\n ① 新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length}` +
`D1 共用:CREDENTIALS_DB = DB = ${bindings['d1:DB']}`);
});
test('情境② 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在', async () => {
const { blocked, bindings, account } = await runInstallerPath('installed', 'update');
assert.equal(blocked, false);
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '更新不該建出任何新資源');
// 使用者的東西掛在資源 id 上:綁定還指向原本那顆 = 東西還在
assert.equal(bindings['kv_namespace:WEBHOOKS'], account.kvIdFor('WEBHOOKS'));
assert.equal(bindings['kv_namespace:SESSIONS_KV'], account.kvIdFor('SESSIONS_KV'));
assert.equal(bindings['d1:DB'], account.d1Id);
console.log(`\n ② 沿用:WEBHOOKS → ${bindings['kv_namespace:WEBHOOKS']}` +
`(工作流 ${account.userData.workflows.length} 支還在)|` +
`SESSIONS_KV → ${bindings['kv_namespace:SESSIONS_KV']}(登入 session 還在)|` +
`DB → ${bindings['d1:DB']}(子庫 ${account.userData.libraries.length} 個還在)|新建 0 顆`);
});
test('情境③ 資源在但名字與預期完全不同 → 仍然沿用(#97 的病根,專門驗)', async () => {
const { blocked, bindings, account } = await runInstallerPath('renamed', 'update');
assert.equal(blocked, false);
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] },
'名字對不上就新建 = 正是 #97:一次更新生出 9 顆空 KV,使用者的東西從畫面上消失');
for (const b of ['WEBHOOKS', 'SESSIONS_KV', 'RECIPES', 'USERS_KV']) {
assert.equal(bindings[bindingKey('kv_namespace', b)], account.kvIdFor(b),
`${b} 沒有沿用到原本那顆`);
}
console.log(`\n ③ 名字全不同(例:WEBHOOKS 那顆實際叫 "${SCENARIOS.renamed.titleFor('WEBHOOKS')}"` +
` → 仍沿用 ${bindings['kv_namespace:WEBHOOKS']},新建 0 顆`);
});
+243
View File
@@ -0,0 +1,243 @@
/**
* Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」
*
* 2026-08-12 實害:leo 更新完 leo21cPortal 設定頁的版本欄變成
* 「無法讀取目前版本(知識庫服務可能正在啟動)」。
* 根因:`ARCRUN_BUNDLE_VERSION` 是部署時注入的 plain_text var**只有安裝器會注入**
* CLI 這條路重部署時 wrangler 整份覆蓋 toml,沒寫的 var 直接消失 ⇒ 標籤被洗掉。
* #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),**沒修「櫃子上的標籤」**。
*
* 這份測試守兩件相反的事(本次的核心判斷):
* · 設定類 var(安裝器注入的 PORTAL_MAIL_RELAY_BASE 之類)=使用者實例的事實 → **沿用**
* · 版本標籤 ARCRUN_BUNDLE_VERSION =這份成品的屬性 → **每趟重烙,絕不沿用舊值**
* (沿用舊值 = 一個永遠停在安裝當天的假標籤,比沒有標籤更糟)
*
* 全部離線跑:真的 wrangler.toml + 真的 render/inject 程式碼,fetch 用假的,不碰任何實例。
*/
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 {
renderWranglerToml,
preservedVars,
applyVars,
resolveBundleStamp,
CLI_MANAGED_VARS,
VERSION_STAMP_WORKER,
type DeployContext,
} from '../src/lib/deploy.ts';
import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
const CTX: DeployContext = {
accountId: 'acc-user-123',
apiToken: 'token',
workerSubdomain: 'user-sub',
selfHosted: true,
kbdbEmbed: true,
};
/** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */
const LIVE_VARS: Record<string, string> = {
ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤
PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信
CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的
WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算
CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算
MULTI_TENANT: 'false', // CLI 自己算
ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫
};
/** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */
function readVars(toml: string): Record<string, string> {
const out: Record<string, string> = {};
let inVars = false;
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; }
if (!inVars || line.startsWith('#')) continue;
const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (m) out[m[1]] = m[2];
}
return out;
}
// ═════════════════════════════════════════════════════════════════════════════
// ① 病灶本身:舊行為會把標籤洗掉
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => {
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map());
assert.equal(
readVars(rendered).ARCRUN_BUNDLE_VERSION,
undefined,
'若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫',
);
});
// ═════════════════════════════════════════════════════════════════════════════
// ② 設定類 var:沿用實例上的事實
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => {
const keep = preservedVars(LIVE_VARS, CYPHER_TOML);
assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
// repo toml 寫死的是官方值,使用者實例上的值才是事實
assert.equal(keep.CONSOLE_TENANT, 'someone-else');
// 與 toml 同值 → 不需要重寫進去(雜訊)
assert.equal(keep.ENVIRONMENT, undefined);
});
test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => {
const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML);
for (const managed of CLI_MANAGED_VARS) {
assert.equal(keep[managed], undefined, `${managed} 不該被沿用`);
}
// 而且注入完的 toml 裡,這些值仍是這趟算出來的那個
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep);
const vars = readVars(rendered);
assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub');
assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123');
assert.equal(vars.MULTI_TENANT, 'false');
assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev');
});
// ═════════════════════════════════════════════════════════════════════════════
// ③ 版本標籤:重烙,不沿用
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => {
const fakeFetch = (async () =>
new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch;
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
assert.equal(stamp.version, '1.4.41');
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue
assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver');
});
test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => {
const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch;
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/);
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION);
assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」');
});
test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => {
const fakeFetch = (async () =>
new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch;
const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch);
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/);
});
// ═════════════════════════════════════════════════════════════════════════════
// ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => {
const api: ResourceApi = {
async getScriptBindings(script: string): Promise<ScriptBindings> {
if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} };
return {
deployed: true,
bindings: [
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' },
{ kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' },
{ kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' },
{ kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' },
{ kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' },
{ kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' },
{ kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' },
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' },
],
vars: LIVE_VARS,
};
},
async listKvNamespaces() {
return new Map([
['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'],
['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'],
]);
},
async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); },
async listVectorizeIndexes() { return []; },
async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); },
async createD1Database() { throw new Error('這趟不該新建 D1'); },
async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); },
};
const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map());
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
const parsed = parseWranglerRequirements(preview);
const plan = await planResources(
api,
parsed.bindings.map((b) => ({ ...b, worker: parsed.script })),
'update',
);
assert.deepEqual(plan.blockers, []);
// 讀綁定時順手把 var 帶回來——不另外打一次 API
assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
const fakeFetch = (async () =>
new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch;
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
const extra = {
...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML),
ARCRUN_BUNDLE_VERSION: stamp.version,
ARCRUN_BUNDLE_COMMIT: stamp.commit!,
};
const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra));
// ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本
assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41');
assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
// ② 安裝器注入的設定沒有在更新中消失
assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
assert.equal(deployed.CONSOLE_TENANT, 'someone-else');
// ③ CLI 自己算的仍然是這趟算出來的
assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub');
assert.equal(deployed.MULTI_TENANT, 'false');
});
// ═════════════════════════════════════════════════════════════════════════════
// ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => {
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/);
assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/);
assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/);
const noVars = applyVars('name = "w"\n', { A: 'a' });
assert.match(noVars, /\[vars\]\nA = "a"/);
assert.match(noVars, /^name = "w"/);
});
test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => {
const out = applyVars('[vars]\n', { A: 'say "hi"\\path' });
assert.match(out, /A = "say \\"hi\\"\\\\path"/);
});
test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => {
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/);
assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/);
// 怪名字不寫進去(不拿它組正規式)
assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n');
});
test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => {
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map()));
const withVars = parseWranglerRequirements(
renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }),
);
assert.equal(withVars.script, withoutVars.script);
assert.deepEqual(withVars.bindings, withoutVars.bindings);
});
+5
View File
@@ -6,6 +6,11 @@
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
// resource-rule.mjs 是共用規則的副本(純 JS + JSDoc,零依賴,見該檔開頭)。
// allowJs 讓 tsc 把它一起編進 dist(否則 npm 套件裡會缺這支 → 執行期 MODULE_NOT_FOUND);
// checkJs 讓它的 JSDoc 型別真的被檢查,而不是靜靜地當 any。
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
@@ -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>';
+75 -36
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;
@@ -2018,6 +2039,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// 兩邊都是 semver(例 1.4.2),用數字逐段比,不用字串比('1.4.10' < '1.4.9' 會出錯)。
var INSTALLER_ORIGIN = 'https://install.arcrun.dev';
// Arcrun#106:版號後面可以帶 build metadata`1.4.41+d61`、`1.4.41+a1b2c3d`)——
// 那是 semver 規格裡「比大小時要忽略」的那一段。舊寫法拿整串去比對正規式,
// 一律落到「較舊版本」(youlin 實例就是這樣,明明有版號卻顯示不出來)。
// 這裡只取前面的 `x.y.z` 當比較用的核心,顯示仍顯示完整原字串。
function semverCore(v) {
var m = String(v || '').match(/^(\d+\.\d+\.\d+)/);
return m ? m[1] : '';
}
function cmpSemver(a, b) {
var x = String(a || '').split('.').map(Number);
var y = String(b || '').split('.').map(Number);
@@ -2034,9 +2064,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var btn = $('st-ver-update');
if (!line) return;
// #106:順便把 bundle_commit 帶回來(有注入才有)——版號是頻道編號,commit 才是「真的部了哪份碼」。
var mineCommit = '';
var mineP = fetch(window.ARCRUN_API_BASE + '/health', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { return (j && j.bundle_version) || ''; })
.then(function (j) {
mineCommit = (j && j.bundle_commit) || '';
return (j && j.bundle_version) || '';
})
.catch(function () { return ''; });
var latestP = fetch(INSTALLER_ORIGIN + '/api/latest')
.then(function (r) { return r.ok ? r.json() : null; })
@@ -2049,15 +2084,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!mine) { line.textContent = '無法讀取目前版本(知識庫服務可能正在啟動)'; return; }
// 舊實例的 bundle_version 是舊格式(2026-07-31+8e83589),比不了 semver。
// 這種情況一律當成「落後」——因為新版才會寫 semver 進來。
var mineIsSemver = /^\d+\.\d+\.\d+$/.test(mine);
// #106`1.4.41+<commit>` 這種帶 build metadata 的**是** semver,取核心比即可。
var mineCore = semverCore(mine);
var mineIsSemver = !!mineCore;
// commit 是輔助資訊(有才顯示):版號說「哪一版」,commit 說「真的是哪份碼」。
var commitNote = mineCommit ? ' <span class="muted">commit ' + esc(String(mineCommit).slice(0, 7)) + '</span>' : '';
if (!latest) {
line.textContent = '目前版本 ' + mine + '(暫時查不到最新版,稍後再試)';
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong>(暫時查不到最新版,稍後再試)' + commitNote;
return;
}
var behind = !mineIsSemver || cmpSemver(mine, latest) < 0;
var behind = !mineIsSemver || cmpSemver(mineCore, latest) < 0;
if (!behind) {
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版';
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版' + commitNote;
dot.style.display = 'none';
btn.style.display = 'none';
return;
+2 -1
View File
@@ -5,7 +5,8 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run"
"check:tenant": "node scripts/check-tenant-source.mjs",
"test": "node scripts/check-tenant-source.mjs && vitest run"
},
"dependencies": {
"@hono/zod-openapi": "^1.2.4",
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* 靜態租戶字串不得用於資料面過濾機械閘的**執行殼**Arcrun#108
*
* 規則本體純函式 node 相依 `tenant-source-rules.mjs`拆開的理由是
* **這道閘自己要能被測試**Workers runtime vitest 沒有 node:fs規則若和走檔案系統的
* 程式碼綁在一起就 import 不動測試也就寫不出來當晚有一道閘連讀自己的原始碼都擋
* 結果沒人驗得了它會不會誤攔現在 tests/tenant-gate.test.ts 直接餵字串驗規則
*
* 用法
* node scripts/check-tenant-source.mjs [projectRoot] # src/有違規 exit 1
* node scripts/check-tenant-source.mjs --stdin <相對路徑> # stdin 即將寫入的內容
* npm run check:tenant
*
* `--stdin` 是給 `.claude/hooks/pre-write-guard.sh`規則 8.1用的在檔案**還沒寫下去之前**
* 就擋這樣違規根本進不了工作區Edit 只給片段也沒關係規則是逐行的正好只看新寫的那幾行
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { scanSource } from './tenant-source-rules.mjs';
/** 遞迴列出目錄下的 .ts 檔(相對 root 的路徑)。 */
function listTsFiles(root, dir = root, out = []) {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) listTsFiles(root, full, out);
else if (name.endsWith('.ts')) out.push(relative(root, full));
}
return out;
}
/** 掃整個 cypher-executor/src。回傳違規清單。 */
export function scanProject(projectRoot) {
const srcRoot = join(projectRoot, 'src');
const all = [];
for (const rel of listTsFiles(projectRoot, srcRoot)) {
const relPosix = rel.split(sep).join('/');
all.push(
...scanSource(relPosix, readFileSync(join(projectRoot, rel), 'utf8')).map((v) => ({
...v,
file: relPosix,
})),
);
}
return all;
}
/** stdin 模式:讀「即將寫入的內容」,印違規、有違規 → exit 1。 */
async function runStdin(relPath) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const violations = scanSource(relPath, Buffer.concat(chunks).toString('utf8'));
if (violations.length === 0) return 0;
for (const v of violations) {
console.error(`[${v.rule}] ${relPath}(新寫入的第 ${v.line} 行):${v.text}`);
console.error(`${v.message}`);
}
return 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
if (process.argv[2] === '--stdin') {
process.exit(await runStdin(process.argv[3] ?? 'src/unknown.ts'));
}
const projectRoot = process.argv[2] ?? process.cwd();
const violations = scanProject(projectRoot);
if (violations.length === 0) {
console.log('✓ 租戶來源檢查通過:資料面 owner_id 全部來自 src/lib/tenant.ts');
process.exit(0);
}
console.error('❌ 租戶來源檢查失敗(Arcrun#108 的閘)\n');
for (const v of violations) {
console.error(` [${v.rule}] ${v.file}:${v.line}`);
console.error(` ${v.text}`);
console.error(`${v.message}\n`);
}
process.exit(1);
}
@@ -0,0 +1,142 @@
/**
* 靜態租戶字串不得用於資料面過濾 機械閘Arcrun#108
*
*
* 為什麼要有這道閘
*
* 同一句話已經寫錯兩次
* #105 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"`
* #108 `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
* 兩次都是拿一個部署環境變數的字面預設值當成使用者資料的歸屬規則早就在rule 07
* 薄殼design §3.3 租戶不下發**沒有任何機制會擋**所以它每隔幾週就長回來一次
* leo 2026-08-12做一個平台要減少 hotfix 修掉 bug 不算完成要留下會擋的東西
*
*
* 判準有沒有在做那件事不是看有沒有出現那個詞
*
* 誤攔比漏攔更容易殺死一道閘被擋煩了就有人把它關掉所以三條規則全部盯**行為**
*
* T1 租戶環境變數只有一個產地
* `env.CONSOLE_TENANT` / `env.ARCRUN_NAMESPACE` 只能在 src/lib/tenant.ts 被讀取
* 盯的是你在把部署設定讀成身分這個動作本身註解裡寫這兩個字不算只看 `env.X` 取值
*
* T2 資料面租戶識別不得憑空捏造
* `as TenantId` 只能出現在 src/lib/tenant.ts且不得套在字面字串上
* 盯的是繞過唯一產地自己造一個租戶
*
* T3 帳號層字串不得流進知識資料面
* 同一行同時在組 owner_id值來自 portalTenant()/accountTenant()
* 這正是 #108 那一行的形狀`owner_id=${encodeURIComponent(portalTenant(c.env))}`
* `owner_id: ns`帳號子 namespace合法不命中`x.owner_id` 這種讀取也不命中
*
*
* 這道閘自己要能被測試
*
* 核心是純函式 `scanSource(relPath, text)`不碰檔案系統測試餵好例子/壞例子驗它會不會叫
* tests/tenant-gate.test.ts當晚有一道閘連讀自己的原始碼都擋導致沒人驗得了它
* 本檔只掃 `src/`測試與 fixture 都不在掃描範圍內所以**不會擋到自己**
*
* 本檔是**純規則** node 相依所以 Workers runtime vitest import 得動
* 走檔案系統的那半在 check-tenant-source.mjs
*/
/** 唯一允許產出租戶識別的檔案(相對 cypher-executor/)。 */
export const TENANT_SOURCE_FILE = 'src/lib/tenant.ts';
/** 只宣告型別、不取值的檔案(`CONSOLE_TENANT?: string` 這種)。 */
const TYPE_DECL_FILES = new Set(['src/types.ts']);
/** 被視為「租戶來源」的環境變數——讀它們=在決定使用者資料的歸屬。 */
const TENANT_ENV_VARS = ['CONSOLE_TENANT', 'ARCRUN_NAMESPACE'];
/** 帳號層租戶字串的取得方式(回的是 string 不是 TenantId,不得用於知識資料面)。 */
const ACCOUNT_TENANT_CALLS = ['portalTenant(', 'accountTenant('];
const ENV_READ = new RegExp(String.raw`\benv\s*\.\s*(${TENANT_ENV_VARS.join('|')})\b`);
const AS_TENANT_ID = /\bas\s+TenantId\b/;
const LITERAL_AS_TENANT_ID = /(['"`][^'"`]*['"`])\s*as\s+TenantId\b/;
/**
* 這一行在組 owner_id **構造**才算**讀取**不算
* `owner_id=` 出現在字串/樣板裡`owner_id:` 當成物件屬性在賦值
* 不算`x.owner_id``owner_id?:`型別宣告`owner_id` 單獨出現在註解句子裡
*/
function buildsOwnerFilter(line) {
const code = stripComment(line);
if (!code.includes('owner_id')) return false;
if (/owner_id\s*=/.test(code) && !/[.\w]owner_id\s*=/.test(code)) return true; // `?owner_id=` / `owner_id=${...}`
if (/(^|[^.\w])owner_id\s*:/.test(code) && !/owner_id\s*\?\s*:/.test(code)) return true; // `owner_id: X`
return false;
}
/** 去掉行末 `//` 註解(不處理跨行 /* *\/——那種行本來就不含可執行的取值)。 */
function stripComment(line) {
const i = line.indexOf('//');
return i === -1 ? line : line.slice(0, i);
}
/** 整行是註解?(`//` 開頭或位於 JSDoc 區塊的 ` *` 行) */
function isCommentLine(line) {
const t = line.trim();
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
}
/**
* 掃一份原始碼回傳違規清單純函式測試直接餵字串
* @param {string} relPath 相對 cypher-executor/ 的路徑例如 'src/routes/portal-data.ts'
* @param {string} text 檔案內容
* @returns {{rule: string, line: number, text: string, message: string}[]}
*/
export function scanSource(relPath, text) {
const rel = relPath.split('\\').join('/');
const violations = [];
const lines = text.split('\n');
lines.forEach((line, idx) => {
const n = idx + 1;
const push = (rule, message) =>
violations.push({ rule, line: n, text: line.trim(), message });
if (isCommentLine(line)) return;
const code = stripComment(line);
// T1:租戶環境變數只有一個產地
if (rel !== TENANT_SOURCE_FILE && !TYPE_DECL_FILES.has(rel) && ENV_READ.test(code)) {
push(
'T1',
`租戶環境變數只能在 ${TENANT_SOURCE_FILE} 讀取。` +
'在別處讀它=又一次「身分來自環境變數」(#105/#108 同形),' +
'請改呼叫 knowledgeOwner(env)(知識資料面)或 accountTenant(env)(帳號層)。',
);
}
// T2:資料面租戶識別不得憑空捏造
if (AS_TENANT_ID.test(code)) {
if (rel !== TENANT_SOURCE_FILE) {
push(
'T2',
`TenantId 只能由 ${TENANT_SOURCE_FILE} 產生。自己 cast 一個等於繞過唯一產地——` +
'請用 knowledgeOwner(env) 或 tenantFromApiKey(header)。',
);
} else if (LITERAL_AS_TENANT_ID.test(code)) {
push(
'T2',
'不得把**字面字串**當成租戶識別(那就是 `|| "leo"` 那個預設值的原形)。' +
'解析不到請丟 TenantUnresolvedError,誠實說讀不到。',
);
}
}
// T3:帳號層字串不得流進知識資料面
if (buildsOwnerFilter(line) && ACCOUNT_TENANT_CALLS.some((fn) => code.includes(fn))) {
push(
'T3',
'這一行拿**帳號層**租戶字串去組知識資料面的 owner_id 過濾——' +
'正是 #108 那一行(1854 條三元組被過濾成 0)。' +
'知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()。',
);
}
});
return violations;
}
+11 -1
View File
@@ -51,6 +51,11 @@ const WASM_HTTP_RUNNER_IDS: ReadonlySet<string> = new Set([
'auth_service_account',
'auth_oauth2',
'auth_mtls',
// hashArcrun#912026-08-13):純計算零件(sha256/sha1/md5hex/base64),
// 出貨線版本號機制與成品指紋核對用它。no_network_syscall,故不走 LOGIC_BINDING_MAP
// 的 Service Binding 路(rule 3.1 禁新增 binding),走這裡的通用 wasmWorkerUrl 推導,
// 與 code/cron 同一形狀(獨立 Worker,白名單只是「知道這個 canonical_id 存在」)。
'hash',
]);
/**
@@ -77,7 +82,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
filter: 'SVC_FILTER',
merge: 'SVC_MERGE',
try_catch: 'SVC_TRY_CATCH',
wait: 'SVC_WAIT',
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: 'SVC_SET',
array_ops: 'SVC_ARRAY_OPS',
string_ops: 'SVC_STRING_OPS',
+62
View File
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
'SUBFLOW': 'CALLS_SUBFLOW',
};
/**
* wait registry/components/wait/component.contract.yaml
* **調**Arcrun#101
*
*/
export const WAIT_MAX_MS = 30000;
/**
*
* WASM = Workercypher-executor HTTP URL R2
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
const c = ctx as Record<string, unknown>;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
['wait', async (ctx) => {
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: 'ms 必須大於 0' };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
await new Promise<void>((resolve) => setTimeout(resolve, ms));
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
? c.context as Record<string, unknown>
: {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}],
]);
export const SCORE_THRESHOLD = 0.5;
+144
View File
@@ -0,0 +1,144 @@
/**
* ****Arcrun#108
*
*
*
*
* #105`ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"`
* #108`portalTenant(env) = env.CONSOLE_TENANT || "leo"`
*
* ****
* leo `owner_id=bfezv28v`08-11 `~/.arcrun/config.yaml`
* `api_key` `X-Arcrun-API-Key` cypher repo `"leo"`
* 1854 0
*
*
*
*
* ****
*
* 使 `~/.arcrun/config.yaml` `api_key` namespace
* CLI push workflow`{ns}:wf:*``owner_id=ns`
* MCP Bearer****
* `acr init/update` `ARCRUN_NAMESPACE`cli/src/lib/deploy.ts
* CF_ACCOUNT_ID / WORKER_SUBDOMAIN / MULTI_TENANT CLI
* 使****
*
* #105 ****
* portal design D-2 `{tenant}::portal`
* namespace `libraries` owner_id owner_id
*
* #105
* ****
*
*
*
* `TenantId`
*
* `TenantId` branded string****`knowledgeOwner` / `tenantFromApiKey`
* owner_id `ownerQuery()` / `ownerField()` `TenantId`
* env `tsc`
*
* `scripts/check-tenant-source.mjs` `tests/tenant-gate.test.ts`
* repo
*/
import type { Bindings } from '../types';
/**
*
*
* branded type minter
* namespace `knowledgeOwner``tenantFromApiKey`
*
*/
export type TenantId = string & { readonly __tenantId: unique symbol };
/** 實例 namespace 解析不出來 → 誠實炸掉,不拿預設值當答案(#100「讀不到就說讀不到」同源)。 */
export class TenantUnresolvedError extends Error {
constructor(message: string) {
super(message);
this.name = 'TenantUnresolvedError';
}
}
/**
* ** owner_id** / entries / records / / KV
* CLIMCP
*
* ****
* 1. `ARCRUN_NAMESPACE``acr init/update` `~/.arcrun/config.yaml` `api_key`
*
* 2. `CONSOLE_TENANT` prod`cypher.arcrun.dev` #108
* prod `leo` `acr update`
* self-hosted 1
* 3. ** TenantUnresolvedError** `"leo"`
*
*/
export function knowledgeOwner(env: Bindings): TenantId {
const injected = (env.ARCRUN_NAMESPACE ?? '').trim();
if (injected) return injected as TenantId;
const legacy = (env.CONSOLE_TENANT ?? '').trim();
if (legacy) return legacy as TenantId;
throw new TenantUnresolvedError(
'這個部署沒有知識命名空間(ARCRUN_NAMESPACE / CONSOLE_TENANT 都沒設)——' +
'不知道要去哪一格找資料。請跑 `acr update` 讓它從你的 ~/.arcrun/config.yaml 注入。',
);
}
/**
* `X-Arcrun-API-Key`namespace self-hosted
*
* **** #105/#108
* owner_id TenantId
*
*/
export function tenantFromApiKey(apiKey: string): TenantId {
const key = (apiKey ?? '').trim();
if (!key) throw new TenantUnresolvedError('缺少 X-Arcrun-API-Key,無法決定查詢範圍');
return key as TenantId;
}
/**
* namespace design D-2 `{tenant}::portal`
*
* 🔴 ** `string` `TenantId`** cypher
* ****
* #108
*
* `'leo'`
*/
export function accountTenant(env: Bindings): string {
return env.CONSOLE_TENANT || 'leo';
}
/**
* KBDB query string owner_id ****
* `kbdbFetch(env, `/map?${ownerQuery(tenant)}`)`
*/
export function ownerQuery(tenant: TenantId): string {
return `owner_id=${encodeURIComponent(tenant)}`;
}
/**
* JSON body / URLSearchParams owner_id ****
* `JSON.stringify({ owner_id: ownerField(tenant) })`
*/
export function ownerField(tenant: TenantId): string {
return tenant;
}
/**
* 🔴 ****KBDB `owner_id`
*
* #100 0
* ****
* review
*/
export function censusQueryAllTenants(): string {
return 'owner_id=';
}
/** 逐筆核對歸屬(讀回來的 record/entry 是不是這個租戶的)。缺欄位一律視為不是。 */
export function isOwnedBy(value: unknown, tenant: TenantId): boolean {
return typeof value === 'string' && value === (tenant as string);
}
+12 -1
View File
@@ -35,6 +35,8 @@ import {
readAuthStore,
type AuthConsoleRecord,
} from '../lib/portal-auth-store';
// Arcrun#108:租戶字串唯一產地。
import { knowledgeOwner } from '../lib/tenant';
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
@@ -79,8 +81,17 @@ async function hashPassword(password: string, salt: string): Promise<string> {
return h;
}
/**
* console api_key console portal
* portal console
*
* Arcrun#108**** owner_id `/kbdb/*`
* CLIMCP namespace `env.CONSOLE_TENANT || 'leo'`
* portal `bfezv28v` `leo`console
* `lib/tenant.ts`
*/
function tenantOf(c: { env: Bindings }): string {
return c.env.CONSOLE_TENANT || 'leo';
return knowledgeOwner(c.env);
}
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
+48 -12
View File
@@ -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,
@@ -72,6 +72,9 @@ import {
taipeiDayKey,
} from '../lib/console-dashboard-model';
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
// Arcrun#108:租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT
// 過濾而看不到自己的資料——與 portal 同一個病,同一個修法。
import { knowledgeOwner } from '../lib/tenant';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -104,6 +107,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);
@@ -240,7 +268,7 @@ export async function cachedGiteaSprint(
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const now = Date.now();
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
@@ -254,6 +282,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal,
@@ -265,7 +294,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 +435,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(),
});
@@ -418,17 +454,17 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
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 +472,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(),
@@ -466,7 +502,7 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!ok) return c.json({ error: '需要登入(console session' }, 401);
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const [todoEntries, inboxEntries] = await Promise.all([
fetchEntries(c.env, tenant, 'todo', 500),
fetchEntries(c.env, tenant, 'inbox', 200),
@@ -497,7 +533,7 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => {
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const { base, headers } = kbdbBase(c.env);
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
+8
View File
@@ -15,11 +15,19 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
// Arcrun#106leo 08-12 實撞:更新完設定頁變成「無法讀取目前版本」):
// `bundle_version` 只在部署時被注入,而**只有安裝器會注入**——CLI 更新那條路重部署
// 等於把這個標籤洗掉(wrangler deploy 整份覆蓋,toml 沒寫的 var 直接消失)。
// 修在 CLI 那側(cli/src/lib/deploy.ts:既有 var 沿用 + 版本標籤每趟重烙)。
// 這裡只多吐一個 `bundle_commit`:版號是「發行頻道的編號」,commit 才是「真的部了哪份碼」——
// 兩個一起看才有辦法查「標籤有沒有跟成品漂掉」。沒注入就省略該欄(同 bundle_version 的既有行為)。
healthRouter.get('/health', (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
return c.json({
ok: true,
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
...(bundleCommit ? { bundle_commit: bundleCommit } : {}),
auth_store: authStoreStatus(c.env),
// arcrun-rag#38/#69/#252026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
+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' } });
+355 -19
View File
@@ -23,8 +23,11 @@
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 { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
// Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import
// portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。
import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant';
import { graphBase, graphHeaders } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
@@ -40,7 +43,9 @@ export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
/** 讀 tenant 的 named workflow graph`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text');
// #108workflow 是 CLI `acr push` 用實例 namespace 寫進來的(`{ns}:wf:*`),
// 所以讀的時候也要用同一個 namespace,不是帳號層那個字串。
const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text');
if (!raw) return null;
try {
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
@@ -186,10 +191,51 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
/**
* KBDB `/records/triplet-stats` SQL COUNTowner '' KBDB
* `?1 = '' OR e.owner_id = ?1`nullcaller 0
*/
async function tripletCount(env: Bindings, owner: TenantId | null): Promise<number | null> {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
// owner=null 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個
// 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(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: TenantId): 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, null) };
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
if (!body || !Array.isArray(body.records)) return null;
@@ -224,7 +270,7 @@ portalDataRouter.get('/portal/data/search', (c) =>
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
if (!libraries.includes('*')) params.set('library', libraries.join(','));
// 透傳的只有「在權限範圍內再收窄」的 filterowner_id/library 上面已由 server 定死,
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
@@ -295,7 +341,7 @@ portalDataRouter.get('/portal/data/entries/:id', (c) =>
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
const entry = body.entry;
if (!entry) return notFound(c);
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
return c.json({ success: true, entry });
}),
@@ -320,7 +366,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const nodeName = normalizeCjkQuery(c.req.param('name'));
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
if (wfGraph) {
const depthRaw = c.req.query('depth') ?? '';
@@ -343,8 +389,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) {
@@ -382,15 +427,24 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
if (!(await hasGraphAccess(c.env, libraries))) {
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 tenant = knowledgeOwner(c.env);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(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 +467,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,
});
}),
);
@@ -437,7 +512,7 @@ portalDataRouter.get('/portal/data/chat', (c) =>
wfGraph,
{ question },
'rag_chat',
portalTenant(c.env),
knowledgeOwner(c.env),
c.executionCtx,
);
if (!result.success) {
@@ -543,7 +618,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
// 資料源與 /webhooks/named /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const prefix = `${tenant}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = await Promise.all(
@@ -569,7 +644,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
let last_execution: { timestamp: string; verdict?: string } | null = null;
const execRes = await kbdbFetch(
c.env,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`,
);
const execBody = await execRes.json().catch(() => null) as {
success?: boolean;
@@ -585,6 +660,267 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
}),
);
// ═══════════════════════════════════════════════════════════════════════════
// 授權的 AIarcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
// ═══════════════════════════════════════════════════════════════════════════
//
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
//
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
// 於是查詢時只好去找一把**服務內部金鑰**KBDB_INTERNAL_TOKEN)直打 KBDB——
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
//
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
// ① 一律 requirePortalUsersession → 回讀 user record → 停用即時生效),
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
// 呼叫端自己帶租戶字串=繞過庫過濾),
// ③ 越權與不存在同回 404(不洩存在性)。
//
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
/**
* record entry ** `library` slot record **
*
* entry 'general' fallbackentry
* general record contact / workflow_metadata / triplet
* triplet library slot general fallback
* ["kb"] contact
* owner_id server /
*/
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
const lib = values?.library;
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
}
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
function canReadRecord(
rec: { values?: Record<string, unknown>; owner_id?: string | null },
tenant: TenantId,
libraries: string[],
): boolean {
if (!isOwnedBy(rec.owner_id, tenant)) return false;
const lib = recordLibrary(rec.values);
return lib === null || canReadLibrary(libraries, lib);
}
// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。
// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
//
// 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜**
// (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制):
// no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題
// filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離
// confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」
// scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上**
// (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾)
// scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。
//
// ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。
// 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。
portalDataRouter.get('/portal/data/map', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'no_library_grant',
note: '此帳號尚未被授權任何知識庫,請聯絡管理員。',
});
}
const tenant = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
if (!body || !Array.isArray(body.libraries)) {
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
}
const allowed = body.libraries.filter(
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
);
if (allowed.length > 0) {
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
}
// 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。
if (body.libraries.length > 0) {
// 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'filtered_out',
note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。',
});
}
const census = await tripletCensus(c.env, tenant);
if (census.owned === null || (census.owned === 0 && census.any === null)) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: false, empty_reason: 'unreadable',
note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。',
});
}
if (census.owned === 0 && (census.any ?? 0) > 0) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: false, empty_reason: 'scope_mismatch',
instance_triplet_count: census.any,
note:
`讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` +
'這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' +
'請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。',
});
}
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'confirmed_empty',
note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
});
}),
);
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
portalDataRouter.get('/portal/data/map/:library', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
const library = c.req.param('library');
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`,
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/templates — template 清單。
// template=虛擬表定義(schema),**全域共享不分租戶**kbdb-proxy 同一裁定,leo 2026-06-14):
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
portalDataRouter.get('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const res = await kbdbFetch(c.env, '/templates');
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// POST /portal/data/templates — 建 templatename + slots)。
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
// created_by 記租戶(溯源),template 本身全域可見可用。
portalDataRouter.post('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as
| { name?: unknown; slots?: unknown; description?: unknown }
| null;
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
return c.json({ error: 'name 與 slots[] 必填' }, 400);
}
const res = await kbdbFetch(c.env, '/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: body.name,
slots: body.slots,
description: typeof body.description === 'string' ? body.description : undefined,
created_by: knowledgeOwner(c.env),
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
const tenant = knowledgeOwner(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`,
);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
| null;
if (!body || !Array.isArray(body.records)) {
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
return c.json({ success: true, records, count: records.length });
}),
);
// GET /portal/data/records/:recordId — 單筆 record。
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
portalDataRouter.get('/portal/data/records/:recordId', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
| null;
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
}),
);
// POST /portal/data/records — 依 template 填一筆 record。
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
portalDataRouter.post('/portal/data/records', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
}
const body = (await c.req.json().catch(() => null)) as
| { template?: unknown; values?: unknown }
| null;
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
return c.json({ error: 'template 與 values 必填' }, 400);
}
const values = body.values as Record<string, unknown>;
const targetLib = recordLibrary(values);
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
}
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
//
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
@@ -607,7 +943,7 @@ portalDataRouter.get('/portal/data/diagnostics', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const core = await buildDiagnostics(c.env, tenant);
return c.json({
generated_at: new Date().toISOString(),
+53 -21
View File
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// Arcrun#108:租戶字串只有一個產地(lib/tenant.ts)。帳號面用 accountTenant(普通 string),
// 知識資料面用 knowledgeOwnerTenantId)——型別分家,拿錯編不過。
import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey, TenantUnresolvedError, type TenantId } from '../lib/tenant';
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential, hasCredential } from './credentials';
@@ -58,14 +61,27 @@ export const LIBRARY_TEMPLATE = 'portal_library';
// ── 基礎 helpers ────────────────────────────────────────────────────────────
/** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */
/**
* **** owner_idArcrun#108
*
* namespace`{tenant}::portal`design D-2 cypher
* extractor_config / credentials cypher
*
* 🔴 **** / entries / records / /
* CLI namespace #108
* leo 1854 `CONSOLE_TENANT="leo"` 0
* `lib/tenant.ts` `knowledgeOwner(env)` `TenantId` `string`
*
*
* ** server 使**
*/
export function portalTenant(env: Bindings): string {
return env.CONSOLE_TENANT || 'leo';
return accountTenant(env);
}
/** 帳號子 namespacedesign D-2)。 */
function portalNamespace(env: Bindings): string {
return `${portalTenant(env)}::portal`;
return `${accountTenant(env)}::portal`;
}
function sessionTtl(env: Bindings): number {
@@ -102,6 +118,11 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
}
// Arcrun#108:連「這台實例的知識放在哪一格」都解析不出來 → 誠實講「讀不到」,
// 不拿 repo 預設值當答案然後回一頁空的(那正是本票的病:設定缺失被畫成「你沒有資料」)。
if (e instanceof TenantUnresolvedError) {
return c.json({ error: e.message, code: 'tenant_unresolved' }, 500);
}
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
throw e;
}
@@ -677,6 +698,11 @@ portalRouter.post('/portal/login', (c) =>
display_name: rec.values.display_name ?? '',
role: rec.values.role ?? 'user',
libraries: parseLibraries(rec.values.libraries),
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
session_expires_in: sessionTtl(c.env),
// 絕不回租戶字串(design §3.3portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*
});
}),
@@ -1404,7 +1430,6 @@ portalRouter.post('/portal/daemon/config', (c) =>
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const tenant = portalTenant(c.env);
// t176leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
//
@@ -1417,9 +1442,12 @@ portalRouter.post('/portal/daemon/config', (c) =>
//
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_urlnamespacelibrary)與本 route 本身照舊,
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
// #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
// 以前給的是帳號層字串(CONSOLE_TENANT),與 CLIMCP 用的實例 namespace 是兩個來源
// ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner,一台實例一個值。
const daemonCfg: Record<string, string> = {
cypher_url: new URL(c.req.url).origin,
namespace: tenant,
namespace: knowledgeOwner(c.env),
library: 'kb',
email,
instance_name: String(rec.values.display_name ?? ''),
@@ -1451,7 +1479,7 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
const key = String(body?.key ?? '').trim();
if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const kvKey = `${tenant}:wf:rag_chat`;
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
@@ -1513,8 +1541,8 @@ portalRouter.get('/portal/admin/libraries', (c) =>
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
try {
const tenant = portalTenant(c.env);
const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
const tenant = knowledgeOwner(c.env);
const ownerParam = ownerQuery(tenant);
const [autoRes, cardRes, tripletRes] = await Promise.all([
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
@@ -1707,8 +1735,8 @@ portalRouter.get('/portal/admin/execution-log-retention', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const ownerId = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`);
const data = (await res.json()) as { retention_days?: number | null; default_days?: number };
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
@@ -1727,10 +1755,10 @@ portalRouter.put('/portal/admin/execution-log-retention', (c) =>
if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
}
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, '/execution-log/retention', {
method: 'PUT',
body: JSON.stringify({ owner_id: ownerId, retention_days: days === undefined ? null : days }),
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === undefined ? null : days }),
});
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`);
const data = (await res.json()) as { retention_days?: number | null };
@@ -1751,10 +1779,10 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
const confirm = String(body?.confirm ?? '').trim();
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
method: 'PATCH',
body: JSON.stringify({ owner_id: ownerId, library: name }),
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }),
});
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
const data = (await res.json()) as { deprecated_count?: number };
@@ -1845,16 +1873,19 @@ export interface DiagnosticsCore {
notes: string[];
}
/** tenantowner_idsession 版傳 portalTenant(env)daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */
export async function buildDiagnostics(env: Bindings, tenant: string): Promise<DiagnosticsCore> {
/**
* tenantowner_idsession `knowledgeOwner(env)`daemon `tenantFromApiKey(header)`
* #108 `TenantId`
*/
export async function buildDiagnostics(env: Bindings, tenant: TenantId): Promise<DiagnosticsCore> {
const notes: string[] = [];
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
let embedding: Record<string, unknown> = { checked: false };
try {
const [statusRes, selftestRes] = await Promise.all([
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`),
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`),
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`),
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`),
]);
const statusBody = (await statusRes.json().catch(() => null)) as
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
@@ -1890,7 +1921,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
// - GET /records/triplet-statsper-library 即時聚合 SQLt142COUNT,非快取)。
let library_count = 0;
let triplet_count = 0;
const ownerParam = new URLSearchParams({ owner_id: tenant }).toString();
const ownerParam = ownerQuery(tenant);
try {
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
@@ -1924,7 +1955,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
let library_scope_check: Record<string, unknown> = { ran: false };
if (library_count === 0 && triplet_count === 0) {
try {
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`);
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant), limit: '1' }).toString()}`);
const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
const total = probeBody?.total ?? 0;
library_scope_check = {
@@ -1971,7 +2002,8 @@ portalRouter.get('/portal/daemon/diagnostics', (c) =>
run(c, async () => {
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const core = await buildDiagnostics(c.env, apiKey);
// 這條路的租戶來自**請求本身**(小幫手帶的 namespace),不是環境變數 → 沒有 #108 的漂移問題。
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
return c.json({
generated_at: new Date().toISOString(),
instance_url: new URL(c.req.url).origin,
+23 -3
View File
@@ -75,6 +75,13 @@ export type Bindings = {
* dev undefined/health
*/
ARCRUN_BUNDLE_VERSION?: string;
/**
* Arcrun#106 commit40 sha
* `ARCRUN_BUNDLE_VERSION` ****semverPortal/daemon
* ****
* `acr init/update`cli/src/lib/deploy.ts var /health
*/
ARCRUN_BUNDLE_COMMIT?: string;
// Platform telemetry api_key(可選,wrangler secret
// 對應 SDD .agents/specs/llm-interface/ M1.2
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
@@ -84,6 +91,20 @@ export type Bindings = {
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
CONSOLE_TENANT?: string;
/**
* ****Arcrun#108使 `~/.arcrun/config.yaml` `api_key`
*
* `acr init/update`cli/src/lib/deploy.ts CLI_MANAGED_VARS**使**
* CLI push workflow`{ns}:wf:*``owner_id=ns`MCP Bearer
*
*
* 沿 `CONSOLE_TENANT` repo toml ** prod **`leo`
* self-hosted #108 1854 0
* `CONSOLE_TENANT` namespace`{tenant}::portal`
* var
* `src/lib/tenant.ts`
*/
ARCRUN_NAMESPACE?: string;
// Console 顯示品牌/實例名(Arcrun#21 rebrand,非機密)。只影響 UI 字樣(title/header/logo),
// 不影響任何行為。未設 → "Arcrun"console 是引擎共用件,不寫死產品名)。
// 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。
@@ -103,9 +124,8 @@ export type Bindings = {
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
ARCRUN_BUNDLE_VERSION?: string;
// ARCRUN_BUNDLE_VERSION 原本在這裡重複宣告了一次——TS2300 重複識別字,
// #106 順手併回上面那一處,說明同源,行為零變化。)
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
// 真相住在 mcp worker 的同名 envmcp/src/types.ts,預設 259200030 天);cypher 這份
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
@@ -0,0 +1,59 @@
/**
* component-loader`hash` Arcrun#91
*
* workflow `component: hash` component-loader step 1-6
* builtin / URL / hash-id / LOGIC_BINDING_MAP / recipe
* step 7 WASM_HTTP_RUNNER_IDS wasmWorkerUrl
* `hash` step 8 #29 `code`
*
*
* 1. `hash` runner throw
* 2. runner URL = arcrun-hash.{WORKER_SUBDOMAIN}.workers.dev
* hash.arcrun.dev self-hosted WORKER_SUBDOMAIN
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { env } from 'cloudflare:test';
import { createComponentLoader, wasmWorkerUrl } from '../src/lib/component-loader';
import type { Bindings } from '../src/types';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('component-loaderhash 零件解析(WASM HTTP runner 白名單,Arcrun#91', () => {
it('wasmWorkerUrl 通用推導:hash → arcrun-hash.{subdomain}.workers.devself-hosted 換 subdomain 即成立)', () => {
expect(wasmWorkerUrl('hash', 'uncle6-me')).toBe('https://arcrun-hash.uncle6-me.workers.dev');
expect(wasmWorkerUrl('hash', 'my-selfhosted-sub')).toBe('https://arcrun-hash.my-selfhosted-sub.workers.dev');
});
it('component: hash 能被解析成 runner,且 fetch 打 subdomain 推導的 URL', async () => {
const fakeEnv = {
...env,
WORKER_SUBDOMAIN: 'test-sub',
} as unknown as Bindings;
const loader = createComponentLoader(fakeEnv);
// 白名單漏 `hash` 時這裡就 throw「找不到零件 "hash"」
const runner = await loader('hash');
expect(typeof runner).toBe('function');
// stub fetch 捕 URL:證明 runner 打的是通用推導 URL,非寫死官方域名
const calledUrls: string[] = [];
const fetchSpy = vi.fn(async (url: unknown) => {
calledUrls.push(String(url));
return new Response(
JSON.stringify({
success: true,
data: { result: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', algorithm: 'sha256', encoding: 'hex' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
});
vi.stubGlobal('fetch', fetchSpy);
const result = await runner({ algorithm: 'sha256', input: 'hello', encoding: 'hex' }) as Record<string, unknown>;
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(calledUrls[0]).toBe('https://arcrun-hash.test-sub.workers.dev');
expect(result.success).toBe(true);
});
});
@@ -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();
});
});
+29
View File
@@ -26,4 +26,33 @@ describe('GET /health — bundle_version 欄位', () => {
expect(data.ok).toBe(true);
expect(data.bundle_version).toBe('2026-07-28/6d06162');
});
// Arcrun#106CLI 更新那條路會多烙一個 commit(版號=發行頻道編號,commit=真的部了哪份碼)。
it('有 ARCRUN_BUNDLE_COMMIT 時一起回(acr update 注入情境)', async () => {
const fakeEnv = {
ARCRUN_BUNDLE_VERSION: '1.4.41',
ARCRUN_BUNDLE_COMMIT: 'f87d0e92f49690253e7c89c5badc82a08eb5d21b',
} as unknown as Bindings;
const res = await healthRouter.fetch(
new Request('http://localhost/health'),
fakeEnv,
{} as ExecutionContext,
);
const data = await res.json() as { bundle_version: string; bundle_commit: string };
expect(data.bundle_version).toBe('1.4.41');
expect(data.bundle_commit).toBe('f87d0e92f49690253e7c89c5badc82a08eb5d21b');
});
// 安裝器那條路沒有這個 var(回歸:不能因為多了新欄位就讓舊路徑多吐一個空字串出來)。
it('沒 ARCRUN_BUNDLE_COMMIT 就省略該欄(安裝器路徑不受影響)', async () => {
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '1.4.41' } as unknown as Bindings;
const res = await healthRouter.fetch(
new Request('http://localhost/health'),
fakeEnv,
{} as ExecutionContext,
);
const data = await res.json() as { bundle_version: string; bundle_commit?: string };
expect(data.bundle_version).toBe('1.4.41');
expect(data.bundle_commit).toBeUndefined();
});
});
@@ -0,0 +1,261 @@
/**
* Arcrun#108
*
*
* 1. owner_id `knowledgeOwner(env)``ARCRUN_NAMESPACE` `CONSOLE_TENANT` 退
* TenantUnresolvedError** `|| 'leo'` **
* 2. `/portal/data/map` KBDBleo ARCRUN_NAMESPACE=bfezv28v
* `owner_id=bfezv28v` 9 `owner_id=leo` 0
* 3. **** KBDB ["kb"] kb
* 4. #100
* no_library_grant / filtered_out / scope_mismatch / confirmed_empty
* 5. ****design §3.3 /kbdb/*
*/
import { env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant';
import { portalDataRouter } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
const LEO_NS = 'bfezv28v';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
/**
* router env SELF.fetch`cloudflare:test` `env`
* SELF worker ARCRUN_BUNDLE_VERSION /health
* Hono router env
* handler KBDB fetch env
*/
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext;
async function seedSession(token: string, recordId: string) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
function mockGetRecord(recordId: string, libraries: string) {
fetchMock
.get(KBDB)
.intercept({ path: `/records/${recordId}`, method: 'GET' })
.reply(200, {
success: true,
record: {
record_id: recordId,
template_id: 'tpl_pu',
values: {
email: 'leo@example.com',
display_name: 'leo',
status: 'active',
role: 'admin',
password_hash: 'pbkdf2-sha256$600000$AA$BB',
libraries,
created_at: '2026-08-12T00:00:00.000Z',
updated_at: '2026-08-12T00:00:00.000Z',
},
},
});
}
/** 攔 `/map`,同時把「實際被查詢的 owner_id」記下來給斷言用。 */
function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) {
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/map')) return false;
seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? '');
return true;
},
method: 'GET',
})
.reply(200, { success: true, libraries, count: libraries.length });
}
function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) {
fetchMock
.get(KBDB)
.intercept({
path: (p: string) =>
p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''),
method: 'GET',
})
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] });
}
async function getMap(token: string, overrides: Partial<Bindings> = {}) {
const res = await portalDataRouter.fetch(
new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }),
{ ...env, ...overrides } as Bindings,
ctx,
);
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}
/** undici 的 path matcher 可能被呼叫多次 → 比對前先去重(我們在意的是「查了哪些 owner_id」)。 */
const distinct = (xs: string[]): string[] => [...new Set(xs)];
// ── ① 唯一產地的解析順序 ───────────────────────────────────────────────────────
describe('knowledgeOwner:租戶字串只有一個產地,且沒有靜默預設值', () => {
it('ARCRUN_NAMESPACE 優先(=acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值)', () => {
expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS);
});
it('沒注入 → 回退 CONSOLE_TENANT(官方 prod 與尚未 acr update 的實例,行為一字不變)', () => {
expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
});
it('空字串不算數(部署把 var 設成空字串 ≠ 有設定)', () => {
expect(knowledgeOwner({ ARCRUN_NAMESPACE: ' ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
});
it('兩個都沒有 → 丟 TenantUnresolvedError**不回 "leo"**(靜默預設值正是本票的病)', () => {
expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError);
});
it('帳號層 accountTenant 不受影響(改它會讓舊實例登不進去,所以刻意不動)', () => {
expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
expect(accountTenant({} as Bindings)).toBe('leo');
});
it('過濾片段只有兩種形狀:帶租戶的 ownerQuery,與明著喊全庫的普查', () => {
expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b');
expect(censusQueryAllTenants()).toBe('owner_id=');
});
});
// ── ② 地圖真的用那個 owner_id 去查 ─────────────────────────────────────────────
describe('GET /portal/data/map — leo 的情境(1854 條 → 看得到,不是 0 個庫)', () => {
it('注入 ARCRUN_NAMESPACE 後,KBDB 收到的 owner_id 是它,而且庫都回得來', async () => {
await seedSession('t-map-1', 'rec_leo');
mockGetRecord('rec_leo', '["*"]');
const seen: string[] = [];
mockMap(
[
{ library: 'kb', triplet_count: 1851 },
{ library: 'general', triplet_count: 3 },
],
seen,
);
const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS });
expect(status).toBe(200);
expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票:以前送出去的是 'leo'
expect(body.count).toBe(2);
expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count))
.toEqual([1851, 3]);
expect(body.empty_reason).toBeNull();
});
it('回應不含租戶字串(前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線)', async () => {
await seedSession('t-map-2', 'rec_leo2');
mockGetRecord('rec_leo2', '["*"]');
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS });
expect(JSON.stringify(body)).not.toContain(LEO_NS);
expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE');
});
it('沒注入時沿用 CONSOLE_TENANT(未跑 acr update 的實例行為不變,這次改動對它是惰性的)', async () => {
await seedSession('t-map-3', 'rec_leo3');
mockGetRecord('rec_leo3', '["*"]');
const seen: string[] = [];
mockMap([{ library: 'kb', triplet_count: 1 }], seen);
await getMap('t-map-3');
expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT
});
});
// ── ③ 權限沒有被拿掉(紅線:修這題不准把 owner_id 過濾或庫過濾拆掉)──────────────
describe('權限:只被授權部分庫的帳號,只看得到那幾個庫', () => {
it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => {
await seedSession('t-perm-1', 'rec_partial');
mockGetRecord('rec_partial', '["kb"]');
mockMap(
[
{ library: 'kb', triplet_count: 1851 },
{ library: 'finance', triplet_count: 42 },
{ library: 'general', triplet_count: 3 },
],
[],
);
const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS });
expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']);
expect(body.count).toBe(1);
});
it('一個庫都沒被授權 → 不打 KBDB,誠實說是權限問題', async () => {
await seedSession('t-perm-2', 'rec_nolib');
mockGetRecord('rec_nolib', '[]');
const { body } = await getMap('t-perm-2'); // 沒有 mockMap:打了就會 assertNoPendingInterceptors 失敗
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_library_grant');
expect(body.empty_confirmed).toBe(true);
});
it('實例有庫但都不在權限內 → filtered_out(是隔離正常,不是資料不見)', async () => {
await seedSession('t-perm-3', 'rec_other');
mockGetRecord('rec_other', '["finance"]');
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS });
expect(body.empty_reason).toBe('filtered_out');
expect(body.empty_confirmed).toBe(true);
});
});
// ── ④ 空地圖的四種成因分得出來(不再把設定錯誤畫成「你沒有資料」)────────────────
describe('空地圖:分得出「讀不到」與「沒有」', () => {
it('命名空間對不上(本租戶 0、整台實例有)→ scope_mismatch,並指出該跑 acr update', async () => {
await seedSession('t-empty-1', 'rec_e1');
mockGetRecord('rec_e1', '["*"]');
mockMap([], []);
mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0
mockTripletStats((o) => o === '', 1854); // 全庫普查:有 1854 條
const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' });
expect(body.empty_reason).toBe('scope_mismatch');
expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」
expect(body.instance_triplet_count).toBe(1854);
expect(String(body.note)).toContain('acr update');
expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串
});
it('整台實例真的空 → confirmed_empty(此時、也只有此時,才准說「還沒有內容」)', async () => {
await seedSession('t-empty-2', 'rec_e2');
mockGetRecord('rec_e2', '["*"]');
mockMap([], []);
mockTripletStats((o) => o === 'leo', 0);
mockTripletStats((o) => o === '', 0);
const { body } = await getMap('t-empty-2');
expect(body.empty_reason).toBe('confirmed_empty');
expect(body.empty_confirmed).toBe(true);
});
it('連統計都讀不到 → unreadable(不假裝是空庫)', async () => {
await seedSession('t-empty-3', 'rec_e3');
mockGetRecord('rec_e3', '["*"]');
mockMap([], []);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(500, { error: 'boom' });
const { body } = await getMap('t-empty-3');
expect(body.empty_reason).toBe('unreadable');
expect(body.empty_confirmed).toBe(false);
});
});
+296
View File
@@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
});
});
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
//
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
describe('藏書地圖 /portal/data/mapMCP 走的那條)', () => {
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
await seedSession('tok-m1', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
],
count: 2,
});
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
expect(data.count).toBe(1);
});
it('["*"] 全庫 → 全部庫都回', async () => {
await seedSession('tok-m2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
],
count: 2,
});
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
const data = (await res.json()) as { libraries: { library: string }[] };
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
});
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
await seedSession('tok-m3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
expect(res.status).toBe(200);
const data = (await res.json()) as { count: number; note?: string };
expect(data.count).toBe(0);
expect(data.note).toContain('尚未被授權');
});
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
await seedSession('tok-m4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
await seedSession('tok-m5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
expect(res.status).toBe(200);
});
it('未登入 → 401', async () => {
expect((await get('/portal/data/map')).status).toBe(401);
});
});
describe('結構化資料 /portal/data/records、/portal/data/templatesMCP 走的那條)', () => {
it('by-templateserver 注入 owner_idcaller 自帶的被靜默覆蓋(繞不過)', async () => {
await seedSession('tok-r1', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
let captured = '';
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/records/by-template/contact')) return false;
captured = p;
return true;
},
method: 'GET',
})
.reply(200, { success: true, records: [], count: 0 });
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
Authorization: 'Bearer tok-r1',
});
expect(res.status).toBe(200);
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
});
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
await seedSession('tok-r2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, {
success: true,
records: [
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
],
count: 3,
});
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
const data = (await res.json()) as { records: { record_id: string }[] };
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
});
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
await seedSession('tok-r3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_other', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
await seedSession('tok-r4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_hr', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
await seedSession('tok-r5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_fin', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
});
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
await seedSession('tok-r6', 'rec_6');
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
let body: Record<string, unknown> = {};
fetchMock
.get(KBDB)
.intercept({
path: '/records',
method: 'POST',
body: (b: string) => {
body = JSON.parse(b) as Record<string, unknown>;
return true;
},
})
.reply(200, { success: true, record: { record_id: 'r_new' } });
const res = await SELF.fetch('http://localhost/portal/data/records', {
method: 'POST',
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
});
expect(res.status).toBe(200);
expect(body.owner_id).toBe(TENANT);
});
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
await seedSession('tok-r7', 'rec_7');
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
const res = await SELF.fetch('http://localhost/portal/data/records', {
method: 'POST',
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
});
expect(res.status).toBe(403);
});
it('templates 全域共享(schema 非內容):登入即可列', async () => {
await seedSession('tok-t1', 'rec_t1');
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/templates', method: 'GET' })
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
expect(res.status).toBe(200);
expect(((await res.json()) as { count: number }).count).toBe(1);
});
it('未登入 → 401records / templates 都是)', async () => {
expect((await get('/portal/data/templates')).status).toBe(401);
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
expect((await get('/portal/data/records/r1')).status).toBe(401);
});
});
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
describe('GET /portal/data/graph/neighbors/:nameD-4 粗閘)', () => {
@@ -942,3 +1150,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('三元組讀取失敗');
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* ****Arcrun#108
*
*
* CLI exit 1hook exit 2
*
*
*
* src/ `?raw`
*/
import { describe, it, expect } from 'vitest';
// @ts-expect-error -- 純規則模組(.mjs,零 node 相依),型別非本檔關注重點
import { scanSource, TENANT_SOURCE_FILE } from '../scripts/tenant-source-rules.mjs';
// @ts-expect-error -- vite ?rawbuild-time 讀檔,runtime 是純字串(Workers 沒有 node:fs
import tenantLibSource from '../src/lib/tenant.ts?raw';
// @ts-expect-error -- 同上
import portalDataSource from '../src/routes/portal-data.ts?raw';
// @ts-expect-error -- 同上
import portalSource from '../src/routes/portal.ts?raw';
// @ts-expect-error -- 同上
import consoleAuthSource from '../src/routes/console-auth.ts?raw';
// @ts-expect-error -- 同上
import consoleDashboardSource from '../src/routes/console-dashboard.ts?raw';
type Violation = { rule: string; line: number; text: string; message: string };
const scan = (path: string, text: string): Violation[] => scanSource(path, text) as Violation[];
const rulesOf = (v: Violation[]): string[] => [...new Set(v.map((x) => x.rule))].sort();
const FILE = 'src/routes/portal-data.ts';
describe('閘會擋:三種「靜態租戶字串進資料面」的真實形狀', () => {
it('T1 — 在 tenant.ts 以外讀租戶環境變數(#105/#108 的原句)', () => {
const bad = `export function portalTenant(env: Bindings): string {\n return env.CONSOLE_TENANT || 'leo';\n}`;
const v = scan('src/routes/portal.ts', bad);
expect(rulesOf(v)).toContain('T1');
expect(v[0].message).toContain('knowledgeOwner');
});
it('T1 — `c.env.ARCRUN_NAMESPACE` 也一樣(換一個變數名不是換一個做法)', () => {
const v = scan('src/routes/console-dashboard.ts', `const t = c.env.ARCRUN_NAMESPACE || 'leo';`);
expect(rulesOf(v)).toEqual(['T1']);
});
it('T2 — 繞過唯一產地自己 cast 一個 TenantId', () => {
const v = scan(FILE, `const tenant = (c.env.SOMETHING ?? '') as TenantId;`);
expect(rulesOf(v)).toContain('T2');
});
it('T2 — 連在 tenant.ts 裡都不准把「字面字串」當成租戶識別(那就是 `|| "leo"` 的原形)', () => {
const v = scan(TENANT_SOURCE_FILE, ` return 'leo' as TenantId;`);
expect(rulesOf(v)).toEqual(['T2']);
expect(v[0].message).toContain('TenantUnresolvedError');
});
it('T3 — 拿帳號層字串去組知識資料面的 owner_id(#108 那一行,逐字)', () => {
const bad = " const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);";
const v = scan(FILE, bad);
expect(rulesOf(v)).toContain('T3');
expect(v[0].message).toContain('1854');
});
it('T3 — 換成物件屬性寫法一樣擋(`owner_id: portalTenant(c.env)`', () => {
const v = scan(FILE, ` body: JSON.stringify({ values, owner_id: portalTenant(c.env) }),`);
expect(rulesOf(v)).toContain('T3');
});
it('T3 — accountTenant() 這個新名字也擋(規則盯的是「這是帳號層的值」,不是某個函式名字的拼法)', () => {
const v = scan(FILE, " kbdbFetch(env, `/entries?owner_id=${accountTenant(env)}`);");
expect(rulesOf(v)).toContain('T3');
});
});
describe('閘不誤攔:長得像、但沒有在做那件事的合法寫法', () => {
const legit: [string, string, string][] = [
['讀取別人回傳的 owner_id(不是在組過濾)', FILE, ` if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);`],
['型別宣告裡的 owner_id 欄位', FILE, ` | { record?: { values?: Record<string, unknown>; owner_id?: string | null } }`],
['走唯一入口組過濾', FILE, " const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);"],
['走唯一入口填 body', FILE, ` body: JSON.stringify({ template, values, owner_id: ownerField(tenant) }),`],
['帳號子 namespace 的過濾(`{tenant}::portal`,那是 cypher 自己寫的資料)', 'src/routes/portal.ts', ` const res = await kbdbFetch(env, \`/records/by-template/x?owner_id=\${encodeURIComponent(ns)}\`);`],
['請求自帶的租戶(webhooks-named 慣例:呼叫端就是租戶)', 'src/routes/webhooks-named.ts', ` owner_id: apiKey,`],
['註解裡整句在講 CONSOLE_TENANT 與 owner_id(文件不是行為)', FILE, `// 之前的病:owner_id 拿 env.CONSOLE_TENANTportalTenant(c.env) 那條路整個空掉`],
['JSDoc 區塊裡出現同樣的字', FILE, ` * 舊寫法 owner_id=\${portalTenant(env)} 已廢除,改走 knowledgeOwner。`],
['行末註解裡出現(程式碼本身乾淨)', FILE, ` const tenant = knowledgeOwner(c.env); // 不是 portalTenant(c.env),也不是 owner_id=leo`],
['tenant.ts 自己讀環境變數(它就是唯一產地)', TENANT_SOURCE_FILE, ` const injected = (env.ARCRUN_NAMESPACE ?? '').trim();`],
['types.ts 只宣告型別不取值', 'src/types.ts', ` CONSOLE_TENANT?: string;`],
];
for (const [name, path, line] of legit) {
it(`零誤攔:${name}`, () => {
expect(scan(path, line)).toEqual([]);
});
}
});
describe('回歸:現行原始碼是乾淨的(讀真檔,不是讀我編的字串)', () => {
const files: [string, string][] = [
[TENANT_SOURCE_FILE, tenantLibSource as string],
['src/routes/portal-data.ts', portalDataSource as string],
['src/routes/portal.ts', portalSource as string],
['src/routes/console-auth.ts', consoleAuthSource as string],
['src/routes/console-dashboard.ts', consoleDashboardSource as string],
];
for (const [path, text] of files) {
it(`${path} 零違規`, () => {
expect(scan(path, text)).toEqual([]);
});
}
it('唯一產地本身沒有字面預設值(knowledgeOwner 解析不到要用丟的,不是回 "leo"', () => {
const body = (tenantLibSource as string).slice(
(tenantLibSource as string).indexOf('export function knowledgeOwner'),
(tenantLibSource as string).indexOf('export function tenantFromApiKey'),
);
expect(body).toContain('TenantUnresolvedError');
// 解析路徑只准回 env 讀到的值;任何 `|| '...'` / `?? '...'` 形式的字面 fallback 都是本票的病本身
expect(body).not.toMatch(/(\|\||\?\?)\s*['"][^'"]+['"]/);
});
});
+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() 只看這兩項
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
+13
View File
@@ -138,6 +138,19 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
CONSOLE_TENANT = "leo"
# 這台實例的**知識命名空間**(Arcrun#108)=知識資料(三元組/卡片/藏書地圖/工作流 KV)
# 實際掛在哪個 owner_id 底下。**這裡刻意不寫死**:官方 prod 的知識確實在 `CONSOLE_TENANT`
# (leo)底下,未設就沿用它,行為一字不變。
#
# self-hosted 實例由 `acr update` 自動注入(值=你 `~/.arcrun/config.yaml` 的 `api_key`
# 也就是 CLI push 工作流、小幫手上傳知識、MCP 查詢用的同一個 namespace),
# 而且**只在確認那個 namespace 底下真的查得到知識時才寫**(見 cli/src/lib/deploy.ts
# namespaceHasKnowledge)——避免把一台原本正常的實例指向空的那一格。
#
# 為什麼要跟 CONSOLE_TENANT 分開:CONSOLE_TENANT 同時是帳號子 namespace`{tenant}::portal`
# 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。
# ARCRUN_NAMESPACE = "your-namespace"
# Portal session TTL 秒數(portal-auth P2#24/#25routes/portal.ts)。預設 7 天(604800)——
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL = "604800"
@@ -0,0 +1,25 @@
-- credential template seedD38 圍牆修復,總管交辦,2026-08-07)
-- SDD:無專屬 SDDD38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
--
-- D38 鐵律(leo 2026-06-14 立、2026-08-07 擴大):KBDB 三張表打天下,永遠不加新表;
-- 新資料類型一律用 template + entries,同 0003_library_map.sql / 0004_execution_log_template.sql
-- 的手法——對 templates 表 INSERT OR IGNORE 一列定義,不建新表、不動既有表的結構。
--
-- 這是「credential 目錄」的第二個家:原本 0002_credentials.sql 在 KBDB 裡多開了一張
-- 獨立表(違規,見 kbdb-usage skill「反例」),本檔 + 0006_drop_credentials_table.sql
-- 把它改回三張表的形狀——一筆 credentialentries 表一列(entry_type='credential'
-- page_name=name 當冪等鍵,owner_id=api_key 做租戶隔離,其餘欄位打包進 metadata_json),
-- 儲存精神比照既有 recipe_stat / execution_logtemplate 只負責文件化,實際資料不走
-- entry_values 全展開的多列 record)。
--
-- 密文本體不在這裡:值仍住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理
-- API 唯寫,D19「擁有目錄,不擁有內容物」不變)。這張 template 定義的 slots 全部是目錄
-- 欄位,零密文——與舊 0002_credentials.sql 的欄位定義一字不變,只是換了個家。
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
VALUES (
'tpl-credential',
'credential',
'credential 目錄(D38 圍牆修復:改走 entries 表 entry_type=credential,取代舊 credentials 表;零密文,密文本體住 Workers per-script Secrets',
'["name","service","sensitivity","secret_ref","last_used_at"]',
'system'
);
@@ -0,0 +1,47 @@
-- 退役 credentials 表(D38 圍牆修復,總管交辦,2026-08-07)
-- SDD:無專屬 SDDD38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
--
-- 這是本次唯一真的需要動表結構的一支 migration,理由(不是繞過鐵律,是鐵律要求的收尾):
-- D38 要求 KBDB 回到「只有三張核心表」的狀態。0002_credentials.sql 當初在 KBDB 裡多開了
-- 一張獨立表,是已知違規(kbdb-usage skill 明文列為反例)。要把違規清乾淨,唯一辦法就是
-- 真的把那張表拆掉——拆表本身不能只用 API 做(API 不提供「拆表」這種牆內維運操作,
-- 也不該提供),所以下面兩句 SQL 標 kbdb-sql-ok:這不是繞過圍牆去存取資料,是圍牆施工
-- 本身(kbdb/migrations/ 就是牆內,本檔存在的唯一目的就是讓舊表退場)。
--
-- 冪等設計(deploy.ts 每次部署都會重跑這支檔案,沒有 migration 追蹤表):
-- 1. 先補一份空表存在保底——self-hosted 各實例套用進度不一,有些從沒跑過 0002(表從不
-- 存在)、有些已經跑過本檔一次(表已被拆)。沒有這一步,下面的搬資料/退場語句會因表
-- 不存在直接整支失敗(D1 對不存在的表沒有條件式跳過語法)。
-- 2. 把舊表裡「entries 還沒有對應列」的 row 搬進 entriesentry_type='credential'
-- page_name=name 冪等鍵,owner_id=api_key,其餘欄位打包進 metadata_json,欄位對應
-- 0005_credential_template.sql 定義的 slots)。NOT EXISTS 判斷防止重跑造成重複列。
-- 3. 搬完資料後表就沒有存在的理由,最後一步讓它退場。下次部署若又被步驟 1 重新墊一份
-- 空殼,也只是空表、立刻搬 0 筆、立刻退場,不影響任何人(真資料只會被搬一次,因為
-- 步驟 2 的判斷是看 entries 裡有沒有,不是看這是不是第一次跑)。
CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 表退場施工步驟①保底存在,非資料存取違規,理由見檔頭
api_key TEXT NOT NULL,
name TEXT NOT NULL,
service TEXT,
sensitivity TEXT NOT NULL DEFAULT 'standard',
secret_ref TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
PRIMARY KEY (api_key, name)
);
INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at)
SELECT
'e_cred_' || lower(hex(randomblob(8))),
'credential',
c.api_key,
c.name,
json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at),
c.created_at,
unixepoch()
FROM credentials c
WHERE NOT EXISTS (
SELECT 1 FROM entries e
WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name
);
DROP TABLE IF EXISTS credentials; -- kbdb-sql-ok: 表退場施工步驟③讓舊表退場,非資料存取違規,理由見檔頭
+17 -7
View File
@@ -65,6 +65,13 @@ export interface RecordResult {
record_id: string;
template_id: string;
values: Record<string, string>;
/**
* record slot entries owner_idcreateRecord
* 2026-08-12 `GET /records/:id` ****
* id cypher portal /AI
* 404 null
*/
owner_id: string | null;
}
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
@@ -85,7 +92,7 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
.run();
}
return { record_id: recordId, template_id: tpl.id, values: input.values };
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
}
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
@@ -147,17 +154,19 @@ export async function updateRecord(
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
const res = await db
.prepare(
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id = ?`,
)
.bind(recordId)
.all<{ slot: string; content: string; template_id: string }>();
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
const rows = res.results ?? [];
if (rows.length === 0) return null;
const values: Record<string, string> = {};
for (const r of rows) values[r.slot] = r.content;
return { record_id: recordId, template_id: rows[0].template_id, values };
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
}
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
@@ -192,19 +201,20 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
const placeholders = chunk.map(() => '?').join(',');
const evRes = await db
.prepare(
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id IN (${placeholders})`,
)
.bind(...chunk)
.all<{ record_id: string; slot: string; content: string; template_id: string }>();
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
for (const r of evRes.results ?? []) {
let rec = byId.get(r.record_id);
if (!rec) {
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
byId.set(r.record_id, rec);
}
rec.values[r.slot] = r.content;
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
}
}
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
+16 -3
View File
@@ -1,13 +1,25 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { Env } from "./types.js";
import { partnerAuthMiddleware } from "./middleware/partner-auth.js";
import { partnerAuthMiddleware, type AuthPath } from "./middleware/partner-auth.js";
import { handleMcpRequest } from "./mcp-handler.js";
import { resolveKnowledgeIdentity } from "./lib/portal-client.js";
import type { PortalIdentity } from "./oauth/store.js";
import { inspectorHtml } from "./pages/inspector.js";
import { kbdbFetch } from "./lib/kbdb-client.js";
import { registerOAuthRoutes } from "./oauth/routes.js";
const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
const _app = new Hono<{
Bindings: Env;
Variables: {
org_namespace: string;
partner_token: string;
// 登入者身分(以帳密走 OAuth 連進來時才有)+ 這條連線是哪種憑據。
// 知識面工具(kbdb_*)據此決定走 portal 資料面還是既有 KBDB 直連(見 lib/portal-client.ts)。
portal?: PortalIdentity;
auth_path: AuthPath;
};
}>();
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
@@ -261,7 +273,8 @@ app.options("/mcp", (c) => {
app.post("/", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
const partnerToken = c.get("partner_token");
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
});
// 輸出根 app_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
+34 -6
View File
@@ -19,6 +19,7 @@
import type { Env } from "../types.js";
import { kbdbFetch } from "./kbdb-client.js";
import { portalFetch, type KnowledgeIdentity } from "./portal-client.js";
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
export interface LibraryMapRow {
@@ -86,25 +87,45 @@ const MAP_FETCH_TIMEOUT_MS = 1500;
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
const CACHE_TTL_FAIL_MS = 60 * 1000;
let instructionsCache: { text: string | null; expiresAt: number } | null = null;
/**
* 2026-08-12
*
* entity
*
*
*/
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
export function __resetLibraryMapInstructionsCacheForTests(): void {
instructionsCache = null;
instructionsCache.clear();
}
/**
* MCP server instructions design §4 / §6session instructions
* push /HTTP // JSON nullcaller
*
* identity.kind === 'portal' cypher `/portal/data/map`
* KBDB `/map` tokenstale
*/
export async function buildLibraryMapInstructions(env: Env): Promise<string | null> {
export async function buildLibraryMapInstructions(
env: Env,
identity: KnowledgeIdentity,
): Promise<string | null> {
if (identity.kind === "stale") return null;
// 快取 keyportal 用 session(=這個人這次登入),service 用固定字串。
// session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。
const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
const now = Date.now();
if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text;
const hit = instructionsCache.get(cacheKey);
if (hit && hit.expiresAt > now) return hit.text;
let text: string | null = null;
try {
const res = await Promise.race([
kbdbFetch(env, "/map"),
identity.kind === "portal"
? portalFetch(env, identity.portal.session, "/portal/data/map")
: kbdbFetch(env, "/map"),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
),
@@ -124,6 +145,13 @@ export async function buildLibraryMapInstructions(env: Env): Promise<string | nu
text = null;
}
instructionsCache = { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS) };
instructionsCache.set(cacheKey, {
text,
expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
});
// isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。
if (instructionsCache.size > 64) {
for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
}
return text;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Portal client AI西
*
* leo 2026-08-12 Portal 西
* AI MCP AI西
* MCP ****
*
* **portal session token** cypher
* portal
* cypher `/portal/data/*` server
* rule 07 API
*
* CYPHER_EXECUTOR service binding binding
*/
import type { Env } from "../types.js";
import type { PortalIdentity } from "../oauth/store.js";
import { errorResponse } from "./cypher-client.js";
export interface PortalCallOpts {
method?: string;
body?: unknown;
query?: Record<string, string | number | undefined>;
}
/** 用登入者的 session 打 cypher 的 portal 資料面。 */
export async function portalFetch(
env: Env,
session: string,
path: string,
opts: PortalCallOpts = {},
): Promise<Response> {
if (!env.CYPHER_EXECUTOR) {
throw new Error("CYPHER_EXECUTOR service binding not configured");
}
const url = new URL(`https://cypher${path}`);
for (const [k, v] of Object.entries(opts.query ?? {})) {
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
}
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
method: opts.method ?? "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session}`,
},
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
}
/**
*
*
* leo
* - portal portal
* - service static token / partner key KBDB
* - stale OAuth token token ****
* 退
*/
export type KnowledgeIdentity =
| { kind: "portal"; portal: PortalIdentity }
| { kind: "service" }
| { kind: "stale" };
export function resolveKnowledgeIdentity(
authPath: "oauth" | "service",
portal: PortalIdentity | undefined,
): KnowledgeIdentity {
if (authPath !== "oauth") return { kind: "service" };
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
}
/** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */
export function staleIdentityError() {
return errorResponse(
"identity_missing",
"這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" +
"重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" +
"不需要另外找任何 credential 或金鑰。",
[
"到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)",
"重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢",
],
);
}
/**
* portal AI
* 401/403 ****
* AI 使
*/
export async function portalError(res: Response, what: string) {
const detail = await res.text().catch(() => "");
if (res.status === 401) {
return errorResponse(
"session_expired",
`${what}失敗:登入階段已過期(portal session 到期或已登出)。`,
[
"到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)",
"重連後權限與你在 portal 網頁上看到的一致",
],
detail,
);
}
if (res.status === 403) {
return errorResponse(
"forbidden",
`${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`,
["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"],
detail,
);
}
return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status}`, ["稍後重試"], detail);
}
+8 -2
View File
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { registerAllTools } from "./tools/registry.js";
import { buildLibraryMapInstructions } from "./lib/library-map.js";
import type { KnowledgeIdentity } from "./lib/portal-client.js";
import { Env } from "./types.js";
export async function handleMcpRequest(
@@ -9,11 +10,16 @@ export async function handleMcpRequest(
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
): Promise<Response> {
// library-map SDD M4design §4/§6):連線時把全館藏書地圖嵌進 server instructions
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeoutisolate TTL 快取
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
const mapInstructions = await buildLibraryMapInstructions(env);
//
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
// 快取也因此改成 per-session key(見 lib/library-map.ts)。
const mapInstructions = await buildLibraryMapInstructions(env, identity);
// 2026-07-30leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
// 如果不會,要寫什麼在外面讓它一聽到就知道?」):
@@ -60,7 +66,7 @@ export async function handleMcpRequest(
{ instructions },
);
registerAllTools(server, env, orgNamespace, partnerToken);
registerAllTools(server, env, orgNamespace, partnerToken, identity);
await server.connect(transport);
return transport.handleRequest(request);
+28 -2
View File
@@ -1,9 +1,19 @@
import { Context, Next } from "hono";
import { Env } from "../types.js";
import { getAccessToken } from "../oauth/store.js";
import { getAccessToken, type PortalIdentity } from "../oauth/store.js";
import { constantTimeEqual } from "../oauth/crypto.js";
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
/**
* ****kbdb_*
* - "oauth" Portal portal session portal
* portal
* - "service"static token / partner key ****
* KBDB
* token
*/
export type AuthPath = "oauth" | "service";
/**
* MCP / GUI
*
@@ -19,7 +29,15 @@ import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.
* ALLOW_PLAINTEXT_NAMESPACE="true"
*/
export async function partnerAuthMiddleware(
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
c: Context<{
Bindings: Env;
Variables: {
org_namespace: string;
partner_token: string;
portal?: PortalIdentity;
auth_path: AuthPath;
};
}>,
next: Next
) {
const origin = originOf(c.req.url);
@@ -50,6 +68,11 @@ export async function partnerAuthMiddleware(
}
c.set("org_namespace", at.namespace);
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
// 登入者的身分(2026-08-12):知識面工具(kbdb_*)帶著它打 cypher 的 portal 資料面,
// 權限與這個人在 portal 網頁上看到的完全一致。舊 token 沒有這欄 → undefined
// 知識面工具會要求重新連線(不偷偷退回服務金鑰那條老路)。
c.set("portal", at.portal);
c.set("auth_path", "oauth");
await next();
return;
}
@@ -60,6 +83,7 @@ export async function partnerAuthMiddleware(
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
c.set("org_namespace", ns);
c.set("partner_token", ns);
c.set("auth_path", "service");
await next();
return;
}
@@ -79,6 +103,7 @@ export async function partnerAuthMiddleware(
}
c.set("org_namespace", info.org_namespace);
c.set("partner_token", token);
c.set("auth_path", "service");
await next();
return;
}
@@ -89,6 +114,7 @@ export async function partnerAuthMiddleware(
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
c.set("org_namespace", token);
c.set("partner_token", token);
c.set("auth_path", "service");
await next();
return;
}
+56 -6
View File
@@ -14,6 +14,7 @@ import {
consumeAuthCode,
putAccessToken,
AUTH_CODE_TTL_SECONDS,
type PortalIdentity,
} from "./store.js";
import {
originOf,
@@ -34,10 +35,25 @@ const CORS_JSON = {
"Cache-Control": "no-store",
} as const;
function ownerNamespace(env: Env): string {
/**
* ****arcrun_* kbdb_*
* portal session store.ts PortalIdentity
*
* cypher workflow API opaque key
* X-Arcrun-API-Key portal session cypher
* portal session workflow
* arcrun_* ****
*
* "leo" ****
* namespace KBDB owner_id API key
*/
function workflowTenant(env: Env): string {
return env.MCP_OWNER_NAMESPACE || "leo";
}
/** portal session TTL 讀不到時的保守假設(秒):短的那邊贏,寧可早點要求重連。 */
const FALLBACK_PORTAL_SESSION_TTL = 604800; // 7 天(cypher portal.ts 的預設值)
function tokenTtl(env: Env): number {
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
@@ -248,7 +264,13 @@ export function registerOAuthRoutes<
}
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
// 走 service bindingMCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
let loginOk = false;
//
// 🔴 2026-08-12leo:「用登入能做的 mcp 就應該能做,結果要你去打 MCP 時自己找
// credential 問題很大」):這裡**接住登入回來的身分**,不再只留 `res.ok`。
// 舊版把身分丟掉 ⇒ 查詢時無身分可帶 ⇒ 只好去撈服務內部金鑰(KBDB_INTERNAL_TOKEN
// 直打 KBDB ⇒ 繞過所有庫過濾、而且不管誰登入都看到同一格。根因就在這幾行。
let portal: PortalIdentity | null = null;
let portalTtl = FALLBACK_PORTAL_SESSION_TTL;
try {
const res = await c.env.CYPHER_EXECUTOR.fetch(
new Request("https://cypher/portal/login", {
@@ -257,11 +279,34 @@ export function registerOAuthRoutes<
body: JSON.stringify({ email, password }),
}),
);
loginOk = res.ok;
if (res.ok) {
const body = (await res.json().catch(() => null)) as {
session_token?: unknown;
display_name?: unknown;
role?: unknown;
libraries?: unknown;
session_expires_in?: unknown;
} | null;
const session = typeof body?.session_token === "string" ? body.session_token : "";
if (session) {
portal = {
session,
display_name: typeof body?.display_name === "string" ? body.display_name : "",
role: typeof body?.role === "string" ? body.role : "user",
libraries: Array.isArray(body?.libraries)
? body.libraries.filter((x): x is string => typeof x === "string")
: [],
};
const ttl = Number(body?.session_expires_in);
if (Number.isFinite(ttl) && ttl > 0) portalTtl = ttl;
}
}
} catch {
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
}
if (!loginOk) {
if (!portal) {
// 帳密不對,或這台 cypher 舊到還不回 session_token。兩者都不可以發碼——
// 發了也是一張沒有身分的 token,查什麼都得再找一次 credential,正是要修的病。
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401);
}
if (!c.env.OAUTH_KV) {
@@ -275,7 +320,9 @@ export function registerOAuthRoutes<
code_challenge_method: "S256",
scope: consent.scope,
resource: consent.resource,
namespace: ownerNamespace(c.env),
namespace: workflowTenant(c.env),
portal,
portal_session_expires_in: portalTtl,
});
const location = redirectWith(redirectUri, {
code,
@@ -318,7 +365,9 @@ export function registerOAuthRoutes<
return err("invalid_grant", "PKCE verification failed");
}
const ttl = tokenTtl(c.env);
// token 活不過它底下的 portal session:否則第 8 天會出現「MCP 還連著、卻什麼都查不到」
// ——使用者看到的是壞掉,實際是身分過期。兩者一起到期,重連就是重新輸入帳密,一次搞定。
const ttl = Math.min(tokenTtl(c.env), data.portal_session_expires_in || FALLBACK_PORTAL_SESSION_TTL);
const accessToken = randomToken(32);
await putAccessToken(
c.env.OAUTH_KV,
@@ -327,6 +376,7 @@ export function registerOAuthRoutes<
namespace: data.namespace,
client_id: data.client_id,
scope: data.scope,
portal: data.portal,
// RFC 8707aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
aud: resourceUri(originOf(c.req.url)),
+31
View File
@@ -4,6 +4,28 @@
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
import { sha256Hex } from "./crypto.js";
/**
* authorize token
*
* leo 2026-08-12 MCP
* ****
*
* `session` cypher `/portal/login` portal session token portal
* KV TTL
* access_token TTL routes.ts
* MCP session
*
* display_name / role / libraries ****arcrun_whoami
* cypher user record
* token
*/
export interface PortalIdentity {
session: string;
display_name: string;
role: string;
libraries: string[];
}
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
export interface AuthCodeData {
client_id: string;
@@ -15,6 +37,10 @@ export interface AuthCodeData {
resource: string;
/** 換發後 token 綁定的資料分區(owner namespace)。 */
namespace: string;
/** 這張 code 是誰換的(帳密驗過的那個人)。 */
portal: PortalIdentity;
/** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */
portal_session_expires_in: number;
}
/** access token 綁定的資料。 */
@@ -26,6 +52,11 @@ export interface AccessTokenData {
aud: string;
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
exp: number;
/**
* token ** token** undefined
* 退fail-closed
*/
portal?: PortalIdentity;
}
const CODE_PREFIX = "oauth:code:";
+57 -13
View File
@@ -5,31 +5,75 @@
* AI CLI acr whoamiMCP rule 07 §5
* AI MCP curl
*
* MCP orgNamespace+ cypher binding
* 2026-08-12 ****display_name / role /
* account_namespace 西
* /kbdb/*portal-data.ts
* arcrun_* API key server
*
* MCP
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { toolName } from "../brand.js";
import { Env } from "../types.js";
import type { KnowledgeIdentity } from "../lib/portal-client.js";
export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) {
export function registerWhoami(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
server.tool(
toolName("whoami"),
"回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" +
"部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
"回報這個 MCP 連線目前生效的身份:以帳密連線時回「登入的是誰、能看哪些知識庫」;" +
"服務級 token 連線時回綁定的帳號 namespace。部署 / 觸發 / 查 workflow 前先 call 此 tool 確認身份," +
"**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
{},
async () => {
// 薄殼:MCP 透過 service bindingCYPHER_EXECUTOR)連 cypherbinding 本身決定連哪台;
// 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。
const identity = {
account_namespace: orgNamespace || "(未設)",
const base = {
cypher: "service-binding:CYPHER_EXECUTOR",
kbdb: "service-binding:KBDB",
note:
"此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
};
return {
content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }],
};
if (identity.kind === "portal") {
const { display_name, role, libraries } = identity.portal;
return json({
...base,
auth: "portal-login(這條連線是有人輸入 Portal 帳密授權的)",
logged_in_as: display_name || "(未設顯示名稱)",
role,
libraries: libraries.length ? libraries : ["(尚未被授權任何知識庫)"],
knowledge_scope:
libraries.includes("*")
? "全部知識庫(此帳號有全庫權限)"
: `僅限上列知識庫——kbdb_* 查得到的東西與這個帳號在 portal 網頁上看得到的完全一致`,
note:
"你是「主人授權的 AI」:主人查得到的你查得到,主人查不到的你也查不到。" +
"kbdb_* 不需要任何額外的 credential / 金鑰 / kbdb_base——已經登入過了,不會再問第二次。",
});
}
if (identity.kind === "stale") {
return json({
...base,
auth: "舊版 token(沒有登入者身分)",
knowledge_scope: "查不到任何知識內容",
note:
"這條連線是本次改版前簽發的 token。到 claude.ai → Settings → Connectors " +
"重新連線一次(輸入 Portal 帳密)即可恢復,不需要找任何 credential。",
});
}
return json({
...base,
auth: "service tokenstatic token / partner key,代表整個實例或租戶,不是某個人)",
account_namespace: orgNamespace || "(未設)",
note: "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
});
},
);
}
function json(obj: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(obj, null, 2) }] };
}
+148 -59
View File
@@ -1,23 +1,27 @@
/**
* KBDB MCP kbdb-base Phase 9.1HANDOFF §2
*
* rule 07 §5 APIMCP +
* kbdbFetchKBDB service binding HTTP APIkbdb/src/routes/*
* rule 07 §5 APIMCP +
*
* 2026-08-12
* leo Portal 西AI
* MCP AI西
* MCP
*
* MCP ****
* KBDB_INTERNAL_TOKEN KBDB
*
* identity.kind === 'portal' portal session cypher
* `/portal/data/*` server portal
* ****MCP
*
* static token / partner keyidentity.kind === 'service' KBDB
*
*
* KBDB leo 2026-06-14 DECISION-kbdb-v3-baseplane.md
* - ** / SQL tool**
* - AI templatename+slots+ recordslotcontent
* Supabase schema template/slot CREATE TABLE
* - 調 HTTP API D1 SQL
*
* API kbdb/src/routes
* POST /templates { name, slots[], description?, created_by? } { template }
* GET /templates { templates[], count }
* GET /templates/:idOrName { template }
* POST /records { template, values:{slot:content}, owner_id? } { record }
* GET /records/by-template/:t ?owner_id= { records[], count }
* GET /records/:recordId { record }
* GET /entries/search ?q=&owner_id= { entries[], count, mode:'keyword' }
* - AI templatename+slots+ recordslotcontent
* - 調 HTTP API D1 SQL
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -25,22 +29,32 @@ import { z } from "zod";
import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/** 走 portal 資料面時,呼叫端傳的 owner_id 一律無效(server 用登入者的歸屬)——如實告訴 AI。 */
const OWNER_IGNORED_HINT =
"owner_id 在登入身分下不生效:查詢範圍由你的帳號權限決定(與你在 portal 網頁看到的一致)";
/** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */
export function registerAllKbdbDataTools(server: McpServer, env: Env) {
registerCreateTemplate(server, env);
registerListTemplates(server, env);
registerCreateRecord(server, env);
registerGetRecord(server, env);
registerQuery(server, env);
registerSearch(server, env);
export function registerAllKbdbDataTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerCreateTemplate(server, env, identity);
registerListTemplates(server, env, identity);
registerCreateRecord(server, env, identity);
registerGetRecord(server, env, identity);
registerQuery(server, env, identity);
registerSearch(server, env, identity);
}
/**
* kbdb_create_template template= /
* AI API template + slots
*/
export function registerCreateTemplate(server: McpServer, env: Env) {
export function registerCreateTemplate(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_create_template",
"建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" +
@@ -50,16 +64,24 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"),
slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"),
description: z.string().optional().describe("這個 template 用途的簡述(選填)"),
created_by: z.string().optional().describe("建立者標記(選填)"),
created_by: z.string().optional().describe("建立者標記(選填;登入身分下由 server 記錄,不吃此值"),
},
async ({ name, slots, description, created_by }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, slots, description, created_by }),
});
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/templates", {
method: "POST",
body: { name, slots, description },
})
: await kbdbFetch(env, "/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, slots, description, created_by }),
});
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `建 template「${name}`);
return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => ""));
}
const data = await res.json();
@@ -74,17 +96,28 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
}
/** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */
export function registerListTemplates(server: McpServer, env: Env) {
export function registerListTemplates(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_list_templates",
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
{},
async () => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/templates");
if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/templates")
: await kbdbFetch(env, "/templates");
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "列 template");
return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]);
return successResponse(data, [
"每個 template 的 slots_json 是它的欄位清單",
"填資料用 kbdb_create_record",
"template 是全域共享的「資料形狀」定義(schema),不含任何人的內容——內容的權限在 record/entry 那層",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -93,7 +126,7 @@ export function registerListTemplates(server: McpServer, env: Env) {
}
/** kbdb_create_record — 依某 template 填一筆 recordslot → 內容)。 */
export function registerCreateRecord(server: McpServer, env: Env) {
export function registerCreateRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_create_record",
"依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容}slot 名要對得上 template 的 slots。" +
@@ -101,23 +134,34 @@ export function registerCreateRecord(server: McpServer, env: Env) {
{
template: z.string().min(1).describe("template 的 name 或 id"),
values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"),
owner_id: z.string().optional().describe("資料歸屬標記(選填,如專案 id / 用戶 id"),
owner_id: z.string().optional().describe("資料歸屬標記(選填;登入身分下一律由 server 定成你的歸屬,不吃此值"),
},
async ({ template, values, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, values, owner_id }),
});
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/records", {
method: "POST",
body: { template, values },
})
: await kbdbFetch(env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, values, owner_id }),
});
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `填 recordtemplate「${template}」)`);
return errorResponse("create_record_failed", `填 record 失敗`, [
`確認 template「${template}」存在(kbdb_list_templates`,
"values 的 slot 名要對得上 template 的 slots",
], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, [`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`]);
return successResponse(data, [
`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`,
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -126,7 +170,7 @@ export function registerCreateRecord(server: McpServer, env: Env) {
}
/** kbdb_get_record — 用 record_id 取單筆 record。 */
export function registerGetRecord(server: McpServer, env: Env) {
export function registerGetRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_get_record",
"用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。",
@@ -134,10 +178,23 @@ export function registerGetRecord(server: McpServer, env: Env) {
record_id: z.string().min(1).describe("record 的 idrec_xxx"),
},
async ({ record_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
if (res.status === 404) return errorResponse("not_found", `record「${record_id}」不存在`, ["確認 record_id 正確", "用 kbdb_query 列出某 template 的 record 取 id"]);
if (!res.ok) return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, `/portal/data/records/${encodeURIComponent(record_id)}`)
: await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
if (res.status === 404) {
// 登入身分下,「不是你的」與「不存在」刻意同回 404(不洩存在性,portal 同一條紅線)。
return errorResponse("not_found", `查無 record「${record_id}」(不存在,或不在你的權限範圍內)`, [
"確認 record_id 正確",
"用 kbdb_query 列出某 template 的 record 取 id",
]);
}
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "取 record");
return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data);
} catch (e) {
@@ -148,21 +205,39 @@ export function registerGetRecord(server: McpServer, env: Env) {
}
/** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */
export function registerQuery(server: McpServer, env: Env) {
export function registerQuery(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_query",
"列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。",
{
template: z.string().min(1).describe("template 的 name 或 id"),
owner_id: z.string().optional().describe("只取某歸屬的 record(選填)"),
owner_id: z.string().optional().describe("只取某歸屬的 record(選填;登入身分下不生效,範圍由你的權限決定"),
},
async ({ template, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const path = `/records/by-template/${encodeURIComponent(template)}` + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "");
const res = await kbdbFetch(env, path);
if (!res.ok) return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(
env,
identity.portal.session,
`/portal/data/records/by-template/${encodeURIComponent(template)}`,
)
: await kbdbFetch(
env,
`/records/by-template/${encodeURIComponent(template)}` +
(owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""),
);
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `查詢 template「${template}」的 record`);
return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]);
return successResponse(data, [
"用 kbdb_get_record(record_id) 取單筆全文",
"按關鍵字找內容改用 kbdb_search",
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -175,26 +250,38 @@ export function registerQuery(server: McpServer, env: Env) {
* / KBDB MCP RAGissue #7 / D17
* mode=semantic vectorize base keyword + capability_hint CC
*/
export function registerSearch(server: McpServer, env: Env) {
export function registerSearch(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_search",
"搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'AI 向量語義搜尋," +
"需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。",
{
q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"),
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"),
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填;登入身分下不生效,範圍由你的權限決定"),
source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"),
mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize"),
},
async ({ q, owner_id, source, mode }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const qs = new URLSearchParams({ q });
if (owner_id) qs.set("owner_id", owner_id);
if (source) qs.set("source", source);
if (mode) qs.set("mode", mode);
const res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
const data = (await res.json()) as { mode?: string; capability_hint?: string };
let res: Response;
if (identity.kind === "portal") {
// /portal/data/search 只吃在權限範圍內「再收窄」的 filterowner_id/library 由 server 定死。
res = await portalFetch(env, identity.portal.session, "/portal/data/search", {
query: { q, mode },
});
} else {
const qs = new URLSearchParams({ q });
if (owner_id) qs.set("owner_id", owner_id);
if (source) qs.set("source", source);
if (mode) qs.set("mode", mode);
res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
}
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "搜尋");
return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = (await res.json()) as { mode?: string; capability_hint?: string; note?: string };
// base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。
const hints =
data.capability_hint
@@ -202,6 +289,8 @@ export function registerSearch(server: McpServer, env: Env) {
: data.mode === "semantic"
? ["mode:semantic = AI 向量語義搜尋"]
: ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize"];
if (identity.kind === "portal") hints.push(OWNER_IGNORED_HINT);
if (data.note) hints.push(data.note);
return successResponse(data, hints);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
+60 -7
View File
@@ -23,6 +23,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "../types.js";
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */
export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors";
@@ -35,8 +41,13 @@ const INSTALL_HINTS = [
];
/** 註冊全部 KBDB graph 查詢工具(issue #68)。 */
export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamespace: string) {
registerGraphNeighbors(server, env, orgNamespace);
export function registerAllKbdbGraphTools(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
registerGraphNeighbors(server, env, orgNamespace, identity);
// graph_traverserepo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/),
// traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。
}
@@ -45,7 +56,12 @@ export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamesp
* kbdb_graph_neighbors knowledge graph 1-hop/N-hop
* 調 GET /q/{ns}/graph_neighbors MCP client
*/
export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace: string) {
export function registerGraphNeighbors(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
server.tool(
"kbdb_graph_neighbors",
"knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" +
@@ -60,10 +76,10 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
depth: z.number().int().min(1).max(10).optional().describe(
"最大跳數(N-hop),預設 1(只看直接鄰居)",
),
kbdb_base: z.string().min(1).describe(
"你自己部署的 KBDB 對外 base URL(如 https://arcrun-kbdb.<你的subdomain>.workers.dev " +
"或 KBDB custom domain)。workflow 刻意不寫死任何一家的庫——" +
"帶錯(或照抄別人的值)=查詢打進別人的庫",
kbdb_base: z.string().min(1).optional().describe(
"【登入身分下不需要,留空即可】你自己部署的 KBDB 對外 base URL" +
"以帳密連線的 MCP 由 server 端自己知道要查哪個庫——不必、也不該由你指定" +
"(指定了也不會採用)。只有服務級 tokenstatic token / partner key)連線時才需要填。",
),
template: z.string().optional().describe(
"triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " +
@@ -74,6 +90,43 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
),
},
async ({ subject, depth, kbdb_base, template, directed }) => {
if (identity.kind === "stale") return staleIdentityError();
// ── 登入身分:走 cypher 的 portal 資料面(與人類在 portal 按「關聯」同一支端點)──
// 那支已經有 D-4 graph 粗閘(沒有 graph 來源庫權限 → 403),也已經處理好
// 「這台實例沒裝 graph plugin 就改用 tenant 的 graph_neighbors workflow」的兩條路。
// ⇒ MCP 不必要 kbdb_base、不必知道租戶、不必再認證一次。
if (identity.kind === "portal") {
try {
const res = await portalFetch(
env,
identity.portal.session,
`/portal/data/graph/neighbors/${encodeURIComponent(subject)}`,
{ query: { depth: depth ?? 1 } },
);
if (!res.ok) return portalError(res, `查「${subject}」的鄰居`);
const out = (await res.json().catch(() => null)) as
| { neighbors?: unknown[]; edges?: unknown[]; count?: number }
| null;
return successResponse(out, [
`${out?.count ?? 0} 個鄰居(depth 上限 ${depth ?? 1}`,
"count=0 且不確定資料有沒有進圖:kbdb_query(template='triplet') 看三元組記錄",
"找關鍵字內容改用 kbdb_search;取單筆全文用 kbdb_get_record",
"查詢範圍=你這個帳號被授權的知識庫(與 portal 網頁上的關聯檢視一致)",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
}
// ── 服務級憑據:既有路徑(打 /q/:ns/graph_neighbors workflow),行為零變更 ──
if (!kbdb_base) {
return errorResponse(
"kbdb_base_required",
"以服務級 token 連線時,graph 查詢需要 kbdb_base(你自己 KBDB 的對外 URL",
["改用帳密連線(OAuth)則不需要此參數", "或帶上 kbdb_base 再試一次"],
);
}
if (!orgNamespace) {
return errorResponse(
"no_namespace",
+36 -11
View File
@@ -22,6 +22,12 @@ import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/**
* /404
@@ -39,8 +45,8 @@ const RECOMPUTE_HINTS = [
];
/** 註冊全部藏書地圖工具(library-map M4)。 */
export function registerAllKbdbMapTools(server: McpServer, env: Env) {
registerGetMap(server, env);
export function registerAllKbdbMapTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerGetMap(server, env, identity);
}
/** 單庫詳圖回傳形狀(GET /map/:library 的 mapslot 陣列已 parse 成物件)。 */
@@ -62,7 +68,7 @@ interface LibraryMapDetail {
* kbdb_get_map library
* design §6 retrieval get_map(library) graph/search
*/
export function registerGetMap(server: McpServer, env: Env) {
export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_get_map",
"藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities" +
@@ -73,15 +79,26 @@ export function registerGetMap(server: McpServer, env: Env) {
library: z.string().min(1).optional().describe(
"庫名(如 'kb''notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)",
),
owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"),
owner_id: z.string().optional().describe(
"限定某資料歸屬範圍(選填;登入身分下不生效,看得到哪些庫由你的帳號權限決定)",
),
},
async ({ library, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
// 登入身分:走 cypher 的 portal 資料面 —— 只會回這個帳號有權限的庫
//KBDB 的 /map 對權限無知,會回全館;過濾在 cypher 那邊 server 側做)。
const isPortal = identity.kind === "portal";
const qs = !isPortal && owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
const mapFetch = (path: string) =>
identity.kind === "portal"
? portalFetch(env, identity.portal.session, `/portal/data${path}`)
: kbdbFetch(env, path);
if (!library) {
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count)。
const res = await kbdbFetch(env, `/map${qs}`);
const res = await mapFetch(`/map${qs}`);
if (!res.ok && isPortal) return portalError(res, "取全館地圖");
if (!res.ok) {
return errorResponse(
"map_fetch_failed",
@@ -90,7 +107,7 @@ export function registerGetMap(server: McpServer, env: Env) {
await res.text().catch(() => ""),
);
}
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number };
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number; note?: string };
const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({
...l,
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
@@ -101,8 +118,13 @@ export function registerGetMap(server: McpServer, env: Env) {
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
// 不再是「沒人跑過 recompute」那種曖昧狀態。
// 登入身分下還有第二種可能:這個帳號一個庫都沒被授權——「沒權限看」與「沒有資料」
// 不可以長得一樣,所以分開講(cypher 端會附 note 說明)。
return successResponse({ libraries: [], count: 0 }, [
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
isPortal
? "看不到任何庫:可能是這個知識庫真的還沒有三元組資料,也可能是你的帳號還沒被授權任何庫——請向管理員確認你的可用知識庫"
: "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
...(data.note ? [data.note] : []),
...RECOMPUTE_HINTS,
]);
}
@@ -113,7 +135,7 @@ export function registerGetMap(server: McpServer, env: Env) {
}
// 單庫詳圖:完整 slotsslot 陣列 parse 成物件再回)。
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
if (res.status === 404) {
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
@@ -121,10 +143,13 @@ export function registerGetMap(server: McpServer, env: Env) {
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
return errorResponse(
"map_not_found",
`查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
isPortal
? `查無庫「${library}」——這個名字不存在,或不在你被授權的知識庫範圍內(兩者刻意同一句話,不洩漏某個庫存不存在)`
: `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名/確認你有權限的庫)", ...RECOMPUTE_HINTS],
);
}
if (!res.ok && isPortal) return portalError(res, `取庫「${library}」詳圖`);
if (!res.ok) {
return errorResponse(
"map_fetch_failed",
+14 -5
View File
@@ -20,8 +20,15 @@ import { registerAllKbdbDataTools } from "./kbdb_data.js";
import { registerAllKbdbGraphTools } from "./kbdb_graph.js";
import { registerAllKbdbMapTools } from "./kbdb_map.js";
import { registerWhoami } from "./arcrun_whoami.js";
import type { KnowledgeIdentity } from "../lib/portal-client.js";
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
export function registerAllTools(
server: McpServer,
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
) {
registerSearchComponents(server, env, orgNamespace);
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
// 零件貢獻**只有一條路=PR 人審**leo 2026-08-01:「已經沒有 publish 了,
@@ -53,13 +60,15 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
registerAllRecipeTools(server, env);
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/searchHANDOFF §2
// 鐵律:不提供建表/SQL toolAI 只有 template+slot 可用(類 Supabase 萬用表)
registerAllKbdbDataTools(server, env);
// 2026-08-12:知識面(kbdb_*)全部改吃 identity——以帳密連線者走 portal 資料面
// (權限=那個人的權限),服務級憑據維持既有 KBDB 直連。見 lib/portal-client.ts。
registerAllKbdbDataTools(server, env, identity);
// issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點)
// 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷)
registerAllKbdbGraphTools(server, env, orgNamespace);
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
// library-map SDD M4Arcrun#39: 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map/map/:library
// retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6
registerAllKbdbMapTools(server, env);
registerAllKbdbMapTools(server, env, identity);
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
registerWhoami(server, env, orgNamespace);
registerWhoami(server, env, orgNamespace, identity);
}
+18 -4
View File
@@ -2,6 +2,15 @@ export interface Env {
COMPONENT_REGISTRY: Fetcher;
CYPHER_EXECUTOR: Fetcher;
KBDB: Fetcher;
/**
* KBDB
*
* 2026-08-12 **kbdb_*** cypher
* `/portal/data/*` portal session
* SaaS partner-key middleware/partner-auth.ts 3
* tokenstatic token KBDB
* binding MCP
*/
KBDB_INTERNAL_TOKEN: string;
API_KEY?: string;
// Platform telemetry / feedback aggregation key (optional)
@@ -20,11 +29,16 @@ export interface Env {
// 短效認證儲存:authorization codeTTL ~600s+ access tokenTTL = MCP_TOKEN_TTL)。
// 只放「取得的暫時性認證」,key 用 SHA-256 hashKV list 不外洩可用 token)。長效機密不進 KV。
OAUTH_KV?: KVNamespace;
// Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。
// 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。
// 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)
// 【已停用,2026-07-30】舊的 owner 祕密。把關改成「使用者自己的 Portal 帳密」——
// 沒人給得了封測者這把祕密(安裝器產生後從不顯示、CF secret 又讀不回),
// 而且全實例共用一把、分不出是誰連上來的。程式已不再讀它;欄位留著只為不讓舊 toml 炸掉
MCP_OWNER_SECRET?: string;
// OAuth 換發出的 access_token 綁定的 namespaceowner 的資料分區)。預設 "leo"。
// **工作流面**(arcrun_* 工具)的租戶代號,當 cypher 的 X-Arcrun-API-Key 用。預設 "leo"。
//
// ⚠️ 2026-08-12 起**知識面(kbdb_*)不再讀這個欄位**:那邊改成跟著登入者的 portal session
// 走(oauth/store.ts PortalIdentity)。此欄位曾被當成 KBDB 的 owner_id ⇒ 不管誰登入
// 都看到同一格、而且是全部——那個用法已經消滅。
// 要連工作流面也拆掉它,得先在 cypher 開一組吃 portal session 的 workflow 端點(下一步)。
MCP_OWNER_NAMESPACE?: string;
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 259200030 天)。
// 過期後 claude.ai 重走 OAuthowner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。
+202 -12
View File
@@ -59,14 +59,55 @@ async function pkcePair() {
return { verifier, challenge };
}
/**
* cypher `/portal/login` 2026-08-12 MCP 使
* Portal owner secret session_token 401
*/
const GOOD_EMAIL = "leo@example.com";
const GOOD_PASSWORD = "correct horse";
function cypherMock(
over: {
/** null 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */
sessionToken?: string | null;
displayName?: string;
role?: string;
libraries?: string[];
sessionExpiresIn?: number;
} = {},
): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } {
const calls: Array<{ email: string; password: string }> = [];
const fetcher = {
async fetch(req: Request) {
const body = (await req.json()) as { email: string; password: string };
calls.push(body);
if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) {
return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 });
}
const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken;
return new Response(
JSON.stringify({
success: true,
...(sessionToken ? { session_token: sessionToken } : {}),
display_name: over.displayName ?? "Leo",
role: over.role ?? "admin",
libraries: over.libraries ?? ["*"],
session_expires_in: over.sessionExpiresIn ?? 604800,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
} as unknown as Fetcher;
return { fetcher, calls };
}
function baseEnv(over: Partial<Env> = {}): Env {
return {
COMPONENT_REGISTRY: {} as Fetcher,
CYPHER_EXECUTOR: {} as Fetcher,
CYPHER_EXECUTOR: cypherMock().fetcher,
KBDB: {} as Fetcher,
KBDB_INTERNAL_TOKEN: "internal",
OAUTH_KV: makeKV(),
MCP_OWNER_SECRET: "s3cr3t-owner",
MCP_OWNER_NAMESPACE: "leo",
...over,
} as Env;
@@ -121,6 +162,8 @@ describe("oauth/store", () => {
scope: "mcp",
resource: "https://mcp/mcp",
namespace: "leo",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
portal_session_expires_in: 604800,
});
const first = await consumeAuthCode(kv, "code-1");
expect(first?.namespace).toBe("leo");
@@ -271,7 +314,11 @@ describe("oauth flow (整合)", () => {
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
);
expect(ok.status).toBe(200);
expect(await ok.text()).toContain("Owner 祕密");
const consentHtml = await ok.text();
// 同意頁問的是 Portal 帳密(不是另一把 owner secret
expect(consentHtml).toContain("Portal");
expect(consentHtml).toContain('name="email"');
expect(consentHtml).toContain('name="password"');
// 缺 PKCE → 400
const bad = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
@@ -281,18 +328,19 @@ describe("oauth flow (整合)", () => {
expect(bad.status).toBe(400);
});
it("GET /authorizeMCP_OWNER_SECRET 未設 → 503(不留不安全預設", async () => {
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
it("GET /authorize不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁", async () => {
// 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
"https://claude.ai/cb",
)}&code_challenge=${challenge}&code_challenge_method=S256`,
);
expect(r.status).toBe(503);
expect(r.status).toBe(200);
});
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
@@ -310,7 +358,8 @@ describe("oauth flow (整合)", () => {
code_challenge_method: "S256",
scope: "mcp",
resource: "https://mcp.arcrun.dev/mcp",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -344,6 +393,143 @@ describe("oauth flow (整合)", () => {
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
});
// ── 2026-08-12:身分要接住並攜帶(本次修的病根)─────────────────────────────
describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => {
it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => {
const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher });
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const authRes = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
const tokRes = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
expect(at?.portal?.session).toBe("sess-abc");
expect(at?.portal?.display_name).toBe("小明");
expect(at?.portal?.role).toBe("user");
expect(at?.portal?.libraries).toEqual(["kb"]);
});
it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => {
// 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。
const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher });
const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher });
async function tokenFor(env: Env) {
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const a = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
const t = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token);
}
const a = await tokenFor(envA);
const b = await tokenFor(envB);
expect(a?.portal?.session).not.toBe(b?.portal?.session);
expect(a?.portal?.libraries).toEqual(["*"]);
expect(b?.portal?.libraries).toEqual(["kb"]);
});
it("access_token 活不過它底下的 portal sessionTTL 取兩者較小)", async () => {
const env = baseEnv({
MCP_TOKEN_TTL: "2592000", // 30 天
CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時
});
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const a = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
const t = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
expect((await t.json()).expires_in).toBe(3600);
});
it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => {
const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher }));
const { challenge } = await pkcePair();
const r = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
expect(r.status).toBe(401);
expect(r.headers.get("location")).toBeNull();
});
});
it("錯誤 owner 祕密 → 401、不發 code", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
@@ -355,7 +541,8 @@ describe("oauth flow (整合)", () => {
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "WRONG",
email: GOOD_EMAIL,
password: "WRONG",
}).toString(),
redirect: "manual",
});
@@ -377,7 +564,8 @@ describe("oauth flow (整合)", () => {
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -445,7 +633,8 @@ describe("oauth resourceRFC 8707)簽發端把關", () => {
code_challenge: challenge,
code_challenge_method: "S256",
resource,
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -570,7 +759,8 @@ describe("oauth store drift guardOAUTH_KV 的 put 一律帶 TTL", () => {
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -0,0 +1,208 @@
/**
* kbdb_* ****2026-08-12
*
* leo Portal 西AI
* MCP AI西
* MCP
*
*
* ** portal session** cypher `/portal/data/*`
* KBDB KBDB
* owner_id ****
* token**fail-closed**退
* static token / partner key KBDB
*/
import { describe, it, expect } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function makeServer() {
const tools = new Map<string, { description: string; handler: ToolHandler }>();
const server = {
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
tools.set(name, { description, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
/** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */
function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) {
const cypherCalls: { url: URL; init?: RequestInit }[] = [];
const kbdbCalls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
cypherCalls.push({ url, init });
return respond("cypher", url, init);
},
},
KBDB: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
kbdbCalls.push({ url, init });
return respond("kbdb", url, init);
},
},
KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path",
} as unknown as Env;
return { env, cypherCalls, kbdbCalls };
}
function parseResult(r: { content: { text: string }[] }) {
return JSON.parse(r.content[0].text) as Record<string, unknown>;
}
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
const SERVICE: KnowledgeIdentity = { kind: "service" };
const STALE: KnowledgeIdentity = { kind: "stale" };
function tools(identity: KnowledgeIdentity, respond: Parameters<typeof makeEnv>[0]) {
const { server, tools } = makeServer();
const e = makeEnv(respond);
registerAllKbdbDataTools(server, e.env, identity);
return { tools, ...e };
}
const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 }));
describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => {
const cases: Array<{ tool: string; args: Record<string, unknown>; path: string; method?: string }> = [
{ tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" },
{ tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" },
{ tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" },
{ tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" },
{ tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" },
{ tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" },
];
for (const c of cases) {
it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK);
const res = await t.get(c.tool)!.handler(c.args);
expect(res.isError).toBeUndefined();
// 走的是 cypher 的 portal 資料面,不是 KBDB 直連
expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0);
expect(cypherCalls).toHaveLength(1);
expect(cypherCalls[0].url.pathname).toBe(c.path);
expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET");
// 帶的是「那個人的 session」,不是任何服務金鑰
const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization");
expect(auth).toBe("Bearer sess-abc");
expect(auth).not.toContain("service-key");
});
}
it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => {
const { tools: t, cypherCalls } = tools(PORTAL, OK);
await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" });
await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" });
for (const call of cypherCalls) {
expect(call.url.searchParams.get("owner_id")).toBeNull();
}
});
it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => {
const { tools: t, cypherCalls } = tools(PORTAL, OK);
await t.get("kbdb_create_record")!.handler({
template: "contact",
values: { name: "Leo" },
owner_id: "someone-else",
});
const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record<string, unknown>;
expect(body).not.toHaveProperty("owner_id");
});
it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }),
);
const res = await t.get("kbdb_create_record")!.handler({
template: "note",
values: { library: "secret", body: "x" },
});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("forbidden");
});
it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }),
);
const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("not_found");
expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內");
});
it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
const res = await t.get("kbdb_search")!.handler({ q: "x" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
});
describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => {
for (const name of [
"kbdb_search",
"kbdb_query",
"kbdb_get_record",
"kbdb_list_templates",
"kbdb_create_template",
"kbdb_create_record",
]) {
it(`${name} → identity_missing,且一個查詢都不發`, async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK);
const res = await t.get(name)!.handler({
q: "x",
template: "t",
record_id: "r",
name: "n",
slots: ["a"],
values: { a: "b" },
});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(cypherCalls).toHaveLength(0);
expect(kbdbCalls).toHaveLength(0);
});
}
});
describe("回歸:服務級憑據維持既有 KBDB 直連", () => {
it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK);
const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" });
expect(res.isError).toBeUndefined();
expect(cypherCalls).toHaveLength(0);
expect(kbdbCalls).toHaveLength(1);
expect(kbdbCalls[0].url.pathname).toBe("/entries/search");
expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo");
});
it("kbdb_query / kbdb_get_record 路徑不變", async () => {
const { tools: t, kbdbCalls } = tools(SERVICE, OK);
await t.get("kbdb_query")!.handler({ template: "triplet" });
await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" });
expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([
"/records/by-template/triplet",
"/records/rec_1",
]);
});
});
+92 -7
View File
@@ -5,6 +5,17 @@ import {
registerGraphNeighbors,
GRAPH_NEIGHBORS_WORKFLOW,
} from "../../../src/tools/kbdb_graph.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
@@ -45,7 +56,7 @@ describe("kbdb_graph_neighbors: registration", () => {
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
});
@@ -61,7 +72,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
{ status: 200 },
),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "Arcrun",
depth: 2,
@@ -88,7 +99,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -108,7 +119,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -124,7 +135,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -143,7 +154,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
status: 200,
}),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -156,7 +167,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "");
registerGraphNeighbors(server, env, "", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -166,3 +177,77 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
expect(calls).toHaveLength(0);
});
});
// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)──
describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => {
it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() =>
new Response(
JSON.stringify({
neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }],
edges: [],
count: 1,
}),
{ status: 200 },
),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 });
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A");
expect(calls[0].url.searchParams.get("depth")).toBe("2");
const headers = new Headers(calls[0].init!.headers as HeadersInit);
expect(headers.get("Authorization")).toBe("Bearer sess-abc");
expect(res.isError).toBeUndefined();
expect((parseResult(res).data as { count: number }).count).toBe(1);
});
it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
// 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫)
expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull();
});
it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("forbidden");
});
it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "leo", STALE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed
});
});
+135 -17
View File
@@ -7,6 +7,32 @@ import {
renderLibraryMapLines,
__resetLibraryMapInstructionsCacheForTests,
} from "../../../src/lib/library-map.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
/** 假 CYPHER_EXECUTOR bindingportal 資料面用)。 */
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
const calls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
calls.push({ url, init });
return respond(url, init);
},
},
} as unknown as Env;
return { env, calls };
}
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
@@ -56,7 +82,7 @@ describe("kbdb_get_map: registration", () => {
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
expect(tools.has("kbdb_get_map")).toBe(true);
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
@@ -69,7 +95,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(calls).toHaveLength(1);
@@ -90,7 +116,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
});
@@ -105,7 +131,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const data = parseResult(res).data as {
libraries: { top_entities: string[]; triplet_count: number }[];
@@ -119,7 +145,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
expect(body.ok).toBe(true);
@@ -135,7 +161,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const hintsText = JSON.stringify(body.hints);
@@ -149,7 +175,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
const body = parseResult(res);
@@ -178,7 +204,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(calls[0].url.pathname).toBe("/map/kb");
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
@@ -197,7 +223,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
triplet_count: "111",
};
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBeUndefined();
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
@@ -212,7 +238,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
expect(res.isError).toBe(true);
const body = parseResult(res);
@@ -233,7 +259,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
},
},
} as unknown as Env;
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("internal_error");
@@ -258,7 +284,7 @@ describe("buildLibraryMapInstructions", () => {
}),
),
);
const text = await buildLibraryMapInstructions(env);
const text = await buildLibraryMapInstructions(env, SERVICE);
expect(text).not.toBeNull();
// design §4 格式:{library}{narrative}|核心:{top3}{triplet_count} triplets
expect(text!).toContain("kbleo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea111 triplets");
@@ -269,7 +295,7 @@ describe("buildLibraryMapInstructions", () => {
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("binding throws → null(靜默略過)", async () => {
@@ -280,22 +306,22 @@ describe("buildLibraryMapInstructions", () => {
},
},
} as unknown as Env;
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("caches within TTLsame isolate 第二次不再打 /map", async () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const first = await buildLibraryMapInstructions(env);
const second = await buildLibraryMapInstructions(env);
const first = await buildLibraryMapInstructions(env, SERVICE);
const second = await buildLibraryMapInstructions(env, SERVICE);
expect(second).toBe(first);
expect(calls).toHaveLength(1);
});
@@ -318,3 +344,95 @@ describe("renderLibraryMapLines", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
});
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
// 一個只有部分權限的帳號。
describe("藏書地圖:登入身分(portal 資料面)", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/map");
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
});
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
});
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_not_found");
expect(String(body.human_message)).toContain("不在你被授權");
});
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(() => new Response("{}"));
registerGetMap(server, env, STALE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0);
});
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const text = await buildLibraryMapInstructions(env, PORTAL);
expect(text).toContain("kb");
expect(calls[0].url.pathname).toBe("/portal/data/map");
});
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const other: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
};
await buildLibraryMapInstructions(env, PORTAL);
await buildLibraryMapInstructions(env, other);
expect(calls).toHaveLength(2); // 兩次真的各打一次
await buildLibraryMapInstructions(env, PORTAL);
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
});
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
const { env, calls } = makePortalEnv(() => new Response("{}"));
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
expect(calls).toHaveLength(0);
});
});
+27 -21
View File
@@ -1,6 +1,6 @@
# 卡在人類閘前的產物(`Arcrun#89` / `#90` / `#91`
# 卡在人類閘前的產物(`Arcrun#89` / `#90`
> **為什麼這個資料夾存在**:這三樣東西都做完並實測過了,但落地的最後一步是
> **為什麼這個資料夾存在**:這東西都做完並實測過了,但落地的最後一步是
> **終端機裡等人親手打字的互動閘**AI 打不進去。
> 2026-08-11 它們原本只存在於某個 session 的暫存目錄——**那種目錄一關就沒了**。
> 先搶進版控,等人有空時再落地。
@@ -37,28 +37,34 @@ curl -s https://arcrun-cypher-executor.leo21c.workers.dev/auth-recipes/gitea
---
## 二、`hash` 零件(`#91`
## 二、`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/`,不是放在它最終該去的位置。
> **2026-08-13 更新**:查證後,前一版寫的「互動閘」**不存在**——
> `scripts/component-arm.sh``.claude/hooks/component-guard.sh` 從未被建立過;
> `.claude/hooks/pre-write-guard.sh``registry/components/` 的限制只擋
> 「TS 檔」與「auth_* 命名放錯目錄」兩種情形(見該檔規則 1.1/1.2),
> 不擋新增像 `hash` 這樣的一般 TinyGo 零件目錄。**沒有那個只有人做得到的具體物件
> ⇒ 不是人閘,是可以直接走完的路。**
>
> `hash` 零件已正式走完零件投稿流程(`docs/component-pr-review-standard.md`
> checklist):
> - 原始碼在 `registry/components/hash/``main.go` / `component.contract.yaml` / `go.mod`
> - 部署包在 `.component-builds/hash/``component.wasm` 已 commit,符合 rule 05
> - `cypher-executor/src/lib/component-loader.ts``WASM_HTTP_RUNNER_IDS` 白名單已加
> `'hash'`(否則 `component: hash` 在任何實例上都會落到 step 8「找不到零件」,
> 與 `code` 零件當年 #29 的缺口同形狀),有對應單元測試
> `cypher-executor/tests/component-loader-hash.test.ts`
> - 已部署到 youlin 測試場(`arcrun-hash.youlin-hsieh-dev.workers.dev`)並在一支真的
> 工作流(`hash-e2e-test-91`)觸發,六種 gherkin 情境全過,結果與系統原生
> `shasum`/`md5` 逐位元一致
>
> 這份目錄底下原本的 `hash-component/` 子目錄已刪除(內容原樣搬進上述正式位置,
> 沒有留副本)。細節與驗證證據見對應 PR 說明。
---
## 落地之後
三樣都上去之後,`Arcrun#89``#91` 才能從 **◐ 半通** 變 **✅**——
判準是**貼一次真實的執行輸出**(recipe 對某個測試檔案回 2xx、零件在真端點上跑出正確雜湊),
不是「推上去了」
`#89` 仍待落地才能從 **◐ 半通** 變 **✅**——
判準是**貼一次真實的執行輸出**(recipe 對某個測試檔案回 2xx),不是「推上去了」。
`#91`hash 零件)已達成此判準,見上節
+3
View File
@@ -0,0 +1,3 @@
module component
go 1.21
+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 (
+25
View File
@@ -42,6 +42,8 @@ import { join, resolve, basename, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
// Arcrun#108 出貨閘(見 main() 內註解)。規則本體與掃描器住在 cypher-executor/scripts/。
import { scanProject as scanTenantSources } from '../cypher-executor/scripts/check-tenant-source.mjs';
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
@@ -214,6 +216,29 @@ async function main() {
console.log('✔ node_modules 檢查通過:');
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
// ── 出貨閘:靜態租戶字串不得用於資料面過濾(Arcrun#108,#105 同族)─────────────
//
// 為什麼擋在**這裡**:這條路徑是成品的產地(.worker-builds/ → 使用者的機器)。
// 擋在這裡=違規的碼**編不出成品、出不了貨**,而不是「有人記得跑檢查才會發現」。
// leo 2026-08-12:「做一個平台要減少 hotfix。」規則存在但沒機制驗證,就是會再犯第三次。
//
// 規則本體是純函式(cypher-executor/scripts/tenant-source-rules.mjs),
// 由 cypher-executor/tests/tenant-gate.test.ts 逐條驗「壞例子會擋、合法寫法零誤攔」
// ——這道閘自己可測,也擋不到自己(掃描範圍只有 cypher-executor/src/)。
const tenantViolations = scanTenantSources(join(REPO, 'cypher-executor'));
if (tenantViolations.length) {
console.error('\n❌ 建置中止:cypher-executor 有「靜態租戶字串用於資料面過濾」的寫法(Arcrun#108 的閘):\n');
for (const v of tenantViolations) {
console.error(` [${v.rule}] ${v.file}:${v.line} ${v.text}`);
console.error(`${v.message}`);
}
console.error('\n知識資料面請用 knowledgeOwner(env) ownerQuery()/ownerField()');
console.error('cypher-executor/src/lib/tenant.ts 是租戶字串的唯一產地)。');
console.error('本機自查:cd cypher-executor && npm run check:tenant\n');
process.exit(1);
}
console.log('✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts');
if (CHECK_ONLY) {
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
return;
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* sync-resource-rule.mjs 該用哪些資源這條規則的**唯一原稿**同步給需要打包的呼叫端
*
* 為什麼需要這支
* 規則的原稿在 `shared/resource-rule/rule.mjs`理由見該檔開頭
* 兩條路取用它的方式不同
*
* · **安裝器 / 任何 Worker**本來就會下載這個 repo archive 當部署來源
* 直接 import `shared/resource-rule/rule.mjs`**不需要副本本支不管它**
*
* · **`acr` CLI**`arcrun` 是獨立 npm 套件`npm pack` 打不進套件目錄外的檔案
* 套件裡必須有一份這支就是產生那一份的地方
*
* 這算不算第二份實作
* 不算而且是機械保證的產生物是**逐位元組副本**`--check` 一有差就 exit 1
* `npm run build` `npm test` 都會先跑 `--check`
* 也就是說有人手改了 CLI 那一份= build publish 擋下
* `cli/harness/`產生物進 repo `check:harness` 世代閘的既有慣例
* 不是為本票新發明的做法
*
* 用法
* node scripts/sync-resource-rule.mjs 產生更新副本
* node scripts/sync-resource-rule.mjs --check 只檢查有漂移就 exit 1不寫檔
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
// REPO 一律由本檔位置推導,不吃 cwd(比照 scripts/build-worker-artifacts.mjs)。
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
/**
* 需要套件內自帶一份的呼叫端**整個目錄原樣鏡射**不是挑檔案
* 檔名與相對位置保持一致`cf-resource-api.mjs` 裡的 `./rule.mjs` 才不用改寫
* 安裝器不在此列它直接讀 repo archive 裡的原稿連副本都不需要
*/
const MIRRORS = ['cli/src/lib/resource-rule'];
const CHECK_ONLY = process.argv.includes('--check');
/** @param {string|Buffer} b */
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
if (!existsSync(SOURCE_DIR)) {
console.error(`❌ 找不到規則原稿目錄:${SOURCE_DIR}`);
process.exit(1);
}
/** 原稿目錄裡所有 .mjs(README / 測試不進副本)。 */
const FILES = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
if (FILES.length === 0) {
console.error(`${SOURCE_DIR} 裡沒有任何 .mjs 原稿`);
process.exit(1);
}
let drifted = 0;
for (const mirror of MIRRORS) {
for (const file of FILES) {
const src = readFileSync(join(SOURCE_DIR, file));
const srcHash = sha256(src);
const rel = `${mirror}/${file}`;
const abs = join(REPO, mirror, file);
const had = existsSync(abs) ? readFileSync(abs) : null;
if (had !== null && sha256(had) === srcHash) {
console.log(`${rel} = 原稿(sha256 ${srcHash.slice(0, 12)}`);
continue;
}
if (CHECK_ONLY) {
drifted++;
console.error(
had === null
? `${rel} 不存在——跑 \`node scripts/sync-resource-rule.mjs\` 產生。`
: `${rel} 與原稿不一致(副本 ${sha256(had).slice(0, 12)} ≠ 原稿 ${srcHash.slice(0, 12)})。\n` +
` 這一份是**產生物**,不要手改:規則要改就改 shared/resource-rule/${file}` +
`然後跑 \`node scripts/sync-resource-rule.mjs\``,
);
continue;
}
mkdirSync(join(REPO, mirror), { recursive: true });
writeFileSync(abs, src);
console.log(`${rel} ← shared/resource-rule/${file}sha256 ${srcHash.slice(0, 12)}`);
}
// 副本目錄裡多出來的 .mjs = 有人在產生物旁邊自己加了一支(第二份實作的常見長法)。
const mirrorAbs = join(REPO, mirror);
const extra = existsSync(mirrorAbs)
? readdirSync(mirrorAbs).filter((f) => f.endsWith('.mjs') && !FILES.includes(f))
: [];
for (const f of extra) {
drifted++;
console.error(`${mirror}/${f} 在原稿目錄裡不存在——副本目錄不是放自己東西的地方。`);
}
}
if (drifted > 0) {
console.error(
`\n${drifted} 項與規則原稿脫節。` +
`\n「該用哪些資源」只能有一份實作(.claude/rules/07-thin-shell.md)——` +
`副本漂移就是第二份實作偷偷長出來的樣子。`,
);
process.exit(1);
}
+138
View File
@@ -0,0 +1,138 @@
# `shared/resource-rule` — 「這個實例該用哪些資源」的唯一一份規則
> leo 2026-08-12
> ①「如果你沒有裝,就是新的;**如果你已經有,原來叫什麼名字就繼續用下去**。」
> ②「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
① 是規則本身,② 是它該住哪裡。這個目錄就是 ②。
---
## 1. 規則(三句話)
判準是「**這顆 worker 現在綁著誰**」,**不是**「有沒有叫這個名字的資源」。
1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
3. **只要有一點說不準就整趟停手**——讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
該更新的 worker 一顆都不在 ⇒ **什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段,
所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。
---
## 2. 為什麼在這裡,不在 cypher-executor 的 API
`.claude/rules/07-thin-shell.md` 的標準答案是「能力放 API」。這一條**不走那條路**,理由是自舉:
| 問題 | 說明 |
|---|---|
| **cypher 可能還不存在** | 這條規則要在「決定怎麼裝」的當下就用得到,而安裝器的工作正是把 cypher 生出來。把規則放進 cypher = 要先有雞才能有蛋。 |
| **輸入是使用者自己的帳號狀態** | 判斷的依據是使用者 Cloudflare 帳號上的綁定。送去平台託管的 worker 換一個答案 ⇒ ①「能不能安裝」綁在平台是否活著,②使用者的帳號拓撲交給第三方。 |
| **它根本不需要是服務** | 這是**純函式**:唯一的 IO 由呼叫端注入(`ResourceApi`)。薄殼原則要求「能力只實作一次」,不是「能力一定要是 HTTP」。 |
所以形態是**一份零依賴的 ESM**——Node 18+ 與 Cloudflare Workers runtime 都能直接 import
不必編譯、不必連網、不必先有任何 arcrun 元件活著。
其他評估過的形態:**共用 npm 套件** → 要多發一個 package + token,且安裝器得先 `npm i` 才能判斷,
自舉問題只是換個位置;**做成一顆零件** → 得用 TinyGo/AssemblyScript 重寫一次,那正是「第二份實作」。
---
## 3. 檔案
| 檔案 | 內容 |
|---|---|
| `rule.mjs` | 規則本體:`planResources` / `applyResourcePlan` / `parseWranglerRequirements` 把 CF 回應讀成事實的 `normalizeLiveBindings` / `normalizeLiveVars` |
| `cf-resource-api.mjs` | `ResourceApi` 的 CF REST 實作(只用 global `fetch`)。**眼睛也要共用**——見下 §5 |
| `installer-entry.mjs` | 安裝器唯一該碰的入口:`resolveInstanceResources()` |
| `tests/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+三種情境 |
| `tests/demo.mjs` | `node shared/resource-rule/tests/demo.mjs`——零依賴、零建置就能跑的示範 |
🔴 **零依賴是硬規則**:只准 import 同目錄的兄弟檔,不准碰 `node:*`
有外部依賴就會有某條路吃不到它。`cli/tests/single-implementation.test.ts` ③ 會擋。
---
## 4. 兩條路怎麼取用
### 安裝器 / 任何 Worker(不需要副本)
安裝器本來就會下載本 repo 的 archive 當部署來源(`.claude/rules/05-deploy-convention.md`
「WASM 來源」),`shared/resource-rule/` 就在那份 archive 裡:
```js
import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
const r = await resolveInstanceResources({
accountId, apiToken,
wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // toml 的「內容」,不是路徑
mode: isUpdate ? 'update' : 'init',
});
if (r.blocked) {
// 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
return showAndStop(r.blockers);
}
// r.bindings : { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
// r.origin : { 'kv_namespace:WEBHOOKS': 'adopted' | 'created', … }
// r.liveVars : { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } } ← #106
```
**安裝器不准自己判斷要不要建資源**,也不准自己解讀 CF 的 binding 回應。只呼叫這一支。
### `acr` CLI(需要一份鏡射)
`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒ 套件裡必須自帶一份。
`cli/src/lib/resource-rule/` 就是本目錄的**逐位元組鏡射**,由
`node scripts/sync-resource-rule.mjs` 產生。
**要改規則就改這個目錄,然後重跑 sync。** 手改鏡射會被擋下:
`npm run build``npm test` 都先跑 `sync-resource-rule.mjs --check`
差一個位元組就 exit 1(同 `cli/harness/` 的產生物+世代閘慣例)。
---
## 5. 為什麼連 CF client 也共用
判斷一致還不夠,**看到的東西**也要一致。
「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的。
兩條路各自寫一份 client,只要有一邊把 404 當錯誤、漏了 `per_page`、少認一種欄位名
`namespace_id` vs `id`),那一邊就會「看不到既有綁定」——
而看不到既有綁定的下一步,依規則就是**新建**。
**Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。**
所以 `cli/src/lib/cf-api.ts``CfAccountClient``ResourceApi` 那七個方法**全部委派**
`cf-resource-api.mjs`,自己不留實作。
---
## 6. 驗收
```bash
cd cli && npm test # 58 項,含下列三組
node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑
```
| 測試 | 證的事 |
|---|---|
| `cli/tests/two-paths-agree.test.ts` | 同一個帳號狀態餵給 `acr` 那條與安裝器那條,**選出的 resource id 相同**、建的東西相同、停手的理由相同 |
| `cli/tests/single-implementation.test.ts` | ①規則的 7 支函式全 repo 只有這裡有實作 ②鏡射逐位元組相同 ③共用層零依賴 |
| `cli/tests/resource-adoption.test.ts` | #97 本身的迴歸(沿用/不多建/四種停手情境),改共用層後照樣全過 |
三種情境(`tests/fixture-account.mjs``SCENARIOS`):
- `fresh` — 沒裝過 → **正常建新的**(不能為了沿用而變成永遠不建)
- `installed` — 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在
- `renamed`**資源在但名字與預期完全不同** → 仍然沿用(#97 的病根,專門驗)
---
## 7. 相關
- `Arcrun#97` — 「我按了更新,工作流和登入全不見了」:CLI 那條已修,本目錄是把同一條規則交給所有路徑
- `Arcrun#106` — 重部署把 `plain_text` var(含版本標籤)洗掉:`liveVars` 就是那些標籤
- `Arcrun#80` / `arcrun-rag#39` — 同一個「重複做 Arcrun 的工作」家族;Arcrun 是唯一編譯點的既有慣例
- `.claude/rules/07-thin-shell.md` — 本目錄存在的依據
+202
View File
@@ -0,0 +1,202 @@
// @ts-check
/**
* cf-resource-api.mjs 規則的**眼睛與手** Cloudflare 帳號的那七個動作也只有一份
*
* `rule.mjs` 是純判斷IO 由呼叫端注入`ResourceApi`本檔就是那個注入物的正貨
* CF REST API 實作 `ResourceApi`零依賴只用 global `fetch`
* Node 18+ Cloudflare Workers runtime 都能直接跑
*
* 為什麼連這層也要共用
* 判斷一致還不夠**看到的東西**也要一致
* 已部署的 worker 綁著什麼是從 `GET /workers/scripts/{script}/settings` 讀來的
* 如果兩條路各自寫一份 client隨便一個差異打錯端點 404 當錯誤漏了 per_page
* 少認一種欄位名都會讓其中一條路看不到既有綁定而看不到既有綁定的下一步
* 依規則就是**新建**Arcrun#97 的災情不需要規則寫錯只要眼睛不一樣就會重演
*
* 這裡**故意只有 `ResourceApi` 那七個方法**verifyAccess / subdomain / KV 讀寫
* 這些跟該用哪些資源無關的帳號操作留在各自的呼叫端不往共用層堆
*
* 🔴 除了同目錄的 `./rule.mjs`這支不准 import 任何東西共用層的價值在於
* 整個目錄複製到哪個 runtime 都能直接跑多一個外部依賴就少一條路吃得到
*/
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
*/
/**
* @typedef {object} CfResourceApiOptions
* @property {string} accountId
* @property {string} apiToken
* @property {typeof globalThis.fetch} [fetch]
* 注入用離線測試餵假帳號或宿主要用自己的 fetch預設 global fetch
*/
/**
* 建一個打真實 Cloudflare `ResourceApi`
*
* @param {CfResourceApiOptions} options
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
*/
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
const doFetch = fetchImpl ?? globalThis.fetch;
if (typeof doFetch !== 'function') {
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
}
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
const headers = {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
/**
* HTTP status 交回呼叫端自己判斷要區分404 不存在其他錯誤時用
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error:
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
}
/**
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<any>}
*/
async function cf(path, init) {
const { ok, status, result, error } = await cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result;
}
return {
cfRaw,
/**
* 讀一顆已部署 worker 現在綁著哪些資源**使用者那側的事實**Arcrun#97 的唯一真相源
*
* - script 不存在404 `{ deployed: false }`這是還沒部署不是錯誤
* - 其他任何失敗 throw呼叫端必須把它當我不知道**不是**它沒有
* 把查不到當成不存在就是 #97 的根因
*
* @param {string} script
* @returns {Promise<ScriptBindings>}
*/
async getScriptBindings(script) {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await cfRaw(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
/** @type {RawWorkerBinding[]} */
const raw = res.result?.bindings ?? [];
return {
deployed: true,
bindings: normalizeLiveBindings(raw),
vars: normalizeLiveVars(raw),
};
},
/** @returns {Promise<Map<string, string>>} title → id */
async listKvNamespaces() {
/** @type {Array<{id: string, title: string}>} */
const result = await cf('/storage/kv/namespaces?per_page=100');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
},
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
},
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
},
/**
* 無條件新建一顆 KV namespace
*
* 🔴 Arcrun#97這裡**故意沒有**找不到同名就順手建一顆 ensure 版本
* 照名字找 找不到 新建 綁上去正是把使用者實例洗成空的那條路
* 安裝器取的名字跟 binding 名不一樣永遠對不上 每次更新都新建
* 要不要建一律先過 `planResources`
*
* @param {string} title
* @returns {Promise<string>}
*/
async createKvNamespace(title) {
const result = await cf('/storage/kv/namespaces', {
method: 'POST',
body: JSON.stringify({ title }),
});
return result.id;
},
/**
* 無條件新建 D1沒有 ensure 版本理由同 createKvNamespaceArcrun#97
* @param {string} name
* @returns {Promise<string>}
*/
async createD1Database(name) {
const result = await cf('/d1/database', {
method: 'POST',
body: JSON.stringify({ name }),
});
return result.uuid;
},
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 / cosine**
* 已存在409 / already exists視為成功並行或重跑不該炸
* 沒有 ensure 版本要不要建 planResources 判斷這裡只負責建Arcrun#97
*
* @param {string} name
* @returns {Promise<string>}
*/
async createVectorizeIndex(name) {
const res = await cfRaw('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
},
};
}
+100
View File
@@ -0,0 +1,100 @@
// @ts-check
/**
* installer-entry.mjs 安裝器那條路的**唯一入口**
*
* 安裝器arcrun-rag `installer/oauth-prototype/worker.js`不必也不准自己判斷
* 該建哪些資源它只要呼叫這一支拿回每個 binding 該用哪顆資源
*
* ```js
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
*
* const r = await resolveInstanceResources({
* accountId, apiToken,
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
* mode: isUpdate ? 'update' : 'init',
* });
* if (r.blocked) {
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
* return showAndStop(r.blockers);
* }
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
* ```
*
* 為什麼安裝器不需要副本安裝器本來就會下載本 repo archive 當部署來源
* `.claude/rules/05-deploy-convention.md`WASM 來源
* `shared/resource-rule/` 就在那份 archive 直接 import 即可
* **不必再編一次不必貼一份也就不會有第二種答案**
*/
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
/**
* @typedef {object} ResolveOptions
* @property {string} accountId
* @property {string} apiToken
* @property {string[]} wranglerTomls worker wrangler.toml **內容**不是路徑
* @property {'update' | 'init'} mode 這台照定義裝過了沒
* @property {typeof globalThis.fetch} [fetch] 注入用測試宿主自帶 fetch
*/
/**
* @typedef {object} ResolveResult
* @property {boolean} blocked true = 什麼都沒建什麼都不該部署
* @property {string[]} blockers blocked 時的原因原文要原樣轉給使用者
* @property {Record<string, string>} bindings `${kind}:${binding}` 資源 idindex
* @property {Record<string, 'adopted'|'created'>} origin 同上 key 這顆是沿用還是新建
* @property {Record<string, Record<string, string>>} liveVars script 現有 plain_text var#106
*/
/**
* 決定這台實例每個 binding 該用哪顆資源照規則沿用既有只在確定沒人綁過時才新建
*
* @param {ResolveOptions} options
* @returns {Promise<ResolveResult>}
*/
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
/** @type {import('./rule.mjs').BindingRequirement[]} */
const requirements = [];
for (const toml of wranglerTomls) {
const parsed = parseWranglerRequirements(toml);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
/** @param {string[]} blockers @returns {ResolveResult} */
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
if (requirements.length === 0) {
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
}
let plan;
try {
plan = await planResources(api, requirements, mode);
} catch (e) {
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
}
if (plan.blockers.length > 0) return stop(plan.blockers);
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
let resolved;
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
}
/** @type {Record<string, string>} */
const bindings = {};
/** @type {Record<string, 'adopted'|'created'>} */
const origin = {};
for (const [key, r] of resolved) {
bindings[key] = r.value;
origin[key] = r.origin;
}
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
}
+570
View File
@@ -0,0 +1,570 @@
// @ts-check
/**
* rule.mjs 這個實例該用哪些資源**唯一一份**規則
*
*
* 這份檔案為什麼在這裡`shared/`不在 `cli/`
*
* leo 2026-08-12根本就不應該在 CLI我要的是一個大家都可以用到的規則
*
* `.claude/rules/07-thin-shell.md` 的判準口訣
* 這段邏輯換一個介面要不要重寫要重寫 它是能力該在共用層
*
* 該沿用哪幾顆資源換到安裝器就得重寫一次 它是**能力**不是薄殼的事
* 而它原本住在 `cli/src/lib/resource-resolver.ts` 那本身就是違規
* 後果也真的發生了`acr` 那條有這條規則安裝器那條沒有於是安裝器照名字找
* 找不到就建新的空的 Arcrun#97我按了更新工作流和登入全不見了
*
* 為什麼不是 cypher-executor API 端點薄殼原則的標準答案
* **自舉**這條規則要在決定怎麼裝怎麼更新的當下就用得到而那個當下
* cypher 可能還不存在安裝器的工作正是把它生出來或正要被覆蓋
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**
* 把它送去一顆平台託管的 worker 換一個答案等於①讓能不能安裝綁在平台是否活著
* ②把使用者的帳號拓撲交給第三方兩件都不該為了形式上的漂亮而做
*
* 薄殼原則要求的是能力只實作一次不是能力一定要是 HTTP
* 這條規則是**純函式**唯一的 IO 由呼叫端注入 `ResourceApi`
* 所以它用不著變成服務一份零依賴的 ESM 就能讓每條路吃到同一份判斷
*
* 怎麼讓兩條路吃到同一份而不是各留一份
* 本檔是**唯一被人手維護的實作**零依賴不吃任何 node 內建Workers runtime 可直接跑
* · `acr``cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**
* `scripts/sync-resource-rule.mjs` 產生CLI 要能單獨 npm publish
* 套件目錄外的檔案打不進 tarball故必須有這一份
* `npm run build` / `npm test` 都會跑 `--check`內容一漂就紅
* `cli/harness/`產生物世代閘的既有慣例
* · 安裝器 / 任何 Worker安裝器本來就會下載本 repo archive部署來源
* `.claude/rules/05-deploy-convention.md`WASM 來源
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可**不需要再編一次也不留副本**
* 用法見同目錄 README.md
*
*
* 規則本身leo 的兩句話
*
* 如果你沒有裝就是新的如果你已經有原來叫什麼名字就繼續用下去
*
* 判準是**這顆 worker 現在綁著誰**不是有沒有叫這個名字的資源
* 1. **已部署的 worker 上綁著什麼那就是事實** 原封不動沿用不管那顆資源叫什麼名字
* 2. **只有確定沒有任何人綁過它才准新建**新版本新增的 binding或真的全新帳號
* 3. **只要有一點說不準就整趟停手**讀不到綁定綁著的資源不見了同一個 binding 指向兩顆
* 該更新的 worker 一顆都不在**什麼都不建什麼都不部署**把話說清楚讓人來判斷
*
* 為什麼拆成 plan / apply 兩段
* `planResources()` **完全不寫入**只回一份要沿用什麼要新建什麼有什麼不敢動的
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行
* 被擋下的時候一顆資源都不會被建出來**結構上的保證**
* 不是靠某個人記得在對的地方寫 early return#97 正是死在先動手後判斷
*
* 🔴 這份檔案沒有 import也不准有任何依賴都會讓某一條路吃不到它
*/
/**
* 這支負責的資源種類要加新種類R2/Queue/Hyperdrive就加在這裡
* 一律走同一道門不准任何呼叫端自己照名字 ensure繞過去
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
*/
/**
* 從已部署 worker 上讀回來的一條綁定`value`KV/D1 是資源 idVectorize index
* @typedef {object} LiveBinding
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
*/
/**
* @typedef {object} ScriptBindings
* @property {boolean} deployed
* false = 這顆 worker 在帳號上還不存在全新部署不是讀取失敗讀取失敗要 throw
* @property {LiveBinding[]} bindings
* @property {Record<string, string>} [vars]
* 這顆 worker 現在掛著的 `plain_text` var
*
* 🔴 Arcrun#106#97 只把資源類綁定當成事實沿用KV/D1/Vectorize
* plain_text var 整批沒人管 重部署把它們洗成 repo toml 的預設值
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`安裝器注入的版本標籤
* 更新完就消失Portal 設定頁變成無法讀取目前版本
* **保留了櫃子沒保留櫃子上的標籤**這個欄位就是那些標籤
*/
/**
* 規則需要的 CF 能力收窄成介面方便離線測試餵假帳號也讓安裝器用自己的 fetch 實作
* @typedef {object} ResourceApi
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
* @property {() => Promise<Map<string, string>>} listKvNamespaces title id
* @property {() => Promise<Map<string, string>>} listD1Databases name uuid
* @property {() => Promise<string[]>} listVectorizeIndexes
* @property {(title: string) => Promise<string>} createKvNamespace
* @property {(name: string) => Promise<string>} createD1Database
* @property {(name: string) => Promise<string>} createVectorizeIndex
*/
/**
* 這顆 worker 需要這個 bindingcreateName 只在**真的要新建**時才會被拿來當名字用
* @typedef {object} BindingRequirement
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} worker 需要它的 worker script = wrangler.toml `name`
* @property {string} createName
*/
/**
* @typedef {object} PlannedAdopt
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
*/
/**
* @typedef {object} PlannedCreate
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} createName
* @property {string[]} wantedBy
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding shareSameResource建一顆大家共用
*/
/**
* @typedef {object} ResourcePlan
* @property {PlannedAdopt[]} adopt
* @property {PlannedCreate[]} create
* @property {string[]} blockers 非空 = 整趟停手applyResourcePlan 會拒絕執行
* @property {Map<string, Record<string, string>>} liveVars
* 每顆**已部署** worker 現在掛著的 plain_text varscript /未部署的不在裡面
*
* Arcrun#106讀綁定的時候本來就把整份 `bindings[]` 拿回來了var 就在同一份回應裡
* 順手帶出來**不另外打一次 API**也不新增一種查不到的失敗模式
* 讀不到綁定這件事已經在上面 blockers 那一關擋掉了
*/
/**
* @typedef {object} ResolvedResource
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* @property {string} [from]
*/
/**
* @typedef {object} WranglerRequirements
* @property {string} script worker script toml 頂層 `name`空字串 = 這份 toml 沒宣告 name不該發生
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
*/
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
/** @param {string[]} blockers */
constructor(blockers) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
/** @type {string[]} */
this.blockers = blockers;
}
}
/**
* @param {ResourceKind} kind
* @param {string} binding
* @returns {string}
*/
export function bindingKey(kind, binding) {
return `${kind}:${binding}`;
}
/** @type {Record<ResourceKind, string>} */
export const KIND_LABEL = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
/**
* @param {unknown} e
* @returns {string}
*/
function msg(e) {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源要不要新建**不寫入任何東西**
*
* @param {ResourceApi} api
* @param {readonly BindingRequirement[]} requirements
* @param {'update' | 'init'} mode
* 'update' = 這台照定義已經裝過了見下方一顆都不在規則'init' = 全新安裝允許從零建
* @returns {Promise<ResourcePlan>}
*/
export async function planResources(api, requirements, mode) {
/** @type {string[]} */
const blockers = [];
/** @type {PlannedAdopt[]} */
const adopt = [];
/** @type {PlannedCreate[]} */
const create = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
/** @type {Map<string, LiveBinding[]>} */
const live = new Map();
/** @type {Map<string, Record<string, string>>} */
const liveVars = new Map();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) {
live.set(script, res.bindings);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
/** @type {Map<string, BindingRequirement[]>} */
const byKey = new Map();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
/** @type {Map<ResourceKind, Set<string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = async (kind) => {
const hit = existingCache.get(kind);
if (hit) return hit;
/** @type {Set<string>} */
let set;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
/** @type {Array<{value: string, script: string}>} */
const found = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Set<string>} */
let existing;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
}
/**
* 收斂不同 binding 其實是同一顆資源的情況
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`不是使用者那側的資源名
* cypher `CREDENTIALS_DB` kbdb `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告這兩個綁定指向同一顆庫 #97 那種拿名字去猜使用者的資源是兩回事
*
* 沒有這一步會出兩種錯
* 全新安裝時建出兩顆同名 D1KBDB 的資料與 credential 目錄從此分家
* 一邊已部署沿用既有另一邊沒有新建一顆空的 半套資料比全壞更難查
*
* @param {PlannedAdopt[]} adopt
* @param {PlannedCreate[]} create
* @param {Map<string, BindingRequirement[]>} byKey
* @returns {PlannedCreate[]}
*/
function shareSameResource(adopt, create, byKey) {
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
const declaredName = (kind, binding) =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
/** @type {PlannedCreate[]} */
const out = [];
/** @type {Map<string, PlannedCreate>} */
const groups = new Map();
for (const c of create) {
const groupKey = `${c.kind} ${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* plan 動手沿用的原樣帶出來該建的才建
* 有任何 blocker 直接丟 ResourcePlanBlocked**一顆都不建**
*
* @param {ResourceApi} api
* @param {ResourcePlan} plan
* @returns {Promise<Map<string, ResolvedResource>>}
*/
export async function applyResourcePlan(api, plan) {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
/** @type {Map<string, ResolvedResource>} */
const out = new Map();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
/** @type {string[]} */
const madeSoFar = [];
for (const c of plan.create) {
/** @type {string} */
let value;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
/**
* wrangler.toml table 資源種類需求解析與注入共用同一張表兩邊才不會對不上
* @type {Record<string, ResourceKind>}
*/
export const TABLE_KIND = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* wrangler.toml 抽出這顆 worker 需要哪些資源綁定
*
* 刻意寫成行掃描而不引 TOML parser注入端injectWranglerConfig本來就是純文字操作
* 兩邊用同一種視角看這份檔案才不會對不上註解掉的區塊**不算需求**
* kbdb `[[vectorize]]` 預設是註解狀態要開語義查詢時才會被取消註解 那時才成為需求
*
* 也是零依賴的一部分不引 TOML parser 安裝器 import 這支不必多裝任何東西
*
* @param {string} toml
* @returns {WranglerRequirements}
*/
export function parseWranglerRequirements(toml) {
let script = '';
let seenTable = false;
/** @type {WranglerRequirements['bindings']} */
const bindings = [];
/** @type {ResourceKind | null} */
let kind = null;
let binding = '';
let createName = '';
const flush = () => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
// ─────────────────────────────────────────────────────────────────────────────
/**
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
* 同一種資源在不同 API 版本欄位名不一故全都收
*
* @typedef {object} RawWorkerBinding
* @property {string} [type]
* @property {string} [name]
* @property {string} [namespace_id]
* @property {string} [id]
* @property {string} [database_id]
* @property {string} [index_name]
* @property {string} [text] `plain_text` 綁定的值#106secret_text 不會回值本來就讀不到也不該讀
*/
/**
* CF binding 陣列收斂成規則認得的三種資源不認得的型別直接略過
*
* 🔴 這支**刻意放在規則裡**不留在各自的 CF client
* 什麼才算這顆 worker 綁著某顆資源是規則的一部分
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來例如一邊認 `namespace_id`
* 另一邊只認 `id`於是一邊看得到綁定另一邊看不到 後者又去新建了
*
* @param {RawWorkerBinding[]} raw
* @returns {LiveBinding[]}
*/
export function normalizeLiveBindings(raw) {
/** @type {LiveBinding[]} */
const out = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
/**
* 抽出已部署 worker 上的 `plain_text` var#106
*
* 只收 `plain_text`**`secret_text` 一律不碰**CF 本來就不回值也不該被搬來搬去
* wrangler deploy 不會動 secret它們自己會留著
*
* @param {RawWorkerBinding[]} raw
* @returns {Record<string, string>}
*/
export function normalizeLiveVars(raw) {
/** @type {Record<string, string>} */
const out = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}
+60
View File
@@ -0,0 +1,60 @@
// @ts-check
/**
* demo.mjs 安裝器那條路的**可獨立執行**證明
*
* node shared/resource-rule/tests/demo.mjs
*
* 這支只 import `shared/resource-rule/`**沒有 node_modules沒有建置步驟**
* 跑得起來本身就是安裝器把 repo archive 拉下來就能直接用這句話的證據
* 對照組`acr` 那條要先 npm ci + TS 轉譯才跑得動兩條路差在外殼判斷是同一份
*
* 三種情境各跑一次印出每個 binding 選到哪顆資源以及這一趟建了幾顆
*/
import { resolveInstanceResources } from '../installer-entry.mjs';
import { makeAccount, SCENARIOS, WORKER_NEEDS } from './fixture-account.mjs';
/** 用 fixture 的需求組出各 worker 的 wrangler.toml 內容。 */
function tomls() {
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
for (const d of need.d1) {
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
}
return t;
});
}
const order = /** @type {const} */ (['fresh', 'installed', 'renamed']);
console.log('安裝器那條路(只 import shared/resource-rule/,零依賴、零建置)\n');
for (const scenario of order) {
const mode = scenario === 'fresh' ? 'init' : 'update';
const account = makeAccount(scenario);
const r = await resolveInstanceResources({
accountId: 'acct-demo',
apiToken: 'tok-demo',
wranglerTomls: tomls(),
mode,
fetch: account.fetch,
});
console.log(`── ${scenario}mode=${mode}):${SCENARIOS[scenario].label}`);
if (r.blocked) {
console.log(' ⛔ 停手,一顆資源都沒建:');
for (const b of r.blockers) console.log(`${b}`);
console.log('');
continue;
}
for (const key of Object.keys(r.bindings).sort()) {
console.log(` ${key.padEnd(30)}${r.bindings[key].padEnd(26)} ${r.origin[key]}`);
}
console.log(
` 本趟新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆、` +
`Vectorize ${account.created.vectorize.length}` +
`|沿用既有版本標籤 ARCRUN_BUNDLE_VERSION=` +
`${r.liveVars['arcrun-cypher-executor']?.ARCRUN_BUNDLE_VERSION ?? '(無,全新安裝)'}\n`,
);
}
@@ -0,0 +1,202 @@
// @ts-check
/**
* fixture-account.mjs 一個假的 Cloudflare 帳號做成 **`fetch` 替身**
*
* 為什麼是 fetch 替身不是假的 ResourceApi 物件
* 本票要證的是`acr` 那條與安裝器那條跑出來的決定必須一致
* 如果兩條路各自餵一個假的 `ResourceApi`那就只測到了 `rule.mjs` 的判斷
* **完全跳過了怎麼把 CF 回應讀成事實** Arcrun#97 的重演只需要眼睛不一樣就夠了
* 一邊把 404 當錯誤一邊漏認 `namespace_id`
* `fetch` 這一層假起兩條路就是真的走完整條鏈HTTP 解析 判斷
*
* 零依賴 ESMNode Workers 都能跑
*/
/** arcrun 各 worker 在 wrangler.toml 裡宣告的 KV binding 名(= 需求,不是資源名)。 */
export const KV_BINDINGS = [
'WEBHOOKS', 'CREDENTIALS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV',
'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', 'OAUTH_KV',
];
/** 這台實例上有資源綁定的四顆 worker,以及各自需要的綁定。 */
export const WORKER_NEEDS = {
'arcrun-cypher-executor': {
kv: ['EXEC_CONTEXT', 'WEBHOOKS', 'CREDENTIALS_KV', 'ANALYTICS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV'],
d1: [{ binding: 'CREDENTIALS_DB', database_name: 'arcrun-kbdb' }],
},
'arcrun-registry': { kv: ['SUBMISSIONS_KV', 'ANALYTICS_KV'], d1: [] },
'arcrun-mcp': { kv: ['OAUTH_KV'], d1: [] },
'arcrun-kbdb': { kv: [], d1: [{ binding: 'DB', database_name: 'arcrun-kbdb' }] },
};
/**
* WORKER_NEEDS 攤成 `BindingRequirement[]`兩條路都用**同一份需求**進去
* 才能證明差異如果有來自實作而不是輸入
* @returns {Array<{kind: 'kv_namespace'|'d1', binding: string, worker: string, createName: string}>}
*/
export function requirements() {
const out = [];
for (const [worker, need] of Object.entries(WORKER_NEEDS)) {
for (const b of need.kv) out.push({ kind: 'kv_namespace', binding: b, worker, createName: b });
for (const d of need.d1) {
out.push({ kind: 'd1', binding: d.binding, worker, createName: d.database_name });
}
}
return out;
}
/**
* 三種情境`titleFor` 決定使用者帳號上那顆資源實際叫什麼名字
* 這正是 #97 的病根所在規則**不准**拿名字當識別
*
* @typedef {'fresh' | 'installed' | 'renamed'} Scenario
*/
/** @type {Record<Scenario, {label: string, deployed: boolean, titleFor: (binding: string) => string}>} */
export const SCENARIOS = {
fresh: {
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
deployed: false,
titleFor: (b) => b,
},
installed: {
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>',
deployed: true,
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
},
renamed: {
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
deployed: true,
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
},
};
/**
* 建一個假帳號 + 對應的 `fetch` 替身
*
* @param {Scenario} scenario
* @returns {{
* fetch: typeof globalThis.fetch,
* created: {kv: string[], d1: string[], vectorize: string[]},
* userData: {workflows: string[], sessions: string[], libraries: string[]},
* kvIdFor: (binding: string) => string | undefined,
* d1Id: string,
* requestLog: string[],
* }}
*/
export function makeAccount(scenario) {
const spec = SCENARIOS[scenario];
/** title → id */
const kv = new Map();
/** name → uuid */
const d1 = new Map();
/** @type {string[]} */
const vectorize = [];
/** script → CF `/settings` 回應裡的 bindings[] 原始形狀 */
const scripts = new Map();
const created = { kv: [], d1: [], vectorize: [] };
const requestLog = [];
// 使用者的東西——驗「更新完還在不在」用。掛在資源 id 上,不是掛在名字上。
const userData = {
workflows: ['webhook:leo:daily-digest', 'webhook:leo:inbox-sync', 'webhook:leo:rag-ingest'],
sessions: ['session:leo-abc123'],
libraries: ['general', '課程', '客戶', '研究'],
};
const kvIdByBinding = new Map();
const D1_ID = 'd1id-kbdb-REAL';
if (spec.deployed) {
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
for (const b of KV_BINDINGS) {
const id = `kvid-${b.toLowerCase()}-REAL`;
kv.set(spec.titleFor(b), id);
kvIdByBinding.set(b, id);
}
d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID);
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
const bindings = [];
for (const b of need.kv) {
bindings.push({ type: 'kv_namespace', name: b, namespace_id: kvIdByBinding.get(b) });
}
for (const d of need.d1) bindings.push({ type: 'd1', name: d.binding, id: D1_ID });
// #106plain_text var 也在同一份回應裡
bindings.push({ type: 'plain_text', name: 'ARCRUN_BUNDLE_VERSION', text: '1.4.33' });
scripts.set(script, bindings);
}
}
/** @param {unknown} result @param {number} [status] */
const ok = (result, status = 200) =>
new Response(JSON.stringify({ success: true, result, errors: [] }), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** @param {string} message @param {number} status */
const fail = (message, status) =>
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** @type {typeof globalThis.fetch} */
// @ts-expect-error — 測試替身只實作用得到的那幾條路徑
const fakeFetch = async (input, init) => {
const url = new URL(typeof input === 'string' ? input : String(input));
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
const method = (init?.method ?? 'GET').toUpperCase();
requestLog.push(`${method} ${path}${url.search}`);
const body = init?.body ? JSON.parse(String(init.body)) : null;
// 已部署 worker 的綁定
const m = path.match(/^\/workers\/scripts\/([^/]+)\/settings$/);
if (m && method === 'GET') {
const script = decodeURIComponent(m[1]);
if (!scripts.has(script)) return fail('workers.api.error.script_not_found', 404);
return ok({ bindings: scripts.get(script) });
}
if (path === '/storage/kv/namespaces' && method === 'GET') {
return ok([...kv].map(([title, id]) => ({ id, title })));
}
if (path === '/storage/kv/namespaces' && method === 'POST') {
const id = `kvid-NEW-${created.kv.length + 1}`;
kv.set(body.title, id);
created.kv.push(body.title);
return ok({ id, title: body.title });
}
if (path === '/d1/database' && method === 'GET') {
return ok([...d1].map(([name, uuid]) => ({ uuid, name })));
}
if (path === '/d1/database' && method === 'POST') {
const uuid = `d1id-NEW-${created.d1.length + 1}`;
d1.set(body.name, uuid);
created.d1.push(body.name);
return ok({ uuid, name: body.name });
}
if (path === '/vectorize/v2/indexes' && method === 'GET') {
return ok(vectorize.map((name) => ({ name })));
}
if (path === '/vectorize/v2/indexes' && method === 'POST') {
vectorize.push(body.name);
created.vectorize.push(body.name);
return ok({ name: body.name });
}
return fail(`fixture 沒有實作這條路徑:${method} ${path}`, 501);
};
return {
fetch: fakeFetch,
created,
userData,
kvIdFor: (binding) => kvIdByBinding.get(binding),
d1Id: D1_ID,
requestLog,
};
}
@@ -376,6 +376,23 @@
真人用滑鼠點擊複製鈕(受限於自動化環境,上述已用既有鈕做過同構對照)。
執行範圍:`console-ui/public/portal/index.html`(新增面板 + JS)。未動後端、未部署。
- [x] **Arcrun#108(任務層修正,2026-08-13):資料面租戶字串收斂到唯一產地**
P3 的 `/portal/data/*` 一律用 `portalTenant(env) = env.CONSOLE_TENANT || 'leo'``owner_id`
那個字串是**部署環境變數**,而知識是 CLI/同步小幫手/MCP 用**實例 namespace**
`~/.arcrun/config.yaml``api_key`)寫進去的——兩個來源會漂。leo 實撞:
藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組(他的在 `owner_id=bfezv28v`)。
`Arcrun#105``env.MCP_OWNER_NAMESPACE || "leo"`)同形,低一層。
**修法**:新增 `cypher-executor/src/lib/tenant.ts` 當唯一產地——
`knowledgeOwner(env)` 回 branded `TenantId``ARCRUN_NAMESPACE``CONSOLE_TENANT`
誠實丟錯,**無字面預設值**),資料面過濾一律經 `ownerQuery()/ownerField()`
帳號子 namespacedesign D-2 的 `{tenant}::portal`)改用 `accountTenant(env)`(回 `string`
型別上不可能流進資料面),**帳號落點一字不動**(動了舊實例登不進去)。
`acr update` 先驗(`GET /kbdb/map?owner_id=<api_key>` 查得到庫)才注入 `ARCRUN_NAMESPACE`
空地圖改回四態(`no_library_grant``filtered_out``scope_mismatch``confirmed_empty`),
沿 Arcrun#100「讀不到就說讀不到」。庫權限過濾一字未動(回歸測試釘住)。
防複發:`scripts/build-worker-artifacts.mjs` 出貨前掃描,違規編不出成品。
規範寫入 `.claude/rules/02-forbidden.md` 第六類、`system-dev/wiki/mistakes.md` #26
## 第二波(不在本 SDD 動工範圍,掛號)
- MCP token 綁庫集合(design §9PR#15 擴充,只動 `mcp/`
+40
View File
@@ -547,6 +547,43 @@ repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。
---
## 26. 「身分來自環境變數」——同一句話寫錯兩次,因為規則有、機制沒有(2026-08-12Arcrun#105#108
**症狀**leo 打開藏書地圖回 **0 個庫**,同一分鐘 KBDB 裡有 **1854 條三元組**
`arcrun_whoami` 顯示 admin/全部知識庫,`kbdb_search` 也查得到東西——**只有地圖那格是空的**。
**根因**(不是資料掉了,是讀寫兩端各拿一個來源):
| | 寫入端用什麼當 owner_id | 讀取端用什麼過濾 |
|---|---|---|
| 之前 | `~/.arcrun/config.yaml``api_key`CLI push/小幫手上傳/MCPleo `bfezv28v` | `env.CONSOLE_TENANT \|\| "leo"`repo toml 帶的**官方 prod 值** |
`acr` 從來不注入 `CONSOLE_TENANT`,所以那個 `"leo"` 不是理論邊角,**是每台 self-hosted 實例的實際行為**。
**這是第二次**。`#105` 前一天才修掉 `env.MCP_OWNER_NAMESPACE || "leo"`——同一句話,換一個檔案。
**判準(下次照用)**
1. **「這個字串是用來決定誰的資料嗎?」** 是 → 它是身分,不是部署設定。
身分要嘛來自請求(登入 session/`X-Arcrun-API-Key`),要嘛來自「寫入端用的那個值」,
**不可以是一個各自抄一份的環境變數預設值**
2. **`|| '預設值'` 出現在身分解析路徑上=把「這台機器沒設定」偽裝成「你沒有資料」**。
解析不到就誠實丟錯(#100 同一條:讀不到就說讀不到)。
3. **「規則存在但沒有機制驗證」=它會再犯**。所以本次除了修 bug,還留下三道會擋的:
- 型別閘:`TenantId` 只能由 `cypher-executor/src/lib/tenant.ts` 產出,
資料面過濾只吃 `ownerQuery()/ownerField()` → 拿隨手一個 string 去過濾,`tsc` 當場不給過。
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編成品前先跑租戶來源檢查
**違規的碼編不出成品、出不了貨**(不是「有人記得跑才會發現」)。
- 這道閘自己可測:規則是純函式(`cypher-executor/scripts/tenant-source-rules.mjs`),
`tests/tenant-gate.test.ts` 逐條驗「壞例子會擋、11 種合法寫法零誤攔」。
**誤攔比漏攔更容易殺死一道閘**——被擋煩了就有人把它關掉。
4. **修法不能比 bug 更危險**`acr update` 注入 `ARCRUN_NAMESPACE` 前**先驗**
`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫)。無條件覆蓋會把「知識本來就在
`CONSOLE_TENANT` 底下」的一鍵安裝實例指向空的那一格——那是 #97/#106 那類
「更新一次把人家的東西弄不見」。
**順手挖出的同族**(同一道閘一次抓到):`console-dashboard.ts`**4 處**
`console-auth.ts` 有 1 處相同寫法——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。
## 快速檢查清單(做新功能前)
- [ ] 這是工作流還是零件?問「有必要嗎?」
@@ -568,3 +605,6 @@ repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。
- [ ] 本地/Gitea 改完 code 想 `acr update` 部署?先確認:它抓的是 GitHub codeload tarball,不是你剛改的目錄(#23
- [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25
- [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25
- [ ] 寫下 `env.X || '預設值'`?先問「這個字串是用來決定誰的資料嗎?」是 → 它是身分不是設定,不准有字面預設值(#26
- [ ] 要用某個字串過濾 owner_id?確認它與**寫入端**用的是同一個來源,不是另一份手抄的環境變數(#26
- [ ] 留了一道新的閘?它自己有測試嗎、誤攔案例驗過嗎、擋不擋得到自己?(#26