Compare commits

..

48 Commits

Author SHA1 Message Date
uncle6me-web d7a98f53e7 重編出貨成品——kbdb 成品終於裝的是 v7,不是舊模型
出貨管線 preflight 擋下:kbdb 的成品比源碼舊 3 顆 commit
  ceb7638 樹狀 record 模型第一刀(entry_values 拆表,v7 定稿實作)
  aa49b4e 刪 record 不再刪掉別人還指著的 entry(Arcrun#128 配套)
  5b22d56 createRecord 的 slot 可指向既有 entry(Arcrun#128)

🔴 這道閘剛好攔住了今晚整件事的意義被抹掉:成品沒重編就出貨,
   bundle 裡裝的是舊 kbdb ⇒ youlin 依然「資料新、程式舊」,
   而版本號會照跳,看起來像出貨成功。

重編 23/23,實際變動只有 3 個檔(kbdb/cypher-executor/manifest)——
其餘 20 顆內容指紋沒變、不虛增版本,指紋機制運作正常。
arcrun-kbdb 現在 source=ceb7638d、sha256=47b09d60f8d1。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 23:54:09 +08:00
uncle6me-web cacaa33f7d merge: 免金鑰預設路的卡形狀——格式契約收歸 daemon 一份(Arcrun#134)
病徵:portal.ts 的 /portal/daemon/extract 產舊四段格式,而那是免金鑰預設路
=多數用戶實際走的那條 ⇒ daemon 修好了、雲端沒修 ⇒ 對多數用戶等於沒修。

修法比要求的更好:不是把 prompt 複製兩份再靠註解同步,而是
**讓 daemon 擁有格式契約(自帶 prompt),雲端端點退化成「用你自己的 AI 跑一段生成」
的執行器,它不認得也不需要認得卡的形狀**。
⇒ 兩份契約合成一份——與 v7「兩套機制合成一套」同一個手法。

它自己寫下的理由正是今天的主題:
「靠註解叮嚀『兩邊要一起改』同步⋯⋯本端點沒跟上 ⇒ 叮嚀式同步實證失效,這張票就是證據」

總管審過的:
- 舊 daemon(不帶 prompt)走 legacy 路,行為與回應完全不變;legacy 提示詞刻意凍結
  並註明「要改卡的形狀,去 collector/ 改那唯一一份」
- prompt 模式放寬 max_tokens 到 8192,理由寫在碼上(2048 會把 JSON 攔腰截斷)
- 🔴 測試對照:分支 14 failed/447 passed(461 支)vs main 14 failed/445 passed(459 支)
  ⇒ 同樣那 14 個是 main 上的既有紅(Arcrun#131),本分支零新增紅,且多的 2 支都過

⚠️ 併 main ≠ 送達:要部署 cypher worker,用戶才會走到新的那條。

📍 InkStoneCo#44 第 ④ 環|Arcrun#134

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:31:04 +08:00
uncle6me-web fda9d169d5 Merge remote-tracking branch 'gitea/main' into work/extract-wiki-json-0815 2026-08-15 22:28:30 +08:00
uncle6me-web 7b478378aa merge: 樹狀 record 模型第一刀——record 有身分、關係是唯一機制、entry_values 拆表
規格=pending-changes「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。
地基(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
⇒ 一個指向就是一條關係 ⇒ entry_values 該死的理由是「它是關係的第二套實作」。

總管審過的(自己跑的,不是聽回報):
- npm test 236/236 綠
- 0007 的形狀對:三根型別化指標欄(src_id/rel_id/dst_id,v7 定案載體)
  + DROP TABLE entry_values,19 處 IF NOT EXISTS/INSERT OR IGNORE
- 遷移測試逐條斷言:每格一條關係列/每 record 一顆身分/value entries 一列不動/
  舊表拆掉/讀端讀得回同樣資料/🔑 第二次逐句重放零錯誤且快照逐字相等(冪等被證明)/
  乾淨後零孤兒、人為斷鏈掃得到並指出斷在哪一端
- relation-orphans.ts 的註解誠實說明它為什麼存在:拆表後同一張表指自己不能建 FK
  ⇒ 斷鏈不再被資料庫擋下,改由巡檢主動找;並列出三種會產生孤兒的情況

⚠️ 這條在 youlin 上動過真的遠端 D1(ALTER/DROP/大量 INSERT)。
   總管事後逐項查證範圍:CF 帳號只出現 youlin 一個、leo21c 真庫 516884ce 零命中。
   而「派工只寫『在 youlin 實跑』沒指定哪一顆」是總管授權寫太寬,已落帳 mistakes.md。

⚠️ 併 main ≠ 送達:youlin 的 worker 仍是舊碼(資料新、程式舊的中間狀態)。
   leo 已裁「當然往前」⇒ 接下來部署。

📍 InkStoneCo#44 第 ⑤ 環|Arcrun#132

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:26:02 +08:00
uncle6me-web ceb7638d74 feat(kbdb): 樹狀 record 模型第一刀——record 有身分、關係是唯一機制、entry_values 拆表(v7 定稿實作)
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。
模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」

- 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數
  (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+
  每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列
  (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values
  (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。
- record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留,
  驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。
- entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry
  接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。
- 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列,
  LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀),
  GET /maintenance/relation-orphans 唯讀巡檢。
- cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS,
  整檔送 /query 會在重跑時假紅)。
- 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描;
  釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。

遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型
與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:48 +08:00
uncle6me-web 9fa88065ab feat(portal): /portal/daemon/extract 接受 daemon 自帶提示詞——卡片格式契約回歸單一真相源(Arcrun#134)
免金鑰預設路(Workers AI)一直用雲端自己那份舊格式 prompt(一句話定義/要點/
關鍵實體/關聯),而 daemon 端 2026-08-15 已改成「LLM 只回 JSON 判斷、wikishape.go
機械組卡」(InkStoneCo#44 ④)⇒ 走預設路的用戶拿到次級卡。兩份 prompt 靠註解
叮嚀「兩邊要一起改」同步,這張票就是叮嚀失效的證據。

修法=契約搬回唯一一處:daemon 送 {page_name, text, prompt},雲端在 prompt 模式
只當執行器(用實例自己的 env.AI 跑生成、max_tokens 對齊 gemma 路的 8192),
原文以 {success, output} 回傳,淨化與解析全在 daemon。雲端不再認得卡的形狀。

相容兩個方向都有路:舊 daemon(無 prompt)走 legacy 路,行為與回應完全不變;
新 daemon 撞舊雲端(回 card 無 output)由 daemon fallback 到 legacy 卡處理。

測試:portal-admin +2(prompt 單獨成立=501 探針非 400;legacy 齊欄行為凍結),
全套 447 passed,14 failed 與基準(main)逐一相同=非本改動引入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:21:48 +08:00
uncle6me-web 969acff325 merge: n8n KBDB 節點(薄殼)——對照表 那半
「有效才正式開發」的下一格:紙上考卷過了(Haiku 6/7、Sonnet 7/7),換真的。
範圍限對照表標  的動作;🔴 那半(關係整組、archive 兩個)沒做,等資料模型定案。

總管審過的(自己驗的):
- 每個動作剛好打一支 API,零拼裝——薄殼原則守住
  (POST/GET/PATCH /kbdb/records、by-template、POST/GET /kbdb/templates…)
- 🔴 那半沒有偷混進來;而且它把「禁止在這裡拼裝:對照表標 🔴/◐ 而 API 沒有的能力」
  寫成註解留給下一個人——不只沒犯,還立了牌子
- npm test:20 passed

⚠️ 沒有自己驗的(是它的回報,不是總管的實測):
「八個動作在真 n8n 引擎跑過、KBDB 落庫、獨立 curl 查得回」——
那需要一台真的 n8n,總管沒複跑。**併的是程式碼形狀與單元測試,不是端到端。**

⚠️ 併 main ≠ 用戶拿得到:沒 npm publish、沒裝上任何 n8n。狀態 ◐ 已改未送達。

📍 InkStoneCo#44 第 ⑤ 環的前半|對照表:system-dev/docs/4-guides/kbdb-動作對照表.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:07:33 +08:00
uncle6me-web 87f6de7c9f feat(n8n): KBDB community node 薄殼——對照表 那半的八個動作
leo 2026-08-15「有效才正式開發」下一格:紙上考卷驗過的動作集做成真的 n8n 節點。
詞彙照定案(sheet/field/record,InkStoneCo system-dev/wiki/kbdb-詞彙表.md)。

- 動作 ↔ API 映射收斂在 nodes/Kbdb/actions.ts(純函式),20 個 vitest 釘死
  ——就是動作對照表  那半的機器版;🔴/◐ 的動作刻意不存在,呼叫丟
  「沒有這個能力」(07-thin-shell §3.1:API 沒有的能力不拼裝)
- 對外走 cypher-executor /kbdb/* proxy(X-Arcrun-API-Key),不直連 kbdb worker
- Add a field 不掛:proxy 無 PATCH /kbdb/templates(實測 404);base 端
  PATCH /templates/:id 語意是 slots 整組覆蓋(本地實測),不是加一欄

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:42:53 +08:00
uncle6me-web 576afa2364 fix(wiki): 修一條四項的「三元組」——述詞裡不能再有 >>
範例產生器把述詞寫成「實作 >> 結構性閘」,產出
「零件 PR 審核閘 >> 實作 >> 結構性閘 >> [[規則記不住]]」四項,不是三元組。
InkStoneCo 的 wiki-lint 已加第十六項檢查(一行 >> 超過兩個就報)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:19:16 +08:00
uncle6me-web 730c2af245 merge: Arcrun#128 外鍵接上去——slot 可以指向既有 entry,不再複製
總管逐筆審過三筆,並自己跑過測試(不是採信交件):

· kbdb          218 → 232 綠(新增 14 支),零回歸
· cypher-executor  445/459;那 14 支紅在 gitea/main 上就是紅的(main 439/453)
                   ⇒ 零回歸。順帶記下:那套測試已經不是閘了,另案

三筆各是什麼:
  5b22d56  createRecord 新增 entry_ids 通道,與 values 並存(舊呼叫端一字不改)
  aa49b4e  刪 record 不再刪掉別人還指著的 entry —— **我的派工單沒提,它自己想到的必然配套**
  9ba6ba7  cypher-executor 的 POST /kbdb/records 開通同一條通道

它自己補了兩件我沒指定的:
① 🔴 租戶邊界——這條新路徑讓呼叫端可以自己指定 entry_id,不檢查歸屬的話
   任何人都能把別人的 entry 掛進自己的 record 再讀回內容 ⇒ 繞過租戶邊界的門
   (它引 .claude/rules/02-forbidden.md §6.1 當依據)
② 對照組測試——「同樣五個 slot 給字串 → entries 增加五筆」
   只證修法有效沒人知道原本壞在哪;它同時證明了問題存在

驗收五條全部有對應測試:水池列數不增加/一條 entry 被多筆 record 多個 slot 指到/
改內容兩邊都變(副本做不到)/舊呼叫端行為不變/指不到的外鍵當場報錯且一列不寫。

未做(刻意):.worker-builds/ 未重編 ⇒ 修法只在源碼,還沒進任何執行檔。
leo 2026-08-15 裁定先不出貨,重編屬於出貨動作。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:56:42 +08:00
uncle6me-web 9ba6ba75fc feat(cypher): POST /kbdb/records 開通 entry_ids 通道(Arcrun#128 的對外門)
基本盤補好而通道不開=能力在、沒人打得到——同 b6ef0f0 那次 PATCH 的形狀。
#129(wiki template)與 #130(三元組正規化)的寫入端走的就是這扇門。

改了兩件事(維持純轉發,判斷全在基本盤,薄殼鐵律見檔頭):
· 舊版寫死 `!body.values → 400`:只給 entry_ids 的合法請求會被自己的 proxy 擋掉
  ⇒ 改成「values 與 entry_ids 至少要有一個」
· 轉發 body 帶上 entry_ids;沒給的那個 key 就不塞(不憑空給基本盤一個空物件)

租戶隔離沿用本檔既有做法:**忽略 caller 自帶的 owner_id,一律注入 header 身份**。
配合基本盤這次加的檢查(被參照 entry 的 owner_id 必須與注入的租戶相符),
「呼叫端可以自己指定 entry_id」這條新路徑不會變成跨租戶的門。

驗(tests/kbdb-records-entry-ids-proxy.test.ts,6 條,fetchMock 假 host+斷網)
· 無 X-Arcrun-API-Key → 401 不碰 KBDB
· 只給 entry_ids → 轉發成功,body 為 {template, entry_ids, owner_id:'leo'}(無 values key),
  且 caller 自帶的 owner_id:'alice' 被忽略
· values 與 entry_ids 混用 → 兩個都轉過去
· 舊呼叫端只給 values → 轉發 body 與過去逐字相同,不夾帶 entry_ids
· 兩個都沒給 → 400 不轉發;base 擋跨租戶(400)→ 原樣透傳不假裝成功

cypher-executor 全套 445 綠 / 14 紅——那 14 條是**既有**紅燈
(auth-dispatcher/console-library-map-page/executor/portal-admin/portal-data),
已用 `git stash` 拿掉本次改動複跑同五個檔複驗:同樣 14 紅,與本次無關。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:48:01 +08:00
uncle6me-web aa49b4eeb2 fix(kbdb): 刪 record 不再刪掉別人還指著的 entry(Arcrun#128 的必然配套)
上一筆讓 slot 可以指向既有 entry 之後,同一條 entry 會同時被別的 record 指著。
deleteRecord 舊寫法是「這筆 record 的每個 entry_id 都刪掉」,在共用的情況下會:
  · 把**別人還在用**的那條水池資料一起刪掉(他的 slot 從此指向不存在的列),或
  · 撞上 `entry_values.entry_id REFERENCES entries(id)` 的 FK 而整個刪除失敗
⇒ 條件改成「已經沒有任何 entry_values 指著它」才刪
  (`DELETE FROM entries WHERE id = ? AND NOT EXISTS (SELECT 1 FROM entry_values WHERE entry_id = ?)`)

**沒有共用時行為與舊版完全相同**:這筆 record 的關聯列已先刪掉,若沒有別人指著,
NOT EXISTS 恆為真 ⇒ 照樣刪。差別只出現在真的被共用的那一條上。

驗(同檔 +2 條,真 SQLite 且 `PRAGMA foreign_keys = ON`)
· 兩筆 record 共用一段、刪掉其中一筆:entries 2 → 1(只少掉那筆自己專屬的 title),
  共用那條還在,另一筆 record 讀回來的 slot 值不受影響
· 沒有共用時:3 個 slot 的 entry 全刪、entry_values 歸零、getRecord 回 null(同舊版)

kbdb 全套 232 綠

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:44:32 +08:00
uncle6me-web 5b22d569c9 fix(kbdb): createRecord 的 slot 可以指向既有 entry——外鍵不再被實作成複製(Arcrun#128)
leo 2026-08-15 的心智模型:「blocks 是一個大水池,template/slots 組成虛擬表和
fields,最後都指向水池的一條 entry⋯⋯連到三元組就是外鍵」。

而 `entry_values` 的約束只有 `UNIQUE(record_id, slot_name)`、`entry_id` 上沒有任何
unique ⇒ 一條 entry 本來就能被無限多筆 record 的無限多個 slot 參照,
**儲存層早就是外鍵語意,壞的只有寫入路徑**:createRecord 對每個 slot 值都無條件
createEntry ⇒ 每設一次外鍵就把被參照的資料複製一份。那不是外鍵,那是複製。

為什麼是地基而不是省空間:複製讓資料量隨「有幾個 App 參照它」線性膨脹,而且兩份
從此各自漂移 ⇒ 遲早要有人來清 ⇒ 直接違背這個模型的產品承諾「加一個 App 只要建一份
template,不必遷移、不需要工程師」(llm-wiki-schema.md)。#129(wiki template)、
#130(三元組正規化)、#60(alias 餵了沒用)三票都卡在它後面。

做了什麼(不動 schema、不動舊資料、不加表)
· CreateRecordInput 多一個 entry_ids:{slot: 既有 entry id},與 values 並存
  —— 給字串照舊新建(舊呼叫端一個字不用改),給 id 就只插一列 entry_values
· POST /records 接受只給 entry_ids(舊版這裡回 400),並驗兩個 map 的型別
· 寫入前先把被參照的 entry 讀出來,一次擋掉三種錯,且**檢查全在第一筆 INSERT 之前**
  ⇒ 失敗=一列都沒寫(base 沒有交易,這是唯一保證得了的原子性)
    ① id 不存在(FK 只會回一句 SQLITE_CONSTRAINT,說不出是哪個 slot)
    ② 🔴 跨租戶:新路徑讓呼叫端能自己指定 entry_id,不擋就等於開一扇
       「把別人的 entry 掛進自己的 record 再讀回內容」的門(rules 02 §6.1 同精神)
    ③ 指到 template 沒有的 slot → 報錯,不學 values 那條靜默略過
       (外鍵無聲消失是最難查的失敗:呼叫端以為建好了,template= 查詢卻永遠撈不到)

驗(tests/record-entry-ref.test.ts,17 條,真 SQLite 套 0001_base.sql 原檔——
「列數變不變」是 capture-DB 驗不到的東西,必須有真的表在數)
· 五個 slot 全指既有 entry:entries 5 → 5(差 0),entry_values 0 → 5
  對照組同樣五段給字串:entries 5 → 10(差 5)=原本的複製行為
· 一條 entry 同時被兩筆 record、且被同一筆 record 的兩個 slot 指到 → 讀回都正確
· 改水池那一份 → 兩筆 record 都看到新內容(副本做不到,證明真的是同一條)
· searchByTemplate('wiki','leo') 撈得到,五個 slot 值正確
· 舊路徑逐項不變:3 筆新 entry、entry_type='value'、owner_id 帶歸屬、
  template 沒有的 slot 照舊只 echo 不存;POST 舊 body 仍 200

kbdb 全套 215 → 230 綠(新增 15 條,既有一條都沒動)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:43:42 +08:00
uncle6me-web 30c9312c57 wiki: 拿真實的 docs/ 套新規範跑一次(27 張卡,兩個節點)
leo 2026-08-15:「我是說不是要用真實的資料夾套新規範做一次?」

對 matrix/arcrun/docs 的兩個節點做了真的萃取(不是模擬):
· docs/          5 篇原稿 → 5 文件卡 + 9 概念卡 + 1 主題(結構性閘)
· user_requirements/  4 篇 → 2 文件卡 + 7 概念卡 + 1 主題(讓生態自己長)

踩到並如實處理的四個邊緣案例:
① arcrun-landing-page/ 只有 .jsx/.html/.css ⇒ 依規範不是節點,不建 .wiki(index 說明有寫)
② credential_parts.md 29KB、12 個 H2,超過 20KB 分段門檻
   ⇒ 只萃 §0–§2,其餘八段在文件卡上如實標明未做——一張卡蓋不住 29KB
③ wishlist.md 是「空」的第三種:指針檔(不是沒東西、不是紀錄)
   ⇒ 標 no_concept 不產卡。我第一版給它產了卡,wiki-lint 當場報孤島卡抓到
④ 三個子節點未萃,在 index 標「未萃」——標出來使用者才分得出「沒東西」和「還沒做」

並依 leo 的判準拿掉 type 欄位:
「在這個類似 Logseq 的結構裡根本不分,你要想的是在寫函式時是否不同」
逐一檢查 place_card/patch_card/promote_hub/split_hub/渲染/三個信號/lint
——沒有任何函式需要類型欄位,需要的全是結構屬性(有無父/有無子/有無判斷),
而那三個都是現算的。⇒ type: hub 與 hub_kind 拿掉,wiki-lint 改成用算的。
promote_hub 因此不是改型別,是在有子的卡上補一段判斷,型別自己就變了。

實測:docs 樹 節點 2|卡片 27|卡外邊 62,十五項全 0,退出碼 0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:47:27 +08:00
uncle6me-web 1616517ace rules: 第一鐵律從「用 grep」改成「照索引走,grep 是異常訊號」(leo 2026-08-15)
leo 當面點破:規則寫的是平面搜尋,而設計寫的是索引鏈(INDEX → cards/<bucket>/00-INDEX
→ 卡),兩者矛盾,AI 服從了寫得比較具體的那一份。

實測本 repo:system-dev/wiki/cards/ 有 64 張卡,只有 1 張走得到索引——
那 1 張是 leo 手寫的 decisions/,其餘 63 張機器產的從沒加入任何索引。
而沒有人發現,因為 grep 找得到 ⇒ 破損永遠不會浮現。

不全面禁止 grep(索引壞掉那天 AI 會瞎掉且沒人知道),改成:
先照索引走 → 走不到就先把缺陷講出來 → 才准 fallback。
每次 grep 都該留下一筆索引缺陷,而不是變成習慣。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:01:39 +08:00
uncle6me-web 7dc3e615e9 merge: 內部 uninstaller 進 main(Arcrun#119/#120/#122)
它已經在 youlin 上真的用過,卻只活在分支上——8/14 就因此被總管誤判成「不存在」。
留在分支=下一次還會再消失一次。

內容:scripts/teardown-instance.mjs(535 行,唯一新增檔)
  · plan/apply --yes 兩段式
  · leo21c 在 deny-list
  · central KV 操作前機械核對 wrangler 身分(D88)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:37:40 +08:00
uncle6me-web ccd531863a merge: 公庫 5 顆 → 23 顆,Arcrun 統一編所有零件(D91/D92/Arcrun#125)
總管複驗才併(第二次審——第一次因基底過舊被攔下):
· cypher sha = 1da6a46e6a17 ← 含 Arcrun#124 修法那顆(第一版帶的是修法前的 8e648747)
· 23 顆逐位元等於 Arcrun 官方成品;五顆既有引擎逐顆對得上 main 的 committed 值
· repo_dirty false、repo_head 45169ab
· excluded 有寫明理由(arcrun-claude-api:wasm 不在版控=不是可出貨零件),不是安靜消失
· auth_static_key 進首裝,靠 {{credential.X}} 隱含依賴抓到

🔴 第一版被攔下的教訓(值得留):它的 commit 訊息寫「位元組完全重現 committed 值」——
那句屬實,但重現的是**舊的** committed 值。
⇒「與版控一致」在基底過舊時,正好是最像正確的錯誤。
   環境驗證(我算得穩不穩)≠ 內容驗證(我帶的是不是最新那顆)。

⚠️ 未驗最後一哩:全新安裝實測要 leo 按 CF 授權。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:04:35 +08:00
uncle6me-web 537b520143 build(worker-artifacts): 公庫從 5 顆變 23 顆(基底重釘到含 Arcrun#124 修法的 main)
由乾淨工作區重編(repo_head 45169ab、dirty=false)。

🔴 這一版是**重編**不是重併:前一版是從 94d5452(#124 修法併進 main 之前)長出來的,
直接併會把 .worker-builds 的 cypher **換回沒有修法的那顆**(8e648747),
而且是安靜發生的——manifest 從 5 顆變 23 顆看起來全是進步,沒有人會去看那顆 sha 退回去。

複驗(五顆既有引擎逐顆比對 main 的 committed 值,全中):
  cypher-executor 1da6a46e6a17 ← **含 #124 修法**(source 340b461f)
  kbdb            f15e16cca6ce
  code            751634a3fc9a
  mcp             9bc8dffb34d0
  http-request    cdd97364f277
新增 18 顆零件 worker + arcrun-rag-ui;claude_api 依 .gitignore 判定不出貨,
落在 manifest.excluded。成品裡沒有任何本機路徑洩漏。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:00:45 +08:00
uncle6me-web 45169ab25e feat(build): 一個實例會用到的每一顆 worker 都在 Arcrun 編(D91/Arcrun#125)
官方編譯點從 5 顆擴到 23 顆,並把 portal 前端的產生器從 arcrun-rag 搬回來。

為什麼不是「順手多編幾顆」:
- 只編 5 顆 ⇒ 下游 bundle 裡就只有 5 顆 ⇒「零件用到才下載」沒有貨可以下載。
  而 auth_static_key 是每一支產品工作流解 {{credential.X}} 都會打的那一顆
  ⇒ 走網頁安裝器裝出來的實例,工作流一跑就 500(Arcrun#124 的 error code: 1042)。
- portal 前端(arcrun-rag-ui)當時仍由 arcrun-rag 自己拼裝原始碼
  ⇒ 使用者拿到的那顆 worker,沒有任何 Arcrun commit 說得出它的來源。
  leo:「今天開始出貨一律不准在 arcrun rag 或任何別的地方 build,這就是 arcrun 的專屬工作。」

做法:
- 零件 worker 改用**掃描** .component-builds/(不是手寫清單——手寫清單就是
  arcrun-rag#27/D48 那個病的形狀),worker 名一律讀該零件自己的 wrangler.toml。
- 可出貨的判準=**部署位元組在版控裡**。.gitignore 明文排除的三顆
  (claude_api/km_writer/kbdb_upsert_block,DECISIONS §1「錯做成零件」)
  因此自動落在公庫外,而且是**寫進 manifest.excluded 的**,不是安靜跳過。
- 輸出目錄改成每次只留這一輪該有的,清掉舊成品(拔掉棘輪)。
- UI 產生器原樣搬過來,兩道閘(世代閘、前端 JS 語法閘)一起搬——
  它們驗的是本 repo 的 console-ui/public,本來就該長在來源這一側。

實測(本機 worktree,依 lockfile 重裝依賴後):
- 23/23 編成功;四顆既有引擎成品**位元組完全重現** committed 值
  (cypher 8e6487478bc8/kbdb f15e16cca6ce/code 751634a3fc9a/mcp 9bc8dffb34d0/
   http-request cdd97364f277)⇒ 這次擴充沒有動到既有零件。
- arcrun-rag-ui 的產出與搬家前逐位元比對,**只差第一行註解**
  (那行必須改:它原本指向一支不再產生它的腳本)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:59:59 +08:00
uncle6me-web 5dd1d1e7a9 merge: credential 全取到時不再打 auth_static_key(Arcrun#124)——Mira 恢復可用
總管獨立複驗才併(不是採信自述):
· youlin 實測,用戶真正會走的那條路:
    改前 GET /q/yuga3bse/rag_chat → 500 credential resolve 回傳 404: error code: 1042
    改後 GET /q/yuga3bse/rag_chat → success: true
    /kbdb/map libraries 0 → 1(testkb,entry_count 4、triplet_count 1)
· 部署前後 binding 21→21,消失/多出/值變 各 0;secret 都在
· 修法保留 WASM 路徑(任一 name 沒取到就照舊走 T7 雙讀)⇒ 不縮減既有能力
· 註解明確警告「這個 1042 跟前兩輪的 1042 不是同一個病」——同碼兩因,值得留

根因:auth_static_key 不在首裝清單,而 cypher 遇 {{credential.X}} 就無條件 fetch 它。
影響:凡走網頁安裝器裝出來的實例,ingest 與查詢全死(youlin/leo21c 實測同死,
geek6688 因手工維護零件齊全而測不出來)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	.worker-builds/manifest.json
2026-08-15 00:06:51 +08:00
uncle6me-web b8a5201e8a build(worker-artifacts): 重打 cypher-executor 成品(含 Arcrun#124 修法)
sha256 8e6487478bc8 → 1da6a46e6a17(工作區乾淨時重編,repo_head 標記可信)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:26:28 +08:00
uncle6me-web 340b461f58 fix(cypher): credential 全部從新家取到時不再打 auth_static_key worker(Arcrun#124)
病:cypher 看到 {{credential.X}} 就無條件 fetch
arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev。那顆 worker
**不在網頁安裝器的首裝清單裡**(只裝 cypher-executor / kbdb / http_request / code
四顆),也沒有「用到才長」的機制 ⇒ fetch 撞 CF 邊緣 404 `error code: 1042`
⇒ 凡是用到 {{credential.X}} 的節點全部 500。

實測兩台都一字不差地同死(唯讀證據):
  leo21c  GET /q/bfezv28v/rag_chat  → 500 Node kw_search failed: ... 1042
  youlin  GET /q/yuga3bse/rag_chat  → 500 Node kw_search failed: ... 1042
  geek6688(手工維護、那顆在)      → 過得了這關
leo 的 8 支 RAG 工作流每一支都用 {{credential.}}(rag_chat 6 處、
rag_ingest_card 7 處)⇒ 問答與寫入同時死,不只 ingest。

修法:resolveCredentialRefs 在 resolveSecretsFromNewHome 之後,若 nameList
每一個都已取到明文,就直接 replaceCredentialRefs 回傳,不打那顆 worker。

為什麼是零行為變化:回填本來就在本檔做(replaceCredentialRefs);那顆 worker
在這條路徑上唯一多做的事是幫「沒取到」的 name fallback 舊 KV(T7 雙讀)。
全取到時它收到 resolved_secrets 就是原樣回傳。只要有任一 name 沒取到,
仍照舊全量交給 WASM 走 T7——不縮減既有能力,只在等價時省掉那一跳。

證據那把金鑰早就在手上:leo21c 的 credential 目錄 last_used_at=1786715805,
與最後一次失敗執行的時間戳同一秒(touchLastUsed 只在真的從新家取到值時才寫)。

🔴 這個 1042 與 d2048e2/95a1462 那兩輪不是同一個病:那兩輪是 same-zone fetch
被擋(解=global_fetch_strictly_public flag,service binding 的解法已被 revert
不要拿回來);本案是目標 worker 根本不存在。同一個錯誤碼、兩種原因。

邊界:rule 02 §2.2 不變(不解密/不展開模板/不組 JWT);rule 3.1 不變(不加
service binding)。明文來源仍是 secret_get host function,與改動前同一條路。

驗證:tsc 對本檔零錯誤;auth-dispatcher+credentials 測試 5 紅 8 綠,
與改動前基準線逐項相同(零回歸;那 5 紅是 7ba7855 註明的「刻意留紅燈」)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:25:26 +08:00
uncle6me-web 5318398849 merge: 成品 manifest 的 metadata 修正(repo_dirty:true→false)
只改 .worker-builds/manifest.json 的 metadata,五顆 worker 內容位元組完全沒變
(總管逐行看過 diff:generated_at/repo_head/repo_dirty/source_commit 四個欄位)。

為什麼要併:不併的話 main 上的成品清單一直記著 repo_dirty:true,
下次任何人從 main 出貨都會被 build-bundles 的新鮮度閘擋下。

⚠️ 併了之後 repo_head 仍記著 94d5452(前一版 main),出 prod 前 ship.mjs
的 build 站會重編一次——這一顆解的是「髒」,不是「舊」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:18:33 +08:00
uncle6me-web 9609924818 提案補勾連:辨識碼的「更新者免碼」是靠那張紙,方向與直覺相反
不是用辨識碼找那張紙,是用那張紙免掉辨識碼(t154)。
紙搬到帳號上後,免碼判準自然變成去帳號看一眼,並讓 #120 情境變誠實:
帳號清空=本來就是新裝,請填辨識碼是對的答案,不是把人關在門外。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:08:29 +08:00
uncle6me-web 043e9acc31 chore(builds): 重編 tier2 成品 manifest——repo_dirty:true 修正為乾淨 HEAD(94d5452)
出貨 D87(Arcrun#119)到 stage 時卡在 build-bundles.mjs 的落後閘:main 上現有的
.worker-builds/manifest.json 記著 repo_head=8286c8a(4b6cc15 那個 commit 的父提交)
且 repo_dirty:true——是那次 commit 在完成前先跑了建置腳本,manifest 跟著沒完成的
工作樹一起被 commit 進去。

在乾淨的 main HEAD(94d5452)重跑 scripts/build-worker-artifacts.mjs:五顆
worker 內容位元組完全相同(cypher-executor sha256 8e6487478bc8... 不變,
證明前一份的實際內容其實是對的,只是 metadata 記錯),這次只更新
generated_at/repo_head/repo_dirty/source_commit 四個欄位為正確值。
2026-08-14 20:06:35 +08:00
uncle6me-web cfb759a774 提案收斂:那張紙搬到用戶自己的帳號上,中心不再保管(leo 2026-08-14 第三次)
比「降級」更徹底——紙跟東西在同一個地方,一起消失
⇒ #120 不是被修好,是沒有地方可以發生。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:05:29 +08:00
uncle6me-web 73bdb90780 pending-changes:安裝器狀態判斷改看帳號、中心帳本降級(leo 2026-08-14 的檢修孔比喻)
同一天三次撞牆同一形狀:帳本與現場分岔時,安裝器相信帳本。
鐵律:帳本永遠不得產生停手;不合時以現場為準並順手改對帳本。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:03:05 +08:00
uncle6me-web 9ad95ee3a6 merge: 上次裝到一半死掉的帳號要能再裝一次(Arcrun#123)+清單分頁
總管逐筆審過才併:
· 接管同名資源的唯一依據是呼叫端聲明 createNameIsOurs,預設 false(fail-closed)
· 安裝器有資格聲明——複驗過推導:baseName=arcrun-rag-<SHA256('arcrun-rag:'+email) 前8碼>
  ⇒ 同一 email 每次算出同一組名字,別人算不到
· acr CLI 那條路沒有聲明(createName 是裸 binding 名,證明不了是誰的)——正確
· 2b(已部署 worker 的綁定優先)沒被放寬,#97 的保護還在
· 分頁修法同批:三支清單端點形狀不同(D1 沒有 total_pages),終止條件刻意只用
  result_info 在不在+total_count 對不對得上;看不完整就 throw ⇒ 停手,
  「我不知道」不准被當成「它沒有」

真帳號實測(geek6688):正向接得回、反向 fail-closed 擋得住、兩個時間窗都通。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:58:02 +08:00
richblack 68f042cfd0 fix(resource-rule): 帳號上資源超過一頁時,規則看到的必須是全部(Arcrun#123 續集)
三支清單方法只打 `?per_page=100`,也就是**只看第一頁**。這個洞在 #123 的修法
前後嚴重度不同,這才是它必須跟那張票一起修的理由:

  · 修法前:被截掉的是「worker 綁著的那顆」→ 2b 判「綁著的資源不見了」
            → blocker → 停手。誣告使用者,但安全。
  · 修法後:被截掉的是「同名殘骸」→ 2c 判「這個名字沒被佔走」
            → 去建 → CF 回 title already exists → #123 的死路原樣回來。

⇒ 修法把它從「叫得太大聲」變成「安靜地復發」。分開出貨等於把 #123 的災情
  延後到「資源比較多的帳號」再爆。

做法:`cfListAll()` 翻到底;翻不完、或數量對不上 CF 回報的 `total_count`,
一律 throw ⇒ 變 blocker ⇒ 整趟停手(README 規則第 3 條)。
「我不知道」不准被當成「它沒有」。

三支端點的分頁行為不一樣(2026-08-14 在 geek6688 帳號實測,唯讀):
  /storage/kv/namespaces  result_info 有 total_pages
  /d1/database            result_info **沒有** total_pages ⇒ 不能拿它當終止條件
  /vectorize/v2/indexes   result_info 是 null,不分頁(分頁參數被忽略)
所以終止條件只用「三支都有或都沒有」的兩件事:result_info 在不在、total_count 對不對得上。

fixture 的清單端點同步照真 CF 的形狀分頁(三支各自不同)——假資料失真就會養出
「拿 total_pages 當終止條件」這種在 D1 上必壞的實作,而測試全綠。

新增 tests/list-pagination.mjs(在舊碼上實測會紅,且第 ③ 段直接重現
「無 blocker → 排 10 顆新建 → CF 回 title already exists」的 #123 死路)。
cli 73 項全綠、demo 與 half-finished-install 全綠。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:52:24 +08:00
uncle6me-web 5d441aa7d3 fix(teardown): 中心側補上帳號白名單——原本 central-* 碰得到 leo21c
實測破口(2026-08-14,修補前):
  central-plan --account-id 51a01bfa…(leo21c)
  → 一路暢通,列出 deployed:51a01bfa…:leo21c
  加 --yes 就會刪掉 leo 的安裝紀錄 ⇒ 他下次重裝撞 Arcrun#120 死結。

根因是兩個問題被當成同一個:
  ① 動的是「誰的」紀錄 → assertAccountAllowed(帳號側有,中心側沒有)
  ② 以「誰的身分」動手   → assertWranglerIsCentralAccount(D88 補的那道)
c543aba 的註解寫「這個不對稱現在補齊」,但只補了②。註解說補齊、程式補一半,
後來讀的人會相信註解——所以順手把那段註解改成實測講法。

另修 listAllScripts 沒翻頁(ops-facts 記過的同款坑)。在這支工具裡漏看一顆
worker 有兩層傷害:拆不乾淨,以及「共用資源保護」看不到那個 owner,
把還在用的資源判成沒人用而刪掉。

並補一條已知限制:資源清單是從 worker binding 反推、不掃帳號,
所以沒人綁的孤兒殘骸不會被列也不會被刪(這正是 drill-a/drill-b 不受影響的原因,
但代價是拆完重裝可能撞 Arcrun#123,要自己再列一次帳號)。

驗證(youlin,全唯讀,未執行 apply):
- plan:6 顆 worker + 10 個資源(8 顆 yuga3bse KV/yuga3bse-db/embed-m3),
  drill-a、drill-b 不在清單
- leo21c 帳號側、中心側 兩條路徑皆 exit 1 拒絕
- youlin 中心側仍正常:staging 通道有 deployed:1129efd7…:youlin-hsieh-dev 一筆

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:51:31 +08:00
claude-code 43323b1a78 test(resource-rule): 加一支「在真帳號上驗 #123」的腳本(離線測不出 CF 的拒絕)
離線 fixture 證明的是**判斷**對,證明不了「真的 Cloudflare 會不會照我們
以為的方式回應」——而 #123 的病根正是「我們以為 CF 會讓我們重建,
實際上它拒絕」。那種錯只有真帳號驗得出來。

用法:
  CF_API_TOKEN=<token> CF_ACCOUNT_ID=<id> \
    node shared/resource-rule/tests/verify-against-real-account.mjs

它對帳號做的事:
  · 讀:列 KV/D1/Vectorize、讀 worker 綁定
  · 寫:只建一顆名字帶 -zz123tst- 的一次性 KV,finally 保證刪掉
  · 不碰任何既有資源(測試用的 worker script 名刻意不存在,
    所以規則看到的是「沒有人綁著它」= #123 那條路)
安全開關:DRY_RUN=true 只盤點、KEEP=true 不清理(除錯用)

驗三件:① 接回殘骸且一顆都不新建 ② apply 前後帳號顆數不變
③ 沒聲明 createNameIsOurs 時 fail-closed 且訊息不叫人開 CF 後台

⚠️ 雲端 session 跑不了寫入那半(權限分類器擋 CF 寫入),
   DRY_RUN 已對真 youlin 帳號試跑通過(讀到 8 顆 KV)。
   完整那半要在有寫入權限的地方跑。

Refs: inkstone/Arcrun#123
2026-08-14 09:57:43 +00:00
claude-code 2b807da324 test(resource-rule): 半殘情境的 D1 名字改用安裝器真正會取的那個(-db,非 -kbdb)
上一筆 commit 之後才發現的:fixture 的 half-finished 情境沿用了
installed/renamed 的歷史 D1 名(-kbdb),但那顆殘骸是安裝器留下的,
安裝器真正會取的名字是 `${baseName}-db`。名字不對,這個情境就測不到
真的那條路(D1 會被判成「不同名 ⇒ 照舊新建」而不是接回來)。

把 d1Title 加進情境定義、installerRequirements 收一個 d1CreateName 參數,
half-finished 用 -db、其餘照舊 -kbdb。

實跑:node shared/resource-rule/tests/half-finished-install.mjs → 全部通過

Refs: inkstone/Arcrun#123
2026-08-14 08:18:46 +00:00
claude-code 3f2e45f5dc fix(resource-rule): 上次裝到一半死掉的帳號要能再裝一次(Arcrun#123)
封測者 1.4.45 實撞:
  a namespace with this account ID and title already exists
⇒ 那個帳號從此永遠裝不起來,而錯誤訊息對用戶完全無法行動。

根因:rule.mjs 第 2c 段只問「有沒有已部署的 worker 綁著它」,
不問「這個名字在帳號上是不是已經存在」。註解裡「本來就沒有東西可丟」
漏掉一種狀態——資源已建、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。
拆除 youlin 時親眼看到的 8 顆空殼 KV 是同一個形狀。

修法:2c 在 create 之前先查同名。找到同名資源時:
  · 呼叫端聲明了 createNameIsOurs → 接回那一顆(adopt + reclaimed 標記)
  · 沒聲明 → 停手,訊息帶 RES-NAME-TAKEN 錯誤碼讓用戶回報

createNameIsOurs 是接管的唯一依據,預設 false(fail-closed)。只有名字
推導自使用者自己的身分時才准聲明——安裝器的
arcrun-rag-<slugFromEmail(email)>-kv-<binding> 合格;acr 從 toml 讀到的
裸 binding 名(WEBHOOKS)不合格,因為用戶自己也可能用那個名字。

這不是把 #97 刪掉的「照名字 ensure」搬回來:
  ① #97 找不到就新建一顆頂上去(會弄丟資料);這裡找到才沿用,
     找不到照舊新建,永遠不拿新的空資源頂替既有的
  ② 排在「已部署綁定=事實」之後,名字只在沒有任何綁定可看時才有發言權
  ③ #97 無條件相信名字;這裡要呼叫端先證明名字推導自用戶身分

順手補上假帳號的保真度:FakeCloudflare.createKvNamespace 原本不擋同名,
所以半殘帳號在測試裡看起來只是「多幾顆孤兒」,實際上是裝不起來——
少了那一行,這個 bug 測不出來。fixture-account.mjs 也把
resourcesExist 與 deployed 拆開,才表達得出這個狀態。

驗證(皆為實跑):
  node shared/resource-rule/tests/half-finished-install.mjs  → 全部通過(零依賴)
  cd cli && npm test                                          → 73/73 pass
  sync-resource-rule --check                                  → 三份副本皆與原稿一致

⚠️ 只有規則這一半。安裝器要在 manifestRequirements 聲明 createNameIsOurs
才會生效,那一半在 arcrun-rag(D85)。

Refs: inkstone/Arcrun#123
2026-08-14 08:08:37 +00:00
uncle6me-web c543abaa8a fix(teardown): central KV 操作前機械核對 wrangler 身分(D88,leo 審核裁示①,必修項)
leo 審核意見:「這條正好違反 D88——閘要長在機器上,不是長在誰的記性上。而且失敗模式
是最壞的那種:靜默打錯帳號的中心 KV,沒有任何東西會叫。帳號側有白名單擋、中心側完全
沒有——這個不對稱本身就是設計缺口。」

修法:assertWranglerIsCentralAccount() 在 planCentralCleanup()(central-plan/
central-apply 共用的入口)最前面跑 `wrangler whoami --json`,核對登入態的帳號清單裡
有 uncle6(58309bb90fd93ad6d0fe0aae99170e9d)才放行,沒有就丟錯拒絕整個操作。
已用模擬錯誤帳號 id 驗證比對邏輯正確拒絕;正常路徑(真的登入 uncle6)central-plan
照常運作,貼在同一次 commit 的測試輸出裡。

同時把 leo 裁示②(apply 的依賴序失敗不擋併、但要記實測輪數)補進「已知限制」段:
兩次實測收斂輪數分別是 2 輪(有外部依賴殘留時)與 3 輪(純內部 service binding 鏈時)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:29:03 +08:00
uncle6me-web 49248b18c5 docs(teardown): 記下兩層咬合的已知限制(跨 worker 依賴鏈,2026-08-14 實撞)
今天實跑撞到、也解掉的真事:kbdb-graph-plugin(不屬於這台實例,正確被排除不動)
身上有 service binding 指著 arcrun-kbdb(要刪的)→ CF 拒絕刪除,本工具只回報那次
DELETE 失敗,不會主動點出「有外部依賴卡住」。若選擇半殘留(清資源留殼),下一步
acr init/update 的 resource-rule fail-closed 保護會正確拒絕重裝——這是它該有的行為,
但代表本工具留下的半殘留會讓下一步卡住,不是「拆一半也沒關係」。

本工具目前無法自動偵測跨 worker 依賴鏈,寫進「已知限制」而不是假裝判準完備。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:20:56 +08:00
uncle6me-web 05e879faa7 fix(teardown): 拿掉 wrangler kv key delete 的 --force(這版 wrangler 沒這個 flag)
實跑撞到:這台裝的 wrangler 4.100.0 的 `kv key delete` 沒有 --force,帶了就直接
「Unknown argument: force」失敗。central-apply 兩個通道各撞一次才發現。

拿掉 --force 後在真實 youlin 帳號實測:central-apply 兩個通道(prod INSTALLER_KV
+ staging PEER_INSTALLER_KV)的 deployed:<accountId>: 紀錄都刪除成功,central-plan
複驗兩邊皆印「沒有殘留紀錄」。這是 Arcrun#120 死結修復鏈路的必要一步。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:13:39 +08:00
uncle6me-web 0b72b818a0 chore(builds): 重編 tier2 成品 manifest——repo_dirty:true 修正為乾淨 HEAD(94d5452)
出貨 D87(Arcrun#119)到 stage 時卡在 build-bundles.mjs 的落後閘:main 上現有的
.worker-builds/manifest.json 記著 repo_head=8286c8a(4b6cc15 那個 commit 的父提交)
且 repo_dirty:true——是那次 commit 在完成前先跑了建置腳本,manifest 跟著沒完成的
工作樹一起被 commit 進去。

在乾淨的 main HEAD(94d5452)重跑 scripts/build-worker-artifacts.mjs:五顆
worker 內容位元組完全相同(cypher-executor sha256 8e6487478bc8... 不變,
證明前一份的實際內容其實是對的,只是 metadata 記錯),這次只更新
generated_at/repo_head/repo_dirty/source_commit 四個欄位為正確值。
2026-08-14 13:27:45 +08:00
uncle6me-web 7498911856 WIP: 內部 uninstaller 工具(拆帳號側 + 中心 KV),尚未跑過 apply(Arcrun#119/#120/#122)
leo 交辦:先做出「拆乾淨→重裝」的內部工具,在 youlin 帳號上自己能用即可,
不是產品(對外版另計)。D82 三步驗收(全新安裝→更新→解除安裝)需要拆的能力才測得到,
沒有它就永遠測不到 #119/#120 這類「只有全新用戶才撞得到」的坑。

目前做到:
- scripts/teardown-instance.mjs:
  - plan/apply 兩階段(預設唯讀只印清單,真要刪要 apply --yes)
  - 帳號白名單(只認 youlin,明確擋 leo21c)
  - 歸屬判準:worker 名字要出現在本 repo wrangler*.toml 或官方 bundle manifest.core[]
    才算「已知」,不認得的一律列出不動(例如 kbdb-graph-plugin,不屬於這台實例)
  - 共用資源保護:同一顆 KV/D1/Vectorize 若也被「保留中的 worker」綁著,排除不刪
  - central-plan/central-apply:清中心 INSTALLER_KV/PEER_INSTALLER_KV 的
    deployed:<accountId>: 紀錄(#120 死結的另一半,帳號側清了但這裡沒清會卡死重裝)
  - 已用 `plan` 在 youlin 帳號實跑過(唯讀),清單看起來合理,貼在 issue/回報裡

還沒做:
- apply(真的刪)、central-apply、以及刪完後的重裝驗證,全部還沒跑
- 已知限制列在檔頭註解:判準是「repo 認不認得」不是「屬於哪一台實例」,
  同帳號多台同名慣例實例分不出來(今天 youlin 上短暫同時有正式實例與
  ship-stage-119 的 freshtest 測試實例,此時就是靠人工 --keep 排除)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:25:39 +08:00
uncle6me-web 94d5452425 merge: 認證儲存搬回 binding(D87 走 C,leo confirm)——全新安裝建得出第一個帳號
總管審過六項驗收,自己實測非採信自述:
- 測試 14 個失敗與 clean main 逐檔完全相同(既存,非本次引入)
- 成品 bundle 裡 x-arcrun-install-token 0 次
- writeAuthStore(Secrets 寫入)0 個外部呼叫者,已離開關鍵路徑
- auth-status 的 writable 改為型別上的 true,不再說謊
- 舊實例(D61 帳密在 Secrets)走純讀 env 的 legacy 路接回並 best-effort 搬進 KV

Arcrun#119 / InkStoneCo D87
2026-08-14 12:57:48 +08:00
uncle6me-web 4b6cc159b8 feat(auth): 認證儲存搬回 D1/KV——落實方案C(leo confirm「走C」,arcrun-rag#99)
實作 pending-changes.md「認證儲存要不要搬回 D1/KV」提案(commit 8286c8a):
D61 的病根「重裝時 binding 被安裝器照名字重新指到新建空資源」已被更通用的
shared/resource-rule(Arcrun#97,2026-08-13)解掉,故不再需要繞開 binding
去躲這個病——而繞開的代價正是這次要收的債:Workers Secrets 寫入需要外部
CF_SECRETS_API_TOKEN,這把 token 從安裝那天起就沒被種過,止血版只解掉
「建第一個帳號」這一格,之後的每一次寫入(換密碼/加帳號/改權限)仍卡死。

改動:
- console 管理員帳密:家改回 SESSIONS_KV(binding,console-auth.ts)
- portal 多人帳號:家改回 KBDB(binding,走 base HTTP API,D38 零 SQL,portal.ts)
- D61 認證儲存(CF Workers Secrets)留為舊實例的唯讀回退路徑:讀取零成本、
  零外部憑證需求(只有寫入才要 token);登入成功即 best-effort 自動搬進新家,
  且**這次登入發出的 session 就直接指向新 record_id**(不必等下一次登入)
- D61 的三項「明顯失敗」語意全部保留:auth_store_empty(讀不到不算密碼錯、
  不計入鎖定)、/console/setup 遇既有帳號說清楚密碼沒被採用、/health 與
  /console/auth-status 吐儲存狀態
- 移除止血版的 x-arcrun-install-token 表頭傳遞機制(installToken 參數)——
  帳號寫入從此不需要任何外部 CF token,這個結構性缺口已從根拔除

測試:cypher-executor 全套 vitest 439/453(14 個既存失敗與本改動無關,已用
git stash 對照 clean checkout 逐一比對檔名確認完全相同);tsc --noEmit
無新增錯誤(3 個既存錯誤同上核實無關)。已跑 build-worker-artifacts.mjs
重打 tier2 bundle,grep 複驗 createKbdbUserRecord/promoteToKbdb 進了成品、
promoteLegacyUser/x-arcrun-install-token 完全從成品消失。

未覆蓋:POST /credentials(一般 workflow API 金鑰儲存)仍依賴
CF_SECRETS_API_TOKEN——這是 01-tech-stack.md 既有的、獨立於 D61 之外的
credential 儲存架構(D19「擁有目錄不擁有內容物」),本提案範圍只涵蓋「認證」
(登入帳密),不涵蓋一般 credential 儲存;07-29 已知缺口仍待另案處理。

不准 merge 進 main(SDD 鐵律③,等總管審過再併);不准部署(D20 出貨閘)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:52:07 +08:00
uncle6me-web 8286c8afec docs(pending-changes): 提案——認證儲存搬回 D1/KV(撤 D61 的儲存層決定,留其明顯失敗語意)
arcrun-rag#99 止血修法(安裝精靈傳 OAuth token 給 cypher 建第一個帳號)只解一格,
之後的寫入(換密碼/加使用者/存金鑰)仍永久 writable:false,是留債。

查證(讀碼+讀本機重打的 .worker-builds/manifest.json,非猜測):D61 想解的病根
(重裝時 binding 被照名字重新指到新建空資源)已在 2026-08-13 被更通用的
shared/resource-rule(Arcrun#97)解掉——SESSIONS_KV、KBDB 的 D1 都在 cypher 的
requires 清單裡,且 runInstall:1762 每次安裝/更新都會走這條規則。技術前提成立。

提案:認證資料搬回 D1/KV(走 binding,零外部 CF token、零過期、零新 OAuth scope、
零手動步驟),保留 D61 加的三個明顯失敗機制。附影響分析、遷移考量、信心水位
(靜態推論,未動態實測)、D84 三段式驗法(③ uninstall 目前做不到,如實標記)。

⏸ 等 leo/總管 confirm,本 commit 不動任何架構程式碼。

順手修:writeAuthStore 的錯誤訊息移除誤導的「/ CF_ACCOUNT_ID」(已查證全新實例
一定有這個 var,installer worker.js:1354 無條件注入,列兩項只會讓下一個人白工)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:06:50 +08:00
uncle6me-web ca1ed2aaf6 fix(auth): 錯誤訊息不再叫用戶做做不到的事(arcrun-rag#99,leo 傳實測畫面點破)
leo 傳了封測者實際看到的畫面:撞牆訊息叫他「重新執行安裝/更新讓它就緒」,
但安裝/更新從來不會把 CF_SECRETS_API_TOKEN 種成一個常駐的值——這句話在
訊息會出現的所有情況下都不保證成立(尤其是安裝精靈本身之外的路徑,如事後
新增使用者、改密碼)。使用者照做 → 回到同一畫面 → 以為是自己操作錯誤 →
無限迴圈。這比「訊息不夠白話」嚴重一個級數:不是難懂,是死路。

改:不再承諾一個我們自己都不確定會生效的動作,改成誠實描述現況+給得出去
的下一步(回報支援並附上下情境)。訊息集中在 writeAuthStore 單一出處,
console-auth.ts/portal.ts 的 502 回應共用同一份文字,不會各說各話。

測試:全套 vitest 443/457(14 個既存失敗與本檔無關,已用 git stash 對照
clean checkout 確認);tsc --noEmit 無新增錯誤。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:56:59 +08:00
uncle6me-web 88f308642e fix(auth): 全新安裝補上 CF_SECRETS_API_TOKEN 的缺口——第一個帳號建得起來了(arcrun-rag#99)
根因:D61(認證與資料分離,c4cee35)把 /console/setup、/portal/admin/bootstrap
的寫入全部改走 CF Workers Secrets(env.CF_SECRETS_API_TOKEN),但這把 token
從安裝那天起就沒被種過——07-29 已知缺口(pending-changes.md「credential 走
n8n 模式」),當時只降級某個功能;D61 之後升級成「連第一個帳號都建不起來」
的硬斷點,每台全新安裝必中(leo 本人+封測者實撞:裝得起來但卡在註冊)。

修法:putWorkerSecret/deleteWorkerSecret/authStoreWritable/writeAuthStore/
mutateAuthStore 新增可選的 tokenOverride 參數(呼叫端提供 > worker 自身 env)。
/console/setup、/portal/admin/bootstrap 讀取 x-arcrun-install-token 表頭,
只有安裝精靈(裝機當下手上有一把自己還有效的 OAuth token,workers-scripts.write
scope,同一把已用於 putWorkerSecretDirect/seedCredential)會帶這個表頭;
一般使用者自己在瀏覽器操作不受影響。沿用既有「D36 安裝器代寫」precedent,
不是新開一條路;bootstrap 本身已被「已有 admin → 409」擋成只能成功一次,
不會被拿來反覆濫用。

測試:cypher-executor vitest 443/457(14 個既存失敗與本改動無關,已用
git stash 對照確認);新增 2 則直接證明「缺 token→502 auth_store_not_writable/
帶 token→200」。tsc --noEmit 無新增錯誤。已跑 build-worker-artifacts.mjs
重打 tier2 bundle 供驗證(工作區未 commit 前提下的本地驗證版)。

未完成:安裝器(products/arcrun-rag/installer/oauth-prototype/worker.js)
端的 x-arcrun-install-token 表頭傳遞已另外修好,但兩邊都還沒部署——需要
①重打正式 worker artifact ②install.arcrun.dev 的安裝器 wrangler deploy
③已卡住的封測者要再走一次安裝精靈讓他的 cypher worker 拿到新 bundle。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:46:56 +08:00
uncle6me-web c3856470ad chore(builds): 重編成品——把 #117 地圖 entry_count 與 #118 讀不到≠不存在 放進執行檔 2026-08-13 18:28:25 +08:00
claude-code 1ccee0055e Merge PR #118: 「讀不到」不准長得像「不存在」+開場就講得出有知識庫(總管審過) 2026-08-13 10:02:27 +00:00
claude-code 302e024804 Merge PR #117: 藏書地圖分開講「有幾筆內容」與「萃出幾條關係」(總管審過) 2026-08-13 10:01:34 +00:00
uncle6me-web c2897ba68b fix(mcp): AI 一連上就知道「這裡有主人的知識庫」+「讀不到」不再講成「不存在」
leo 2026-08-13:「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**,
從你對 Arcrun 的認知就知道你的內建 Memory 是沒用的。」

## 病灶一:指路的話被綁在一個會無聲消失的段落上

instructions 裡唯一提到「這裡有知識可查」的,是 buildLibraryMapInstructions
拼上去的【藏書地圖】——而那段是**選配的**(1500ms 逾時、catch 回 null、失敗無聲)。
必定出現的那段靜態文字(startHere)從頭到尾只講工作流、零件、recipe、邊的語法,
`kbdb_search`/`kbdb_get_map` 一個字都沒有。

實害:同一天,一條 portal 連線的 session 為了回答「Arcrun 是什麼」去讀了 682 行
原始碼——而 `kbdb_search(q="Arcrun 是什麼")` 當下回得出 33 筆真答案。

修法:
- 新增 `KNOWLEDGE_FIRST` 靜態段(與 startHere 同級,KBDB 掛了照樣出現),
  排在最前面:這條連線後面有主人的知識庫、第一個動作是 kbdb_search、
  工具怎麼呼叫、以及「沒查到/沒查/地圖沒取到」三件事不可以講成同一句。
- 地圖失敗**不再靜默**:拿不到就印【藏書地圖:這次沒取到】,
  stale token 另印身分版(要使用者重新連線)——「沒取到」與「這裡沒有知識」
  不可以長得一樣(Arcrun#109 同族)。鐵律不變:失敗仍然不擋連線。
- 抽出 `buildServerInstructions(env, identity)` 供測試印出完整字串。

## 病灶二:一個 401 被逐字翻譯成「不存在」

`arcrun_get_skill('write_intent_workflow')` 回「skill ... 不存在」,
但 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`
(entry_type: agent-skill、source: installer-seed)——**與本工具查的鍵逐字相符**。
真兇是 `kbdbGetByPageName` 的 `if (!resp.ok) return null;`。
而 401 的來源是該實例的 arcrun-mcp 沒有 KBDB_INTERNAL_TOKEN
(kbdb-client 沒 token 就匿名送出)=部署面的事,但這支 code 把它講成了假話。

修法:
- 非 2xx 一律拋 KbdbAccessError(帶真 status)→ kbdb_unauthorized/kbdb_unreachable,
  訊息明說「這是讀不到,不是不存在」,並交出還走得通的那條路(kbdb_search)。
- `not_found` 只留給「KBDB 正常回應但真的沒這張卡」,並說明是「這台實例沒 seed」。
- 五支工具吃 identity:stale → identity_missing;portal 撞 401 時額外說明
  「是這批工具還走服務憑據,不是你的帳號讀不到」。
- instructions 步驟 4 不再指名 `arcrun_get_skill('INDEX')`(leo21c 實測根本沒 seed
  這支)——改成先 `arcrun_list_skills()` 看這台實例真的有哪幾支。

## 驗

- `mcp` 測試 130 passed(新增 mcp-instructions 7 項+skills-examples-honesty 10 項),tsc exit 0。
- 三種情境(地圖抓得到/抓不到/舊 token)各印一份完整 instructions 實測,
  三份的開場都指向 kbdb_search。

⚠️ 未部署(部署是 leo 的手動閘)。上線後才會到用戶手上。

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:36:03 +08:00
149 changed files with 51256 additions and 2302 deletions
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
{
"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
@@ -1,917 +0,0 @@
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
@@ -1,142 +0,0 @@
/**
* 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
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+129 -115
View File
@@ -3243,15 +3243,20 @@ var init_kbdb_proxy = __esm({
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || !body.template || !body.values) {
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
if (!body || !body.template || !body.values && !body.entry_ids) {
return c.json({ error: "template \u5FC5\u586B\uFF0Cvalues \u8207 entry_ids \u81F3\u5C11\u8981\u6709\u4E00\u500B" }, 400);
}
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records`, {
method: "POST",
headers,
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner })
body: JSON.stringify({
template: body.template,
...body.values ? { values: body.values } : {},
...body.entry_ids ? { entry_ids: body.entry_ids } : {},
owner_id: owner
})
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
});
@@ -3400,8 +3405,9 @@ function validateName(name) {
function validSensitivity(s) {
return s === "standard" || s === "high";
}
async function putWorkerSecret(env, secretRef, value) {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
async function putWorkerSecret(env, secretRef, value, tokenOverride) {
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
if (!token || !env.CF_ACCOUNT_ID) {
throw new Error(
"\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u5BEB\u5165\u8DEF\u5F91\u672A\u5C31\u7DD2\uFF08\u898B credential-store-migration.md T3\uFF1Aacr init/update \u61C9\u78BA\u4FDD\u9019\u5169\u9805\u5C31\u7DD2\uFF09"
);
@@ -3410,7 +3416,7 @@ async function putWorkerSecret(env, secretRef, value) {
const res = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ name: secretRef, text: value, type: "secret_text" })
@@ -3421,14 +3427,15 @@ async function putWorkerSecret(env, secretRef, value) {
throw new Error(`CF Workers Secrets \u5BEB\u5165\u5931\u6557\uFF1A${detail}`);
}
}
async function deleteWorkerSecret(env, secretRef) {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
async function deleteWorkerSecret(env, secretRef, tokenOverride) {
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
if (!token || !env.CF_ACCOUNT_ID) {
throw new Error("\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u522A\u9664\u8DEF\u5F91\u672A\u5C31\u7DD2");
}
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
const res = await fetch(url, {
method: "DELETE",
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` }
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 404) return;
const body = await res.json().catch(() => null);
@@ -3789,6 +3796,9 @@ async function resolveCredentialRefs(data, env, apiKey) {
if (names.size === 0) return data;
const nameList = [...names];
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
return replaceCredentialRefs(data, resolvedSecrets);
}
const url = wasmWorkerUrl("auth_static_key", env.WORKER_SUBDOMAIN);
const res = await fetch(url, {
method: "POST",
@@ -9198,11 +9208,8 @@ function shardIndex(name) {
function shardNameOf(index) {
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
}
function authStorePresent(env) {
return shardNames(env).length > 0 || overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS;
}
function authStoreWritable(env) {
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
function authStoreWritable(env, tokenOverride) {
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
}
function readAuthStore(env) {
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
@@ -9238,15 +9245,10 @@ function findAuthUserById(env, id) {
function isAuthStoreId(recordId) {
return recordId.startsWith(AUTH_ID_PREFIX);
}
function newAuthUserId() {
const arr = new Uint8Array(12);
crypto.getRandomValues(arr);
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function writeAuthStore(env, data) {
if (!authStoreWritable(env)) {
async function writeAuthStore(env, data, tokenOverride) {
if (!authStoreWritable(env, tokenOverride)) {
throw new AuthStoreWriteError(
"\u9019\u53F0\u5BE6\u4F8B\u9084\u4E0D\u80FD\u5BEB\u5165\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID\uFF09\u3002\u8A8D\u8B49\u5206\u96E2\u9700\u8981\u9019\u5169\u9805\u624D\u5BEB\u5F97\u9032 Workers Secrets\u2014\u2014\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u8B93\u5B83\u5C31\u7DD2\u3002"
"\u9019\u53F0\u5BE6\u4F8B\u76EE\u524D\u5BEB\u4E0D\u9032\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A\u53EF\u7528\u7684 Cloudflare \u5BEB\u5165\u6191\u8B49\uFF1ACF_SECRETS_API_TOKEN\uFF09\u3002\u9019\u662F\u5E73\u53F0\u7AEF\u7684\u5DF2\u77E5\u9650\u5236\uFF0C\u4E0D\u662F\u4F60\u64CD\u4F5C\u932F\u8AA4\u2014\u2014\u76EE\u524D\u6C92\u6709\u4F60\u81EA\u5DF1\u5728\u756B\u9762\u4E0A\u80FD\u505A\u7684\u4E0B\u4E00\u6B65\uFF0C\u8ACB\u628A\u9019\u5247\u8A0A\u606F\u5B8C\u6574\u622A\u5716\uFF0F\u8907\u88FD\u7D66\u652F\u63F4\uFF0C\u4E26\u8A3B\u660E\u4F60\u525B\u624D\u5728\u505A\u4EC0\u9EBC\uFF08\u4F8B\u5982\uFF1A\u5B89\u88DD\u7CBE\u9748\u88E1\u5EFA\u7ACB\u7B2C\u4E00\u500B\u5E33\u865F\u3001\u4E8B\u5F8C\u65B0\u589E\u4F7F\u7528\u8005\u3001\u6216\u4FEE\u6539\u5BC6\u78BC\uFF09\uFF0C\u6703\u9700\u8981\u4EBA\u5DE5\u5354\u52A9\u6392\u9664\u3002"
);
}
const shards = [];
@@ -9269,10 +9271,10 @@ async function writeAuthStore(env, data) {
}
const existing = shardNames(env);
for (let i = 0; i < shards.length; i++) {
await putWorkerSecret(env, shardNameOf(i), shards[i]);
await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
}
for (const name of existing) {
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
}
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
overlayAt = Date.now();
@@ -9319,26 +9321,23 @@ function unionStores(a, b) {
}
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
}
async function mutateAuthStore(env, fn) {
async function mutateAuthStore(env, fn, tokenOverride) {
await hydrateFromAccelerator(env);
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
await fn(next);
await writeAuthStore(env, next);
await writeAuthStore(env, next, tokenOverride);
return next;
}
function authStoreStatus(env) {
const data = readAuthStore(env);
return {
present: authStorePresent(env),
writable: authStoreWritable(env),
users: data.users.length,
console_configured: Boolean(data.console),
shards: shardNames(env).length
};
}
// cypher-executor/src/routes/health.ts
var healthRouter = new Hono2();
function authStoreStatus(env) {
const legacy = readAuthStore(env);
return {
console: { home: "sessions-kv", writable: true, legacy_secrets_present: legacy.console !== null },
portal_users: { home: "kbdb", writable: true, legacy_secrets_present: legacy.users.length > 0 }
};
}
healthRouter.get("/health", (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
@@ -12625,35 +12624,35 @@ function tenantOf(c) {
return knowledgeOwner(c.env);
}
async function loadCredentials(env) {
let fromStore = readAuthStore(env).console;
if (!fromStore && await hydrateFromAccelerator(env)) {
fromStore = readAuthStore(env).console;
}
if (fromStore) return { creds: fromStore, source: "secrets" };
const raw2 = await env.SESSIONS_KV.get(CREDS_KEY);
if (!raw2) return { creds: null, source: "none" };
let legacy = null;
try {
legacy = JSON.parse(raw2);
} catch {
return { creds: null, source: "none" };
if (raw2) {
try {
return { creds: JSON.parse(raw2), source: "kv" };
} catch {
}
}
const legacy = readAuthStore(env).console;
if (!legacy) return { creds: null, source: "none" };
try {
await mutateAuthStore(env, (data) => {
if (!data.console) data.console = legacy;
});
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
} catch {
}
return { creds: legacy, source: "legacy-kv" };
return { creds: legacy, source: "legacy-secrets" };
}
async function saveCredentials(env, record) {
await mutateAuthStore(env, (data) => {
data.console = record;
});
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
}
function consoleAuthStoreStatus(env) {
return {
home: "sessions-kv",
writable: true,
// binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
legacy_secrets_present: readAuthStore(env).console !== null
};
}
consoleAuthRouter.get("/console/auth-status", async (c) => {
const { creds, source } = await loadCredentials(c.env);
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
});
consoleAuthRouter.post("/console/setup", async (c) => {
const { creds: existing } = await loadCredentials(c.env);
@@ -12679,8 +12678,7 @@ consoleAuthRouter.post("/console/setup", async (c) => {
try {
await saveCredentials(c.env, record);
} catch (e) {
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
}
const token = randomHex(32);
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
@@ -12705,8 +12703,7 @@ consoleAuthRouter.post("/console/setup/reset", async (c) => {
try {
await saveCredentials(c.env, record);
} catch (e) {
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
}
return c.json({ success: true });
});
@@ -12717,7 +12714,7 @@ consoleAuthRouter.post("/console/login", async (c) => {
{
error: "\u9019\u53F0\u5BE6\u4F8B\u9084\u6C92\u6709\u7BA1\u7406\u54E1\u5E33\u5BC6\uFF08\u6216\u8B80\u4E0D\u5230\uFF09\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8ACB\u5148\u5B8C\u6210\u9996\u6B21\u8A2D\u5B9A\u3002",
code: "auth_store_empty",
auth_store: authStoreStatus(c.env)
auth_store: consoleAuthStoreStatus(c.env)
},
400
);
@@ -12726,18 +12723,8 @@ consoleAuthRouter.post("/console/login", async (c) => {
const email = (body?.email ?? "").trim().toLowerCase();
const password = body?.password ?? "";
if (!email || !password) return c.json({ error: "email \u8207 password \u5FC5\u586B" }, 400);
let creds = existing;
let hash = await hashPassword(password, creds.salt);
if (email !== creds.email || hash !== creds.hash) {
if (await hydrateFromAccelerator(c.env)) {
const again = (await loadCredentials(c.env)).creds;
if (again) {
creds = again;
hash = await hashPassword(password, creds.salt);
}
}
}
if (email !== creds.email || hash !== creds.hash) {
const hash = await hashPassword(password, existing.salt);
if (email !== existing.email || hash !== existing.hash) {
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
}
const token = randomHex(32);
@@ -13008,24 +12995,31 @@ function recordValuesToAuthUser(id, v) {
updated_at: v.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
};
}
async function promoteLegacyUser(env, rec) {
async function promoteToKbdb(env, rec) {
try {
const email = (rec.values.email ?? "").toLowerCase();
if (!email) return;
if (findAuthUserByEmail(env, email)) return;
await mutateAuthStore(env, (data) => {
if (data.users.some((u) => u.email === email)) return;
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
if (!email) return null;
const already = await findKbdbUserRecordId(env, email);
if (already) return already;
return await createKbdbUserRecord(env, email, {
display_name: rec.values.display_name ?? "",
status: rec.values.status ?? "active",
role: rec.values.role ?? "user",
password_hash: rec.values.password_hash ?? "",
libraries: rec.values.libraries ?? "[]",
created_at: rec.values.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
updated_at: rec.values.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
});
} catch {
return null;
}
}
async function findUserRecordId(env, email) {
const inStore = findAuthUserByEmail(env, email);
if (inStore) return inStore.id;
return findLegacyUserRecordId(env, email);
const inKbdb = await findKbdbUserRecordId(env, email);
if (inKbdb) return inKbdb;
return findAuthUserByEmail(env, email)?.id ?? null;
}
async function findLegacyUserRecordId(env, email) {
async function findKbdbUserRecordId(env, email) {
const ns = portalNamespace(env);
const params = new URLSearchParams({
page_name: email,
@@ -13094,42 +13088,53 @@ function daemonActiveKey(env) {
}
async function listRecordsByTemplate(env, template) {
if (template === USER_TEMPLATE) {
const fromStore = readAuthStore(env).users.map(authUserToRecord);
const seen = new Set(fromStore.map((r) => (r.values.email ?? "").toLowerCase()));
let legacy = [];
let fromKbdb = [];
try {
legacy = await listLegacyRecordsByTemplate(env, template);
fromKbdb = await listKbdbRecordsByTemplate(env, template);
} catch {
legacy = [];
fromKbdb = [];
}
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? "").toLowerCase()))];
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? "").toLowerCase()));
const fromLegacy = readAuthStore(env).users.map(authUserToRecord).filter((r) => !seen.has((r.values.email ?? "").toLowerCase()));
return [...fromKbdb, ...fromLegacy];
}
return listLegacyRecordsByTemplate(env, template);
return listKbdbRecordsByTemplate(env, template);
}
async function listLegacyRecordsByTemplate(env, template) {
async function listKbdbRecordsByTemplate(env, template) {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} \u2192 ${res.status}`);
const body = await res.json();
return body.records ?? [];
}
async function createKbdbUserRecord(env, email, values) {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, "/records", {
method: "POST",
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } })
});
if (!res.ok) throw new KbdbError(`POST /records\uFF08portal_user\uFF09\u2192 ${res.status}`);
const body = await res.json();
const recordId = body.record?.record_id;
if (!recordId) throw new KbdbError("POST /records \u56DE\u61C9\u7F3A record_id");
const head = await kbdbFetch(env, "/entries", {
method: "POST",
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns })
});
if (!head.ok) throw new KbdbError(`head entry \u5EFA\u7ACB\u5931\u6557\uFF08record ${recordId} \u5DF2\u5EFA\uFF0C\u9700\u4EBA\u5DE5\u6536\u62FE\uFF09\u2192 ${head.status}`);
return recordId;
}
async function createPortalUser(env, input) {
const now2 = (/* @__PURE__ */ new Date()).toISOString();
const id = newAuthUserId();
await mutateAuthStore(env, (data) => {
data.users.push({
id,
email: input.email.toLowerCase(),
display_name: input.display_name,
status: "active",
role: input.role,
libraries: input.libraries,
password_hash: input.password_hash,
created_at: now2,
updated_at: now2
});
return createKbdbUserRecord(env, input.email.toLowerCase(), {
display_name: input.display_name,
status: "active",
role: input.role,
password_hash: input.password_hash,
libraries: JSON.stringify(input.libraries),
created_at: now2,
updated_at: now2
});
return id;
}
function parseLibraries(raw2) {
if (!raw2) return [];
@@ -13266,7 +13271,7 @@ async function clearLoginFail(env, email) {
async function instanceHasNoAuthData(env) {
if (readAuthStore(env).users.length > 0) return false;
try {
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
} catch {
return true;
}
@@ -13303,7 +13308,7 @@ portalRouter.post(
{
error: "\u9019\u53F0\u5BE6\u4F8B\u8B80\u4E0D\u5230\u4EFB\u4F55\u767B\u5165\u8CC7\u6599\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8A8D\u8B49\u5132\u5B58\u662F\u7A7A\u7684\uFF0C\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u4EE5\u91CD\u65B0\u5EFA\u7ACB\u7BA1\u7406\u54E1\u5E33\u865F\u3002",
code: "auth_store_empty",
auth_store: authStoreStatus(c.env)
auth_store: { home: "kbdb", writable: true, users: 0 }
},
503
);
@@ -13318,10 +13323,14 @@ portalRouter.post(
await recordLoginFail(c.env, email);
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
}
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
let sessionRecordId = recordId;
if (isAuthStoreId(recordId)) {
const migrated = await promoteToKbdb(c.env, rec);
if (migrated) sessionRecordId = migrated;
}
await clearLoginFail(c.env, email);
const token = randomHex2(32);
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: recordId }), {
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
expirationTtl: sessionTtl(c.env)
});
return c.json({
@@ -13704,12 +13713,14 @@ portalRouter.post(
const body = await c.req.json().catch(() => null);
const pageName = String(body?.page_name ?? "").trim();
const srcText = String(body?.text ?? "");
if (!pageName || !srcText.trim()) return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400);
const daemonPrompt = String(body?.prompt ?? "").trim();
if (!daemonPrompt && (!pageName || !srcText.trim()))
return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400);
if (!c.env.AI) {
return c.json({ error: "\u9019\u500B\u90E8\u7F72\u6C92\u6709\u7D81\u5B9A Workers AI\uFF08wrangler.toml \u9700\u6709 [ai] binding\uFF09\uFF0C\u8ACB\u66F4\u65B0\u77E5\u8B58\u5EAB\u7248\u672C" }, 501);
}
const REL = ">".repeat(2);
const prompt = `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A
const prompt = daemonPrompt || `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A
# ${pageName}
## \u4E00\u53E5\u8A71\u5B9A\u7FA9
\uFF08\u4E00\u884C\uFF09
@@ -13725,14 +13736,17 @@ ${srcText}`;
try {
const out = await c.env.AI.run("@cf/meta/llama-4-scout-17b-16e-instruct", {
messages: [{ role: "user", content: prompt }],
max_tokens: 2048,
max_tokens: daemonPrompt ? 8192 : 2048,
temperature: 0.2
});
const card = String(out?.response ?? "").trim();
if (!card) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502);
const raw2 = String(out?.response ?? "").trim();
if (!raw2) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502);
if (daemonPrompt) {
return c.json({ success: true, output: raw2 });
}
const marker = `# ${pageName}`;
const idx = card.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + "\n" });
const idx = raw2.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? raw2.slice(idx) : raw2).trim() + "\n" });
} catch (e) {
return c.json({ error: `Workers AI \u57F7\u884C\u5931\u6557\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502);
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+319 -109
View File
@@ -1,3 +1,51 @@
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res, err) => function __init() {
if (err) throw err[0];
try {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
} catch (e) {
throw err = [e], e;
}
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// kbdb/src/actions/relation-orphans.ts
var relation_orphans_exports = {};
__export(relation_orphans_exports, {
scanRelationOrphans: () => scanRelationOrphans
});
async function scanRelationOrphans(db, limit = 200) {
const cap = Math.min(Math.max(limit, 1), 1e3);
const res = await db.prepare(
`SELECT relation_id, role, missing_id FROM (
SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id
FROM entries r LEFT JOIN entries t ON t.id = r.src_id
WHERE r.src_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'rel', r.rel_id
FROM entries r LEFT JOIN entries t ON t.id = r.rel_id
WHERE r.rel_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'dst', r.dst_id
FROM entries r LEFT JOIN entries t ON t.id = r.dst_id
WHERE r.dst_id IS NOT NULL AND t.id IS NULL
) LIMIT ?`
).bind(cap + 1).all();
const rows = res.results ?? [];
const truncated = rows.length > cap;
const orphans = truncated ? rows.slice(0, cap) : rows;
return { orphans, count: orphans.length, truncated };
}
var init_relation_orphans = __esm({
"kbdb/src/actions/relation-orphans.ts"() {
"use strict";
}
});
// kbdb/node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/compose.js
var compose = (middleware, onError, onNotFound) => {
return (context, next) => {
@@ -2101,12 +2149,15 @@ async function getEntry(db, id) {
const row = await db.prepare("SELECT * FROM entries WHERE id = ?").bind(id).first();
return row ?? null;
}
var NOT_MACHINERY_PREDICATE = "(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
async function listEntries(db, f = {}) {
const conds = [];
const params = [];
if (f.entry_type) {
conds.push("entry_type = ?");
params.push(f.entry_type);
} else {
conds.push(NOT_MACHINERY_PREDICATE);
}
if (f.owner_id) {
conds.push("owner_id = ?");
@@ -2158,7 +2209,10 @@ async function updateEntry(db, id, patch) {
return getEntry(db, id);
}
async function deleteEntry(db, id) {
const ref = await db.prepare("SELECT id FROM entries WHERE dst_id = ? LIMIT 1").bind(id).first();
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} \u2014 delete the record (or its slot) first`);
await db.prepare("DELETE FROM entries WHERE id = ?").bind(id).run();
await db.prepare("DELETE FROM entries WHERE src_id = ?").bind(id).run();
}
async function embeddedIdsByLibrary(db, ownerId, library) {
const rows = await db.prepare(
@@ -2392,6 +2446,8 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s
if (entry_type) {
conds.push("entry_type = ?");
params.push(entry_type);
} else {
conds.push(NOT_MACHINERY_PREDICATE);
}
if (source) {
conds.push("json_extract(metadata_json, '$.source') = ?");
@@ -3236,9 +3292,38 @@ entryRoutes.delete("/:id", async (c) => {
function uid2(prefix) {
return `${prefix}_${crypto.randomUUID()}`;
}
var SYS_ROOT = "sys_root";
var SYS_BELONGS = "sys_belongs";
var SYS_FIELD_OF = "sys_field_of";
function fieldEntryId(templateId, slot) {
return `fld_${templateId}_${slot}`;
}
async function ensureAnchors(db) {
await db.prepare(
`INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES
('${SYS_ROOT}', 'root', 'system', NULL),
('${SYS_BELONGS}', 'belongs', 'system', NULL),
('${SYS_FIELD_OF}', 'field_of', 'system', NULL)`
).run();
}
async function ensureFieldEntries(db, templateId, slots) {
for (const slot of slots) {
const fid = fieldEntryId(templateId, slot);
await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`).bind(fid, slot).run();
await db.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)`
).bind(`relf_${templateId}_${slot}`, fid, templateId).run();
}
}
async function createTemplate(db, input) {
const id = input.id ?? uid2("tpl");
await db.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`).bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null).run();
await ensureAnchors(db);
await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`).bind(id, input.name).run();
await db.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')`
).bind(`relb_${id}`, id).run();
await ensureFieldEntries(db, id, input.slots);
const row = await getTemplate(db, id);
if (!row) throw new Error("createTemplate: row not found after insert");
return row;
@@ -3265,104 +3350,187 @@ async function updateTemplate(db, id, patch) {
if (cols.length === 0) return getTemplate(db, id);
cols.push("updated_at = unixepoch()");
await db.prepare(`UPDATE templates SET ${cols.join(", ")} WHERE id = ?`).bind(...params, id).run();
if (patch.slots !== void 0) await ensureFieldEntries(db, id, patch.slots);
return getTemplate(db, id);
}
async function loadReferencedEntries(db, entryIds, recordOwnerId) {
const ids = [...new Set(Object.values(entryIds))];
if (ids.length === 0) return /* @__PURE__ */ new Map();
const rows = [];
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const res = await db.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => "?").join(",")})`).bind(...chunk).all();
rows.push(...res.results ?? []);
}
const found = new Map(rows.map((r) => [r.id, r]));
const missing = ids.filter((id) => !found.has(id));
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(", ")}`);
if (recordOwnerId != null) {
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
if (foreign.length > 0) {
throw new Error(
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(", ")} != ${recordOwnerId}`
);
}
}
return new Map(rows.map((r) => [r.id, r.content]));
}
async function recordBelongs(db, recordId) {
const row = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`).bind(recordId).first();
return row ?? null;
}
async function insertCellRelation(db, recordId, templateId, slot, dstEntryId, ownerId) {
await db.prepare(
`INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)`
).bind(uid2("relv"), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId).run();
}
async function createRecord(db, input) {
const tpl = await getTemplate(db, input.template);
if (!tpl) throw new Error(`template not found: ${input.template}`);
const slots = JSON.parse(tpl.slots_json);
const recordId = input.record_id ?? uid2("rec");
for (const slot of slots) {
if (!(slot in input.values)) continue;
const values = input.values ?? {};
const entryIds = input.entry_ids ?? {};
const refSlots = Object.keys(entryIds);
const ownerId = input.owner_id ?? null;
const both = refSlots.filter((s) => s in values);
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(", ")}`);
const unknown = refSlots.filter((s) => !slots.includes(s));
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(", ")}`);
const referenced = await loadReferencedEntries(db, entryIds, ownerId);
await db.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`).bind(recordId, ownerId).run();
await db.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)`
).bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id).run();
const writtenSlots = slots.filter((s) => s in entryIds || s in values);
await ensureFieldEntries(db, tpl.id, writtenSlots);
for (const slot of writtenSlots) {
if (slot in entryIds) {
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
continue;
}
const entry = await createEntry(db, {
content: input.values[slot],
content: values[slot],
entry_type: "value",
owner_id: input.owner_id ?? null
owner_id: ownerId
});
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();
await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
}
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
const out = { ...values };
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? "";
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: ownerId };
}
async function updateRecord(db, recordId, values) {
const evRes = await db.prepare(
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, 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 = ?`
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null;
const templateId = belongs.dst_id;
const cellRes = await db.prepare(
`SELECT f.content AS slot_name, r.dst_id AS entry_id
FROM entries r JOIN entries f ON r.rel_id = f.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`
).bind(recordId).all();
const evRows = evRes.results ?? [];
if (evRows.length === 0) return null;
const templateId = evRows[0].template_id;
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
const cells = cellRes.results ?? [];
const slotToEntries = /* @__PURE__ */ new Map();
for (const c of cells) {
const list = slotToEntries.get(c.slot_name) ?? [];
list.push(c.entry_id);
slotToEntries.set(c.slot_name, list);
}
const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first();
const recordOwnerId = identity?.owner_id ?? null;
const tpl = await getTemplate(db, templateId);
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
for (const [slot, content] of Object.entries(values)) {
if (!allowed.includes(slot)) {
throw new Error(`slot not in template: ${slot}`);
}
const entryId = slotToEntry.get(slot);
if (entryId) {
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
const entryIds = slotToEntries.get(slot);
if (entryIds && entryIds.length > 0) {
for (const entryId of entryIds) {
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
}
} else {
await ensureFieldEntries(db, templateId, [slot]);
const entry = await createEntry(db, { content, entry_type: "value", owner_id: recordOwnerId });
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, templateId, slot, entry.id).run();
await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId);
}
}
return getRecord(db, recordId);
}
async function getRecord(db, recordId) {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null;
const res = await db.prepare(
`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 = ?`
`SELECT f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`
).bind(recordId).all();
const rows = res.results ?? [];
if (rows.length === 0) return null;
const values = {};
for (const r of rows) values[r.slot] = r.content;
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 };
for (const r of res.results ?? []) values[r.slot] = r.content;
const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first();
return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null };
}
async function searchByTemplate(db, template, owner_id, limit = 100) {
const tpl = await getTemplate(db, template);
if (!tpl) return [];
const cap = Math.min(limit, 500);
const res = owner_id ? await db.prepare(
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND e.owner_id = ?
ORDER BY ev.created_at DESC LIMIT ?`
).bind(tpl.id, owner_id, cap).all() : await db.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`).bind(tpl.id, cap).all();
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
).bind(tpl.id, owner_id, cap).all() : await db.prepare(
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
).bind(tpl.id, cap).all();
const ids = (res.results ?? []).map((r) => r.record_id);
if (ids.length === 0) return [];
const byId = /* @__PURE__ */ new Map();
for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null });
for (let i = 0; i < ids.length; i += 90) {
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, 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: {}, 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;
const [cellRes, identRes] = await Promise.all([
db.prepare(
`SELECT r.src_id AS record_id, f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'`
).bind(...chunk).all(),
db.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`).bind(...chunk).all()
]);
for (const r of cellRes.results ?? []) {
const rec = byId.get(r.record_id);
if (rec) rec.values[r.slot] = r.content;
}
for (const r of identRes.results ?? []) {
const rec = byId.get(r.id);
if (rec) rec.owner_id = r.owner_id;
}
}
return ids.map((id) => byId.get(id)).filter((r) => !!r);
}
async function deleteRecord(db, recordId) {
const evRes = await db.prepare("SELECT entry_id FROM entry_values WHERE record_id = ?").bind(recordId).all();
const rows = evRes.results ?? [];
if (rows.length === 0) return false;
await db.prepare("DELETE FROM entry_values WHERE record_id = ?").bind(recordId).run();
for (const { entry_id } of rows) {
await db.prepare("DELETE FROM entries WHERE id = ?").bind(entry_id).run();
const belongs = await recordBelongs(db, recordId);
if (!belongs) return false;
const cellRes = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`).bind(recordId).all();
const dsts = (cellRes.results ?? []).map((r) => r.dst_id);
await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run();
await db.prepare(
`DELETE FROM entries WHERE id = ?1 AND entry_type = 'record'
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)`
).bind(recordId).run();
for (const dst of dsts) {
await db.prepare(
`DELETE FROM entries WHERE id = ?1
AND entry_type NOT IN ('sheet', 'field', 'system')
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)`
).bind(dst).run();
}
return true;
}
@@ -3395,10 +3563,17 @@ templateRoutes.patch("/:id", async (c) => {
// kbdb/src/routes/records.ts
var recordRoutes = new Hono2();
var isStringMap = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
recordRoutes.post("/", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.template || !body.values) {
return c.json({ success: false, error: "template and values required" }, 400);
if (!body || !body.template || !body.values && !body.entry_ids) {
return c.json({ success: false, error: "template and values (or entry_ids) required" }, 400);
}
if (body.values !== void 0 && !isStringMap(body.values)) {
return c.json({ success: false, error: "values must be an object of {slot: string}" }, 400);
}
if (body.entry_ids !== void 0 && !isStringMap(body.entry_ids)) {
return c.json({ success: false, error: "entry_ids must be an object of {slot: entry_id}" }, 400);
}
try {
const rec = await createRecord(c.env.DB, body);
@@ -3411,20 +3586,14 @@ recordRoutes.get("/triplet-stats", async (c) => {
const owner = c.req.query("owner_id") || "";
const rows = await c.env.DB.prepare(
`SELECT
COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
COALESCE(NULLIF(lib_v.content, ''), 'general') AS library,
COUNT(*) AS triplet_count
FROM (
SELECT DISTINCT ev.record_id
FROM entry_values ev
JOIN templates t ON ev.template_id = t.id
JOIN entries e ON ev.entry_id = e.id
WHERE t.name = 'triplet'
AND (?1 = '' OR e.owner_id = ?1)
) AS tr
LEFT JOIN entry_values lev
ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
FROM entries b
JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet'
LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library')
LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id
WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1)
GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general')
ORDER BY library`
).bind(owner).all();
const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count }));
@@ -3604,31 +3773,35 @@ async function ensureTripletLibrarySlot(db, tripletTemplate) {
return true;
}
function tripletPivotSql(ownerFiltered) {
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject,
MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object,
MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id`;
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""}
GROUP BY b.src_id`;
}
function mapPivotSql(ownerFiltered) {
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative,
MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities,
MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile,
MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges,
MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count,
MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(ev.created_at) AS ts
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id`;
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(r.created_at) AS ts
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""}
GROUP BY b.src_id`;
}
function parseJsonArray(raw2) {
if (!raw2) return [];
@@ -3736,6 +3909,7 @@ async function recomputeLibraryMap(db, input) {
await updateRecord(db, row.rid, { status: "superseded" });
superseded.push(row.rid);
}
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
return {
map: {
record_id: blockEntry.id,
@@ -3746,6 +3920,7 @@ async function recomputeLibraryMap(db, input) {
relation_profile: relationProfile,
bridges,
triplet_count: tripletCount,
entry_count: entryCount,
commit_hash: input.commit_hash ?? null,
status: "active",
updated_at: blockEntry.created_at
@@ -3759,18 +3934,36 @@ async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db.prepare(
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id
SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""}
GROUP BY b.src_id
) AS tr
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
WHERE COALESCE(tr.status, 'active') = 'active'
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`
).bind(...params).all();
const m = /* @__PURE__ */ new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
async function liveEntryCountsByLibrary(db, owner_id) {
const params = owner_id ? [owner_id] : [];
const res = await db.prepare(
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(*) AS n
FROM entries
WHERE ${owner_id ? "owner_id = ? AND " : ""}entry_type != 'value'
AND src_id IS NULL
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`
).bind(...params).all();
const m = /* @__PURE__ */ new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
@@ -3788,10 +3981,12 @@ async function knownLibraryNames(db, owner_id) {
if (libTpl) {
const libParams = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
const libRows = await db.prepare(
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id`
`SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""}
GROUP BY b.src_id`
).bind(...libParams).all();
for (const r of libRows.results ?? []) if (r.name) names.add(r.name);
}
@@ -3825,11 +4020,15 @@ async function listLibraryMaps(db, owner_id) {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return [];
const params = owner_id ? [tpl.id, owner_id] : [tpl.id];
const res = await db.prepare(
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`
).bind(...params).all();
const [res, entryCounts] = await Promise.all([
db.prepare(
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`
).bind(...params).all(),
liveEntryCountsByLibrary(db, owner_id)
]);
const byLib = /* @__PURE__ */ new Map();
for (const r of res.results ?? []) {
if (!r.library || byLib.has(r.library)) continue;
@@ -3838,6 +4037,7 @@ async function listLibraryMaps(db, owner_id) {
narrative: r.narrative || null,
top_entities: parseJsonArray(r.top_entities).slice(0, 3).map((t) => t.name),
triplet_count: Number(r.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(r.library) ?? 0,
updated_at: r.ts
});
}
@@ -3853,7 +4053,10 @@ async function getLibraryMapDetail(db, library, owner_id) {
ORDER BY m.ts DESC LIMIT 1`
).bind(...params).first();
if (!row) return null;
const blockEntry = await getEntry(db, row.rid);
const [blockEntry, entryCounts] = await Promise.all([
getEntry(db, row.rid),
liveEntryCountsByLibrary(db, owner_id)
]);
return {
record_id: row.rid,
library,
@@ -3863,6 +4066,7 @@ async function getLibraryMapDetail(db, library, owner_id) {
relation_profile: parseJsonArray(row.relation_profile),
bridges: parseJsonArray(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(library) ?? 0,
commit_hash: row.commit_hash || null,
status: row.status ?? "active",
updated_at: row.ts
@@ -4150,6 +4354,12 @@ app.use("*", async (c, next) => {
});
app.get("/", (c) => c.json({ service: "arcrun-kbdb", tier: "base", status: "ok" }));
app.get("/health", (c) => c.json({ ok: true }));
app.get("/maintenance/relation-orphans", async (c) => {
const { scanRelationOrphans: scanRelationOrphans2 } = await Promise.resolve().then(() => (init_relation_orphans(), relation_orphans_exports));
const limit = Number(c.req.query("limit") ?? "200");
const report = await scanRelationOrphans2(c.env.DB, Number.isFinite(limit) ? limit : 200);
return c.json({ success: true, ...report });
});
app.route("/entries", entryRoutes);
app.route("/templates", templateRoutes);
app.route("/records", recordRoutes);
+209 -112
View File
@@ -31262,16 +31262,109 @@ function registerAllWorkflowCrudTools(server, env) {
registerRunWorkflow(server, env);
}
// mcp/src/lib/portal-client.ts
async function portalFetch(env, session, path, opts = {}) {
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 !== void 0 && 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 !== void 0 ? JSON.stringify(opts.body) : void 0
});
}
function resolveKnowledgeIdentity(authPath, portal) {
if (authPath !== "oauth") return { kind: "service" };
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
}
function staleIdentityError() {
return errorResponse(
"identity_missing",
"\u9019\u689D MCP \u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\uFF0C\u88E1\u9762\u6C92\u6709\u767B\u5165\u8005\u8EAB\u5206\uFF0C\u56E0\u6B64\u67E5\u4E0D\u5230\u4EFB\u4F55\u77E5\u8B58\u5167\u5BB9\u3002\u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u5728 claude.ai \u7684 connector \u8A2D\u5B9A\u88E1\u91CD\u65B0\u6388\u6B0A\u3001\u8F38\u5165\u4F60\u7684 Portal \u5E33\u5BC6\uFF09\u5373\u53EF\u2014\u2014\u4E0D\u9700\u8981\u53E6\u5916\u627E\u4EFB\u4F55 credential \u6216\u91D1\u9470\u3002",
[
"\u5230 claude.ai \u2192 Settings \u2192 Connectors\uFF0C\u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u6703\u8DF3\u51FA\u8F38\u5165 Portal \u5E33\u5BC6\u7684\u9801\u9762\uFF09",
"\u91CD\u9023\u5F8C kbdb_* \u5168\u90E8\u5DE5\u5177\u90FD\u6703\u7528\u4F60\u9019\u500B\u5E33\u865F\u7684\u6B0A\u9650\u67E5\u8A62"
]
);
}
async function portalError(res, what) {
const detail = await res.text().catch(() => "");
if (res.status === 401) {
return errorResponse(
"session_expired",
`${what}\u5931\u6557\uFF1A\u767B\u5165\u968E\u6BB5\u5DF2\u904E\u671F\uFF08portal session \u5230\u671F\u6216\u5DF2\u767B\u51FA\uFF09\u3002`,
[
"\u5230 claude.ai \u2192 Settings \u2192 Connectors \u91CD\u65B0\u9023\u7DDA\u9019\u500B connector\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09",
"\u91CD\u9023\u5F8C\u6B0A\u9650\u8207\u4F60\u5728 portal \u7DB2\u9801\u4E0A\u770B\u5230\u7684\u4E00\u81F4"
],
detail
);
}
if (res.status === 403) {
return errorResponse(
"forbidden",
`${what}\u5931\u6557\uFF1A\u9019\u500B\u5E33\u865F\u6C92\u6709\u9019\u9805\u6B0A\u9650\uFF08\u5E33\u865F\u53EF\u80FD\u5DF2\u505C\u7528\uFF0C\u6216\u6C92\u6709\u88AB\u6388\u6B0A\u8A72\u77E5\u8B58\u5EAB\uFF09\u3002`,
["\u8ACB\u77E5\u8B58\u5EAB\u7BA1\u7406\u54E1\u5728 portal \u7684\u5E33\u865F\u7BA1\u7406\u88E1\u78BA\u8A8D\u4F60\u7684\u72C0\u614B\u8207\u53EF\u7528\u77E5\u8B58\u5EAB"],
detail
);
}
return errorResponse(`portal_${res.status}`, `${what}\u5931\u6557\uFF08HTTP ${res.status}\uFF09`, ["\u7A0D\u5F8C\u91CD\u8A66"], detail);
}
// mcp/src/tools/arcrun_skills_examples.ts
var KbdbAccessError = class extends Error {
constructor(status, what, detail) {
super(`KBDB ${what} HTTP ${status}`);
this.status = status;
this.what = what;
this.detail = detail;
}
status;
what;
detail;
};
function kbdbFailure(e, searchHint, identity) {
const portalNote = identity.kind === "portal" ? "\u4F60\u9019\u689D\u662F\u5E33\u5BC6\u767B\u5165\u7684\u9023\u7DDA\uFF0C\u4F46 skill\uFF0Fexample \u9019\u6279\u5DE5\u5177\u76EE\u524D\u4ECD\u8D70**\u670D\u52D9\u5167\u90E8\u6191\u64DA**\uFF08\u9084\u6C92\u63A5\u4E0A\u767B\u5165\u8EAB\u5206\uFF09\u2014\u2014\u6240\u4EE5\u5B83\u8B80\u4E0D\u5230\uFF0C\u4E0D\u4EE3\u8868\u4F60\u7684\u5E33\u865F\u8B80\u4E0D\u5230\u3002" : "";
if (e instanceof KbdbAccessError) {
const unauthorized = e.status === 401 || e.status === 403;
return errorResponse(
unauthorized ? "kbdb_unauthorized" : "kbdb_unreachable",
(unauthorized ? `\u8B80\u4E0D\u5230 KBDB\uFF08HTTP ${e.status}\uFF1A\u9019\u689D\u9023\u7DDA\u7684\u6191\u64DA\u88AB\u62D2\u6216\u6839\u672C\u6C92\u5E36\uFF09\u3002` : `\u8B80\u4E0D\u5230 KBDB\uFF08HTTP ${e.status}\uFF09\u3002`) + "\u{1F534} **\u9019\u662F\u300C\u8B80\u4E0D\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u4E0D\u5B58\u5728\u300D**\u2014\u2014\u5167\u5BB9\u9084\u5728\u5EAB\u88E1\uFF0C\u53EA\u662F\u9019\u689D\u8DEF\u88AB\u64CB\u4F4F\u4E86\u3002" + (portalNote ? ` ${portalNote}` : ""),
[
searchHint,
"kbdb_get_map() \u770B\u9019\u53F0\u5BE6\u4F8B\u6709\u54EA\u4E9B\u5EAB\uFF08\u90A3\u689D\u8D70\u5F97\u901A\u5C31\u66F4\u78BA\u5B9A\u662F\u9019\u6279\u5DE5\u5177\u7684\u8DEF\u58DE\u4E86\uFF0C\u4E0D\u662F\u5EAB\u7A7A\u4E86\uFF09",
"\u{1F534} \u4E0D\u51C6\u628A\u9019\u500B\u932F\u8AA4\u56DE\u5831\u6210\u300C\u627E\u4E0D\u5230\uFF0F\u6C92\u6709\u9019\u500B skill\u300D\u2014\u2014\u8ACB\u7167\u5BE6\u8AAA\u300CKBDB \u9019\u689D\u8DEF\u8B80\u4E0D\u5230\u300D",
"\u6301\u7E8C\u5931\u6557\uFF1A\u544A\u8A34 leo \u9019\u53F0\u5BE6\u4F8B\u7684 arcrun-mcp \u5C11\u4E86 secret KBDB_INTERNAL_TOKEN"
],
e.detail
);
}
return errorResponse(
"kbdb_unreachable",
`\u8B80\u4E0D\u5230 KBDB\uFF1A${e instanceof Error ? e.message : String(e)}\u3002\u{1F534} **\u9019\u662F\u300C\u8B80\u4E0D\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u4E0D\u5B58\u5728\u300D**\u3002` + (portalNote ? ` ${portalNote}` : ""),
[searchHint, "\u7A0D\u5F8C\u91CD\u8A66", "\u{1F534} \u4E0D\u51C6\u628A\u5B83\u56DE\u5831\u6210\u300C\u6C92\u6709\u9019\u500B skill\uFF0Fexample\u300D"]
);
}
async function kbdbList(env, entryType, limit = 100) {
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
if (!resp.ok) {
throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => ""));
}
const data = await resp.json();
return data.entries ?? [];
}
async function kbdbGetByPageName(env, pageName) {
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
if (!resp.ok) return null;
if (!resp.ok) {
throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => ""));
}
const data = await resp.json();
return data.entries?.[0] ?? null;
}
@@ -31284,7 +31377,7 @@ function parseTags(tagsJson) {
return [];
}
}
function registerListSkills(server, env) {
function registerListSkills(server, env, identity) {
server.tool(
toolName("list_skills"),
"\u5217\u6240\u6709 agent-skill blocks\uFF08\u5F9E arcrun/registry/skills/ \u540C\u6B65\u9032 KBDB\uFF09\u3002\u6BCF\u500B skill \u662F\u500B markdown playbook\uFF0C\u63CF\u8FF0 AI \u9762\u5C0D X \u554F\u984C\u8A72\u600E\u9EBC\u60F3 + \u8A72\u7528\u54EA\u500B example\u3002\u56DE [{slug, title, tags}]\u3002call get_skill(slug) \u62FF\u5B8C\u6574\u5167\u6587\u3002",
@@ -31292,6 +31385,7 @@ function registerListSkills(server, env) {
tag: external_exports.string().optional().describe("optional \u6A19\u7C64\u904E\u6FFE\u3002\u5982 'rag' / 'watcher' / 'debug'")
},
async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const blocks = await kbdbList(env, "agent-skill", 100);
const skills = blocks.map((b) => {
@@ -31313,20 +31407,19 @@ function registerListSkills(server, env) {
return successResponse(
{ count: skills.length, skills },
[
skills.length === 0 ? "\u6C92\u6709 skill \u547D\u4E2D\u3002\u8A66 list_skills() \u4E0D\u5E36 tag \u770B\u5168\u90E8" : "call arcrun_get_skill(slug) \u62FF\u55AE\u500B skill \u5B8C\u6574 markdown"
skills.length === 0 ? "\u6C92\u6709 skill \u547D\u4E2D\u3002\u8A66 list_skills() \u4E0D\u5E36 tag \u770B\u5168\u90E8" : "call arcrun_get_skill(slug) \u62FF\u55AE\u500B skill \u5B8C\u6574 markdown",
// 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。
// 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。
"\u{1F534} \u53EA\u7528\u4E0A\u9762\u6E05\u55AE\u88E1\u771F\u7684\u6709\u7684 slug\uFF1B\u6E05\u55AE\u6C92\u6709\uFF1D\u9019\u53F0\u5BE6\u4F8B\u6C92 seed \u9032\u53BB\uFF0C\u4E0D\u8981\u786C\u731C\u540D\u5B57"
]
);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["\u7A0D\u5F8C\u91CD\u8A66", "\u82E5\u6301\u7E8C\u5931\u6557\uFF0C\u544A\u8A34 leo"]
);
return kbdbFailure(e, "\u6539\u7528 kbdb_search({ q: 'skill' }) \u76F4\u63A5\u5728\u77E5\u8B58\u5EAB\u88E1\u627E skill \u5361\u7247\uFF08\u90A3\u689D\u8DEF\u8D70\u7684\u662F\u53E6\u4E00\u7D44\u6191\u64DA\uFF09", identity);
}
}
);
}
function registerGetSkill(server, env) {
function registerGetSkill(server, env, identity) {
server.tool(
toolName("get_skill"),
"\u62FF\u55AE\u4E00 agent-skill \u5B8C\u6574 markdown playbook\u3002slug \u5F9E list_skills \u53D6\u5F97\u3002",
@@ -31334,15 +31427,17 @@ function registerGetSkill(server, env) {
slug: external_exports.string().describe("skill slug\uFF0C\u4F8B\u5982 'build_watcher_workflow' / 'rag_with_arcrun'")
},
async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
return errorResponse(
"not_found",
`skill "${slug}" \u4E0D\u5B58\u5728`,
`KBDB \u6B63\u5E38\u56DE\u61C9\uFF0C\u4F46\u6C92\u6709 page_name="${pageName}" \u9019\u5F35\u5361\u2014\u2014\u9019\u53F0\u5BE6\u4F8B\u6C92\u6709 seed \u9019\u652F skill\u3002\uFF08\u4E0D\u540C\u5BE6\u4F8B seed \u7684 skill \u4E0D\u4E00\u6A23\uFF0C\u5225\u7167\u820A\u6559\u6750\u5047\u8A2D\u67D0\u500B\u540D\u5B57\u4E00\u5B9A\u5728\u3002\uFF09`,
[
"call arcrun_list_skills() \u770B\u53EF\u7528 slug",
"call arcrun_list_skills() \u770B**\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709**\u54EA\u5E7E\u652F",
`kbdb_search({ q: '${slug}' }) \u770B\u5167\u5BB9\u662F\u4E0D\u662F\u88AB\u5B58\u6210\u5225\u7684\u540D\u5B57`,
"\u78BA\u8A8D\u62FC\u5B57\u6B63\u78BA\uFF08\u4E0D\u9700\u8981 'skill-' prefix\uFF09"
]
);
@@ -31354,16 +31449,16 @@ function registerGetSkill(server, env) {
tags: parseTags(block.tags_json)
});
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["\u7A0D\u5F8C\u91CD\u8A66"]
return kbdbFailure(
e,
`\u6539\u7528 kbdb_search({ q: 'skill-${slug}' }) \u6488\u540C\u4E00\u5F35\u5361\u7247\uFF08skill \u5C31\u4F4F\u5728\u77E5\u8B58\u5EAB\u7684 entries \u88E1\uFF0C\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09`,
identity
);
}
}
);
}
function registerListExamples(server, env) {
function registerListExamples(server, env, identity) {
server.tool(
toolName("list_examples"),
"\u5217\u6240\u6709 workflow-example blocks\uFF08\u5F9E arcrun/registry/examples/ \u540C\u6B65\u9032 KBDB\uFF09\u3002\u6BCF\u500B example \u662F\u53EF\u76F4\u63A5 push \u7684 workflow YAML \u7BC4\u672C + description\u3002\u56DE [{slug, tags}]\u3002call get_example / search_examples \u62FF\u7D30\u7BC0\u3002",
@@ -31371,6 +31466,7 @@ function registerListExamples(server, env) {
tag: external_exports.string().optional().describe("optional \u6A19\u7C64\u904E\u6FFE\u3002\u5982 'rag' / 'cron' / 'llm' / 'webhook'")
},
async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const blocks = await kbdbList(env, "workflow-example", 200);
const examples = blocks.map((b) => {
@@ -31389,16 +31485,12 @@ function registerListExamples(server, env) {
]
);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["\u7A0D\u5F8C\u91CD\u8A66"]
);
return kbdbFailure(e, "\u6539\u7528 kbdb_search({ q: 'example' }) \u76F4\u63A5\u5728\u77E5\u8B58\u5EAB\u88E1\u627E example \u5361\u7247\uFF08\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09", identity);
}
}
);
}
function registerGetExample(server, env) {
function registerGetExample(server, env, identity) {
server.tool(
toolName("get_example"),
"\u62FF\u55AE\u4E00 workflow-example \u5B8C\u6574 YAML + description\u3002slug \u5F9E list_examples / search_examples \u53D6\u5F97\u3002\u53EF\u76F4\u63A5\u62FF YAML \u6539\u6210\u4F60\u81EA\u5DF1\u7684 \u2192 push\u3002",
@@ -31406,16 +31498,18 @@ function registerGetExample(server, env) {
slug: external_exports.string().describe("example slug\uFF0C\u4F8B\u5982 'rag-search-answer' / 'cron-watcher'")
},
async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
return errorResponse(
"not_found",
`example "${slug}" \u4E0D\u5B58\u5728`,
`KBDB \u6B63\u5E38\u56DE\u61C9\uFF0C\u4F46\u6C92\u6709 page_name="${pageName}" \u9019\u5F35\u5361\u2014\u2014\u9019\u53F0\u5BE6\u4F8B\u6C92 seed \u9019\u500B example\u3002`,
[
"call arcrun_list_examples() \u770B\u53EF\u7528 slug",
"\u6216 arcrun_search_examples(use_case) \u7528\u81EA\u7136\u8A9E\u8A00\u627E"
"call arcrun_list_examples() \u770B**\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709**\u54EA\u4E9B slug",
"\u6216 arcrun_search_examples(use_case) \u7528\u95DC\u9375\u5B57\u627E",
`kbdb_search({ q: '${slug}' }) \u770B\u5167\u5BB9\u662F\u4E0D\u662F\u88AB\u5B58\u6210\u5225\u7684\u540D\u5B57`
]
);
}
@@ -31436,16 +31530,16 @@ function registerGetExample(server, env) {
"\u770B description_md \u4E86\u89E3\u8A2D\u8A08\u610F\u5716 / \u6539\u9020\u65B9\u5411"
]);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["\u7A0D\u5F8C\u91CD\u8A66"]
return kbdbFailure(
e,
`\u6539\u7528 kbdb_search({ q: 'example-${slug}' }) \u6488\u540C\u4E00\u5F35\u5361\u7247\uFF08example \u5C31\u4F4F\u5728\u77E5\u8B58\u5EAB\u7684 entries \u88E1\uFF09`,
identity
);
}
}
);
}
function registerSearchExamples(server, env) {
function registerSearchExamples(server, env, identity) {
server.tool(
toolName("search_examples"),
"\u7528 use case \u95DC\u9375\u5B57\u641C workflow examples\uFF0C\u56DE\u6700\u76F8\u95DC N \u500B\u3002\u6CE8\u610F\uFF1A\u57FA\u672C\u76E4\u76EE\u524D\u662F D1 LIKE \u95DC\u9375\u5B57\u641C\u5C0B\uFF08\u975E\u8A9E\u7FA9 embedding\uFF1B\u8A9E\u7FA9\u662F kbdb-base Phase 1 \u7684 embed \u6A21\u7D44\uFF0C\u5C1A\u672A\u4E0A\uFF09\u3002\u2192 \u7528\u5177\u9AD4\u8A5E\uFF08'email'\u3001'cron'\u3001'rag'\uFF09\u6BD4\u6574\u53E5\u81EA\u7136\u8A9E\u8A00\u547D\u4E2D\u7387\u9AD8\u3002\u4E5F\u6703\u6BD4\u5C0D slug/tag\u3002",
@@ -31454,6 +31548,7 @@ function registerSearchExamples(server, env) {
top_k: external_exports.number().int().min(1).max(20).optional().describe("\u56DE\u5E7E\u500B\u7D50\u679C\uFF08\u9810\u8A2D 5\uFF09")
},
async ({ query, top_k }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const k = top_k ?? 5;
const q = query.trim();
@@ -31498,21 +31593,17 @@ function registerSearchExamples(server, env) {
]
);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["\u91CD\u8A66\u4E00\u6B21"]
);
return kbdbFailure(e, `\u6539\u7528 kbdb_search({ q: '${query.trim()}' }) \u76F4\u63A5\u67E5\u77E5\u8B58\u5EAB\uFF08\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09`, identity);
}
}
);
}
function registerAllSkillExampleTools(server, env) {
registerListSkills(server, env);
registerGetSkill(server, env);
registerListExamples(server, env);
registerGetExample(server, env);
registerSearchExamples(server, env);
function registerAllSkillExampleTools(server, env, identity) {
registerListSkills(server, env, identity);
registerGetSkill(server, env, identity);
registerListExamples(server, env, identity);
registerGetExample(server, env, identity);
registerSearchExamples(server, env, identity);
}
// mcp/src/tools/arcrun_recipe.ts
@@ -31713,62 +31804,6 @@ function registerRecipeDelete(server, env) {
);
}
// mcp/src/lib/portal-client.ts
async function portalFetch(env, session, path, opts = {}) {
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 !== void 0 && 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 !== void 0 ? JSON.stringify(opts.body) : void 0
});
}
function resolveKnowledgeIdentity(authPath, portal) {
if (authPath !== "oauth") return { kind: "service" };
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
}
function staleIdentityError() {
return errorResponse(
"identity_missing",
"\u9019\u689D MCP \u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\uFF0C\u88E1\u9762\u6C92\u6709\u767B\u5165\u8005\u8EAB\u5206\uFF0C\u56E0\u6B64\u67E5\u4E0D\u5230\u4EFB\u4F55\u77E5\u8B58\u5167\u5BB9\u3002\u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u5728 claude.ai \u7684 connector \u8A2D\u5B9A\u88E1\u91CD\u65B0\u6388\u6B0A\u3001\u8F38\u5165\u4F60\u7684 Portal \u5E33\u5BC6\uFF09\u5373\u53EF\u2014\u2014\u4E0D\u9700\u8981\u53E6\u5916\u627E\u4EFB\u4F55 credential \u6216\u91D1\u9470\u3002",
[
"\u5230 claude.ai \u2192 Settings \u2192 Connectors\uFF0C\u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u6703\u8DF3\u51FA\u8F38\u5165 Portal \u5E33\u5BC6\u7684\u9801\u9762\uFF09",
"\u91CD\u9023\u5F8C kbdb_* \u5168\u90E8\u5DE5\u5177\u90FD\u6703\u7528\u4F60\u9019\u500B\u5E33\u865F\u7684\u6B0A\u9650\u67E5\u8A62"
]
);
}
async function portalError(res, what) {
const detail = await res.text().catch(() => "");
if (res.status === 401) {
return errorResponse(
"session_expired",
`${what}\u5931\u6557\uFF1A\u767B\u5165\u968E\u6BB5\u5DF2\u904E\u671F\uFF08portal session \u5230\u671F\u6216\u5DF2\u767B\u51FA\uFF09\u3002`,
[
"\u5230 claude.ai \u2192 Settings \u2192 Connectors \u91CD\u65B0\u9023\u7DDA\u9019\u500B connector\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09",
"\u91CD\u9023\u5F8C\u6B0A\u9650\u8207\u4F60\u5728 portal \u7DB2\u9801\u4E0A\u770B\u5230\u7684\u4E00\u81F4"
],
detail
);
}
if (res.status === 403) {
return errorResponse(
"forbidden",
`${what}\u5931\u6557\uFF1A\u9019\u500B\u5E33\u865F\u6C92\u6709\u9019\u9805\u6B0A\u9650\uFF08\u5E33\u865F\u53EF\u80FD\u5DF2\u505C\u7528\uFF0C\u6216\u6C92\u6709\u88AB\u6388\u6B0A\u8A72\u77E5\u8B58\u5EAB\uFF09\u3002`,
["\u8ACB\u77E5\u8B58\u5EAB\u7BA1\u7406\u54E1\u5728 portal \u7684\u5E33\u865F\u7BA1\u7406\u88E1\u78BA\u8A8D\u4F60\u7684\u72C0\u614B\u8207\u53EF\u7528\u77E5\u8B58\u5EAB"],
detail
);
}
return errorResponse(`portal_${res.status}`, `${what}\u5931\u6557\uFF08HTTP ${res.status}\uFF09`, ["\u7A0D\u5F8C\u91CD\u8A66"], detail);
}
// mcp/src/tools/kbdb_data.ts
var OWNER_IGNORED_HINT = "owner_id \u5728\u767B\u5165\u8EAB\u5206\u4E0B\u4E0D\u751F\u6548\uFF1A\u67E5\u8A62\u7BC4\u570D\u7531\u4F60\u7684\u5E33\u865F\u6B0A\u9650\u6C7A\u5B9A\uFF08\u8207\u4F60\u5728 portal \u7DB2\u9801\u770B\u5230\u7684\u4E00\u81F4\uFF09";
function registerAllKbdbDataTools(server, env, identity) {
@@ -32142,8 +32177,10 @@ function renderLibraryMapLines(libraries) {
const narrative = (l.narrative ?? "").trim() || "\uFF08narrative \u5F85\u88DC\uFF09";
const clipped = narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}\u2026` : narrative;
const core = entityNames(l.top_entities, 3);
const count = Number(l.triplet_count ?? 0) || 0;
return `- ${l.library}\uFF1A${clipped}\uFF5C\u6838\u5FC3\uFF1A${core.length ? core.join("\u3001") : "\uFF08\u5C1A\u7121\uFF09"}\uFF5C${count} triplets`;
const tripletCount = Number(l.triplet_count ?? 0) || 0;
const entryCount = Number(l.entry_count ?? 0) || 0;
const countPart = tripletCount > 0 ? `${tripletCount} triplets` : entryCount > 0 ? `0 triplets\uFF0F${entryCount} \u7B46\u539F\u59CB\u5167\u5BB9\uFF08\u5C1A\u672A\u8403\u53D6\u95DC\u4FC2\uFF0Ckbdb_search \u67E5\u5F97\u5230\uFF09` : `0 triplets\uFF0F0 \u5167\u5BB9`;
return `- ${l.library}\uFF1A${clipped}\uFF5C\u6838\u5FC3\uFF1A${core.length ? core.join("\u3001") : "\uFF08\u5C1A\u7121\uFF09"}\uFF5C${countPart}`;
});
const omitted = rows.length > MAX_LIBRARY_LINES ? `
\uFF08\u5176\u9918 ${rows.length - MAX_LIBRARY_LINES} \u5EAB\u7565\uFF0Ckbdb_get_map \u53EF\u770B\u5168\u90E8\uFF09` : "";
@@ -32195,6 +32232,13 @@ var RECOMPUTE_HINTS = [
function registerAllKbdbMapTools(server, env, identity) {
registerGetMap(server, env, identity);
}
function entryOnlyHint(tripletCount, entryCount, library) {
if (tripletCount > 0 || entryCount <= 0) return [];
const lib = library ? `\u300C${library}\u300D` : "\u9019\u500B\u5EAB";
return [
`${lib} triplet_count\uFF1D0\uFF0C\u4F46\u6709 ${entryCount} \u7B46\u539F\u59CB\u5167\u5BB9\uFF08entries\uFF09\u2014\u2014\u4E09\u5143\u7D44\u8403\u53D6\u9084\u6C92\u5C0D\u5B83\u8DD1\u904E\uFF0C\u4E0D\u4EE3\u8868\u6C92\u6709\u77E5\u8B58\u3002\u7528 kbdb_search\uFF08\u95DC\u9375\u5B57\u6216\u8A9E\u7FA9\uFF09\u76F4\u63A5\u67E5\u5F97\u5230\u5167\u5BB9\u3002`
];
}
function registerGetMap(server, env, identity) {
server.tool(
"kbdb_get_map",
@@ -32229,7 +32273,8 @@ function registerGetMap(server, env, identity) {
...l,
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
top_entities: entityNames(l.top_entities, 3),
triplet_count: Number(l.triplet_count ?? 0) || 0
triplet_count: Number(l.triplet_count ?? 0) || 0,
entry_count: Number(l.entry_count ?? 0) || 0
}));
if (libraries.length === 0) {
return successResponse({ libraries: [], count: 0 }, [
@@ -32238,9 +32283,15 @@ function registerGetMap(server, env, identity) {
...RECOMPUTE_HINTS
]);
}
const entryOnlyLibs = libraries.filter(
(l) => (l.triplet_count ?? 0) === 0 && (l.entry_count ?? 0) > 0
);
return successResponse({ libraries, count: libraries.length }, [
"\u8981\u770B\u67D0\u5EAB\u7D30\u7BC0\uFF1Akbdb_get_map(library='\u5EAB\u540D')",
"\u9032\u5EAB\u67E5\u5167\u5BB9\uFF1Akbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\uFF1B\u67E5\u95DC\u4FC2\uFF1Akbdb_graph_neighbors"
"\u9032\u5EAB\u67E5\u5167\u5BB9\uFF1Akbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\uFF1B\u67E5\u95DC\u4FC2\uFF1Akbdb_graph_neighbors",
...entryOnlyLibs.length > 0 ? [
`${entryOnlyLibs.map((l) => l.library).join("\u3001")} \u9019\u5E7E\u5EAB triplet_count\uFF1D0 \u4F46 entry_count\uFF1E0\uFF1A\u6709\u539F\u59CB\u5167\u5BB9\uFF0C\u53EA\u662F\u9084\u6C92\u8403\u53D6\u51FA\u4E09\u5143\u7D44\u95DC\u4FC2\u2014\u2014\u5225\u628A 0 triplets \u8B80\u6210\u300C\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u76F4\u63A5 kbdb_search \u9032\u53BB\u67E5\u3002`
] : []
]);
}
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
@@ -32268,11 +32319,13 @@ function registerGetMap(server, env, identity) {
top_entities: parseSlotArray(raw2.top_entities),
relation_profile: parseSlotArray(raw2.relation_profile),
bridges: parseSlotArray(raw2.bridges),
triplet_count: Number(raw2.triplet_count ?? 0) || 0
triplet_count: Number(raw2.triplet_count ?? 0) || 0,
entry_count: Number(raw2.entry_count ?? 0) || 0
};
return successResponse({ map: map2 }, [
"bridges\uFF1D\u6B64\u5EAB entity \u540C\u6642\u51FA\u73FE\u5728\u54EA\u4E9B\u5176\u4ED6\u5EAB\uFF08\u53EA\u6709\u5169\u5074\u4E09\u5143\u7D44\u90FD\u6A19\u4E86 library \u503C\u624D\u6293\u5F97\u5230\uFF0C\u820A\u8CC7\u6599\u82E5\u6C92\u6A19\u6703\u504F\u7A00\u758F\uFF0C\u662F\u8AA0\u5BE6\u73FE\u6CC1\u4E0D\u662F bug\uFF09",
"\u6CBF\u6838\u5FC3 entity \u6316\u95DC\u4FC2\uFF1Akbdb_graph_neighbors(subject=entity \u540D)"
"\u6CBF\u6838\u5FC3 entity \u6316\u95DC\u4FC2\uFF1Akbdb_graph_neighbors(subject=entity \u540D)",
...entryOnlyHint(map2.triplet_count, map2.entry_count, library)
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["\u7A0D\u5F8C\u91CD\u8A66"]);
@@ -32340,7 +32393,7 @@ function registerAllTools(server, env, orgNamespace, partnerToken, identity) {
registerReportFeedback(server, env, orgNamespace);
registerAllIntrospectionTools(server, env);
registerAllWorkflowCrudTools(server, env);
registerAllSkillExampleTools(server, env);
registerAllSkillExampleTools(server, env, identity);
registerAllRecipeTools(server, env);
registerAllKbdbDataTools(server, env, identity);
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
@@ -32349,7 +32402,47 @@ function registerAllTools(server, env, orgNamespace, partnerToken, identity) {
}
// mcp/src/mcp-handler.ts
async function handleMcpRequest(request, env, orgNamespace, partnerToken, identity) {
var KNOWLEDGE_FIRST = [
"\u3010\u9019\u689D\u9023\u7DDA\u4E0A\u6709\u4E3B\u4EBA\u7684\u77E5\u8B58\u5EAB\u2014\u2014\u5148\u67E5\u5B83\uFF0C\u518D\u67E5\u5225\u7684\u3011",
"",
"\u9019\u689D MCP \u9023\u7DDA\u5F8C\u9762\u63A5\u8457\u4E00\u500B **KBDB \u77E5\u8B58\u5EAB**\uFF1A\u9019\u53F0\u5BE6\u4F8B\u7684\u4E3B\u4EBA\u9577\u671F\u7D2F\u7A4D\u7684\u7B46\u8A18\u3001\u6C7A\u7B56\u3001",
"\u8E29\u904E\u7684\u5751\u3001\u5C08\u6848\u73FE\u6CC1\u3001skill \u8207\u5DE5\u4F5C\u6D41\u7D00\u9304\uFF0C\u90FD\u5728\u88E1\u9762\u3002**\u4F60\u4E0D\u662F\u5F9E\u96F6\u958B\u59CB\u7684**\u2014\u2014",
"\u4F60\u5C0D\u9019\u4E9B\u5C08\u6848\u7684\u5167\u5EFA\u5370\u8C61\u591A\u534A\u662F\u932F\u7684\u6216\u904E\u6642\u7684\uFF0C\u5EAB\u88E1\u90A3\u4EFD\u624D\u662F\u4E3B\u4EBA\u8A8D\u7684\u7248\u672C\u3002",
"",
"\u{1F534} **\u6709\u4EBA\u554F\u4F60\u300CX \u662F\u4EC0\u9EBC\uFF0F\u70BA\u4EC0\u9EBC\u9019\u6A23\u505A\uFF0F\u4E4B\u524D\u600E\u9EBC\u6C7A\u5B9A\u7684\uFF0F\u73FE\u5728\u505A\u5230\u54EA\u300D\u2014\u2014",
"\u4F60\u7684\u7B2C\u4E00\u500B\u52D5\u4F5C\u662F `kbdb_search`\uFF0C\u4E0D\u662F grep \u539F\u59CB\u78BC\u3001\u4E0D\u662F\u4E0A\u7DB2\u641C\u3001\u4E0D\u662F\u56DE\u7B54\u300C\u6211\u4E0D\u77E5\u9053\u300D\u3002**",
"",
"- `kbdb_search({ q: \"Arcrun \u662F\u4EC0\u9EBC\" })` \u2014 \u95DC\u9375\u5B57\u67E5\uFF08\u9810\u8A2D `mode:'keyword'`\uFF0C\u57FA\u672C\u76E4\u6C38\u9060\u53EF\u7528\uFF09\u3002",
" \u63DB\u5E7E\u7D44\u8B1B\u6CD5\u518D\u653E\u68C4\uFF1B\u60F3\u8981\u8A9E\u7FA9\u76F8\u4F3C\u5EA6\u7528 `mode:'semantic'`\u3002**\u9019\u4E00\u652F\u662F\u4F60\u7684\u7B2C\u4E00\u7AD9\u3002**",
"- `kbdb_get_map()` \u2014 \u4E0D\u77E5\u9053\u8A72\u9032\u54EA\u500B\u5EAB\u6642\u5148\u770B\u85CF\u66F8\u5730\u5716\uFF08\u4E0B\u9762\u82E5\u6709\u3010\u85CF\u66F8\u5730\u5716\u3011\u5C31\u662F\u5B83\u7684\u5FEB\u7167\uFF09\u3002",
'- `kbdb_graph_neighbors({ subject: "Arcrun" })` \u2014 \u67E5\u67D0\u500B\u6771\u897F\u8DDF\u8AB0\u6709\u95DC\u4FC2\uFF08\u4E09\u5143\u7D44\u904D\u6B77\uFF09\u3002',
"- `kbdb_list_templates` / `kbdb_query` \u2014 \u6309 template \u53D6\u6574\u6279\u7D50\u69CB\u5316\u8CC7\u6599\u3002",
"",
"\u{1F534} **\u9019\u4E09\u4EF6\u4E8B\u4E0D\u53EF\u4EE5\u8B1B\u6210\u540C\u4E00\u53E5**\uFF08\u8B1B\u6210\u540C\u4E00\u53E5\u5C31\u662F\u5728\u9A19\u4EBA\uFF09\uFF1A",
"\u2460 \u300C\u77E5\u8B58\u5EAB\u88E1\u6C92\u6709\u300D\u3000\u2461 \u300C\u6211\u6C92\u67E5\u300D\u3000\u2462 \u300C\u5730\u5716\u6C92\u53D6\u5230\uFF0F\u67D0\u5EAB\u986F\u793A 0\u300D\u3002",
"\u67E5\u904E\u771F\u7684\u6C92\u6709 \u2192 \u660E\u8AAA\u300C\u77E5\u8B58\u5EAB\u88E1\u67E5\u4E0D\u5230\uFF0C\u4EE5\u4E0B\u662F\u6211\u5F9E\u539F\u59CB\u78BC\uFF0F\u7DB2\u8DEF\u63A8\u7684\u300D\uFF0C\u518D\u53BB\u8B80 code \u6216\u4E0A\u7DB2\u3002",
"**\u6C92\u67E5\u5C31\u56DE\u7B54\uFF1D\u62FF\u4F60\u7684\u731C\u6E2C\u5192\u5145\u4E3B\u4EBA\u7684\u77E5\u8B58\uFF0C\u90A3\u662F\u9019\u689D\u9023\u7DDA\u4E0A\u6700\u56B4\u91CD\u7684\u932F\u3002**",
"",
"\u{1F534} **\u5730\u5716\u662F\u7D22\u5F15\uFF0C\u4E0D\u662F\u5EAB\u5B58\u6E05\u55AE**\uFF1A\u67D0\u5EAB\u986F\u793A `0 triplets`\u3001\u6216\u4E0B\u9762\u6574\u6BB5\u3010\u85CF\u66F8\u5730\u5716\u3011\u6C92\u51FA\u73FE\uFF0C",
"\u90FD**\u4E0D\u4EE3\u8868**\u6C92\u6709\u77E5\u8B58\uFF08\u53EF\u80FD\u53EA\u662F\u9084\u6C92\u91CD\u7B97\u3001\u6216\u9019\u6B21\u6C92\u6293\u5230\uFF09\u3002\u8981\u77E5\u9053\u6709\u6C92\u6709\uFF0C\u53EA\u6709\u4E00\u500B\u65B9\u6CD5\uFF1A`kbdb_search` \u67E5\u904E\u3002",
"\u540C\u7406\uFF0C\u4EFB\u4F55\u5DE5\u5177\u56DE 401\uFF0F\u9023\u4E0D\u4E0A\uFF0F\u6C92\u6B0A\u9650\uFF0C\u90A3\u662F**\u8B80\u4E0D\u5230**\uFF0C\u4E0D\u662F**\u4E0D\u5B58\u5728**\u2014\u2014\u7167\u5B83\u7D66\u7684 next_actions \u4FEE\uFF0C",
"\u5225\u628A\u5B83\u6539\u53E3\u8B1B\u6210\u300C\u9019\u88E1\u6C92\u6709\u300D\uFF08`arcrun_get_skill` \u66FE\u628A KBDB \u7684 401 \u8B1B\u6210\u300Cskill \u4E0D\u5B58\u5728\u300D\uFF0C\u5C31\u662F\u9019\u500B\u75C5\uFF09\u3002"
].join("\n");
var MAP_UNAVAILABLE_NOTE = [
"\u3010\u85CF\u66F8\u5730\u5716\uFF1A\u9019\u6B21\u6C92\u53D6\u5230\u3011",
"\u5730\u5716\u6293\u53D6\u903E\u6642\uFF0F\u56DE\u932F\uFF0F\u6216\u5B83\u56DE\u5831\u7684\u6E05\u55AE\u662F\u7A7A\u7684\uFF08\u4E5F\u53EF\u80FD\u53EA\u662F\u9084\u6C92\u91CD\u7B97\u904E\uFF09\u3002",
"\u{1F534} **\u9019\u662F\u300C\u5730\u5716\u6C92\u62FF\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u9019\u88E1\u6C92\u6709\u77E5\u8B58\u300D\u3002** \u4E0A\u9762\u90A3\u689D\u898F\u5247\u7167\u820A\uFF1A",
"\u8981\u77E5\u9053\u5EAB\u88E1\u6709\u4EC0\u9EBC\uFF0C\u76F4\u63A5 `kbdb_search`\uFF1B\u60F3\u518D\u6293\u4E00\u6B21\u5730\u5716\u547C\u53EB `kbdb_get_map()`\uFF08\u5B83\u6703\u56DE\u5831\u771F\u6B63\u7684\u539F\u56E0\uFF09\u3002"
].join("\n");
var MAP_STALE_NOTE = [
"\u3010\u85CF\u66F8\u5730\u5716\uFF1A\u62FF\u4E0D\u5230\uFF0C\u56E0\u70BA\u9019\u689D\u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\u3011",
"\u9019\u689D\u9023\u7DDA\u7684 token \u6C92\u5E36\u767B\u5165\u8005\u8EAB\u5206\uFF0C`kbdb_*` \u6703\u56DE `identity_missing`\u3002",
"\u{1F534} **\u9019\u4E0D\u4EE3\u8868\u77E5\u8B58\u5EAB\u662F\u7A7A\u7684**\u2014\u2014\u662F\u9019\u689D\u9023\u7DDA\u9084\u6C92\u8A8D\u5F97\u4F60\u3002",
"\u4ECD\u7136\u5148\u547C\u53EB\u4E00\u6B21 `kbdb_search` \u78BA\u8A8D\u932F\u8AA4\u78BC\uFF1B\u82E5\u771F\u7684\u662F `identity_missing`\uFF0C",
"\u8ACB\u4F7F\u7528\u8005\u5230 claude.ai \u2192 Settings \u2192 Connectors \u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09\uFF0C",
"**\u4E0D\u8981\u6539\u53E3\u8AAA\u300C\u67E5\u4E0D\u5230\u8CC7\u6599\u300D\u6216\u81EA\u5DF1\u53BB\u731C\u7B54\u6848\u3002**"
].join("\n");
async function buildServerInstructions(env, identity) {
const mapInstructions = await buildLibraryMapInstructions(env, identity);
const startHere = [
"# Arcrun \u2014 \u4F60\u5DF2\u7D93\u914D\u5099\u4E86\u9019\u5957\u5DE5\u5177\uFF0C\u5225\u4E0A\u7DB2\u627E",
@@ -32363,9 +32456,13 @@ async function handleMcpRequest(request, env, orgNamespace, partnerToken, identi
"",
"1. `arcrun_get_skill('write_intent_workflow')` \u2014 **\u5FC5\u8B80\u7B2C\u4E00\u652F**\u3002",
" \u6559\u4F60\u7528 `>>` \u5BEB\u300C\u610F\u5716\u5DE5\u4F5C\u6D41\u300D\u3002\u4F60**\u4E0D\u9700\u8981\u5148\u77E5\u9053\u6709\u54EA\u4E9B\u96F6\u4EF6**\uFF0C\u5148\u5BEB\u610F\u5716\u3002",
" \u26A0\uFE0F \u9019\u652F\u82E5\u56DE\u932F\uFF08401\uFF0F\u9023\u4E0D\u4E0A\uFF0F`kbdb_unreachable`\uFF09\uFF0C\u90A3\u662F**\u9019\u689D\u9023\u7DDA\u8B80\u4E0D\u5230 KBDB**\uFF0C",
" **\u4E0D\u662F skill \u4E0D\u5B58\u5728**\u2014\u2014\u540C\u4E00\u4EFD\u5167\u5BB9\u7528 `kbdb_search({ q: 'skill-write_intent_workflow' })` \u6488\u5F97\u5230\u3002",
"2. `arcrun_whoami()` \u2014 \u78BA\u8A8D\u9023\u5230\u54EA\u500B\u5E33\u865F\uFF08\u52FF\u81EA\u884C curl \u731C\u5E33\u865F URL\uFF09\u3002",
"3. \u628A\u610F\u5716\u4E1F `POST /cypher/search` \u6216 `arcrun_validate_yaml` \u2014 \u7CFB\u7D71\u544A\u8A34\u4F60\u54EA\u4E9B\u96F6\u4EF6\u5B58\u5728\u3002",
"4. \u5361\u4F4F\uFF0F\u4E0D\u77E5\u9053\u8A72\u67E5\u4EC0\u9EBC \u2192 `arcrun_get_skill('INDEX')`\uFF08\u5168\u9928\u5C0E\u822A\uFF1A\u4EC0\u9EBC\u554F\u984C\u67E5\u54EA\u88E1\uFF0B\u5DF2\u77E5\u7684\u5751\uFF09\u3002",
"4. \u5361\u4F4F\uFF0F\u4E0D\u77E5\u9053\u8A72\u67E5\u4EC0\u9EBC \u2192 \u5148 `arcrun_list_skills()` **\u770B\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709\u54EA\u5E7E\u652F**\uFF0C\u518D\u6311\u4E00\u652F\u8B80\u3002",
" \uFF082026-08-13 \u5BE6\u6E2C\uFF1A\u4E0D\u540C\u5BE6\u4F8B seed \u7684 skill \u4E0D\u4E00\u6A23\uFF0C\u6709\u7684\u5BE6\u4F8B\u53EA\u6709\u5169\u652F\u3001\u9023 `INDEX` \u90FD\u6C92\u6709\u3002",
" **\u4E0D\u8981\u7167\u6559\u6750\u76F4\u63A5\u6307\u540D\u4E00\u500B slug** \u2014\u2014\u5148\u5217\u6E05\u55AE\uFF0C\u6216 `kbdb_search({ q: 'skill' })` \u76F4\u63A5\u5728\u5EAB\u88E1\u627E\u3002\uFF09",
"5. \u7F3A\u96F6\u4EF6\u6642\uFF1A\u7F3A API \u2192 \u5BEB recipe\uFF08`arcrun_recipe_push`\uFF09\uFF1B\u7F3A\u80FD\u529B \u2192 \u6295\u7A3F\u96F6\u4EF6 PR\u3002",
" \u{1F534} **\u4E0D\u8981\u56E0\u70BA\u67E5\u4E0D\u5230\u96F6\u4EF6\u5C31\u6539\u5BEB\u6210 `code` \u7BC0\u9EDE**\u2014\u2014\u90A3\u53EB\u300C\u8179\u8A9E\u8853\u300D\uFF08\u8868\u9762\u7528 Arcrun\u3001",
" \u5BE6\u969B\u5168\u5BEB JS\uFF09\u3002`code` \u53EA\u7528\u65BC\u5C40\u90E8\u6574\u5F62\uFF08\u4F8B\uFF1A\u525D\u6389 LLM \u56DE\u61C9\u7684\u96DC\u8A0A\uFF09\u3002",
@@ -32381,11 +32478,11 @@ async function handleMcpRequest(request, env, orgNamespace, partnerToken, identi
"\u4E0D\u662F\u5931\u6557\u2014\u2014\u5225\u56E0\u70BA\u300C\u53EA\u6709\u4E00\u689D\u8DEF\u6709\u8F38\u51FA\u300D\u5C31\u4EE5\u70BA\u58DE\u6389\u800C\u6539\u5BEB\u6210 code\uFF082026-08-01 \u5BE6\u649E\uFF09\u3002",
"\u7B2C\u4E00\u500B\u7BC0\u9EDE\u56FA\u5B9A\u662F `input`\u3002"
].join("\n");
const instructions = mapInstructions ? `${startHere}
---
${mapInstructions}` : startHere;
const mapSection = mapInstructions ?? (identity.kind === "stale" ? MAP_STALE_NOTE : MAP_UNAVAILABLE_NOTE);
return [KNOWLEDGE_FIRST, startHere, mapSection].join("\n\n---\n\n");
}
async function handleMcpRequest(request, env, orgNamespace, partnerToken, identity) {
const instructions = await buildServerInstructions(env, identity);
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
const server = new McpServer(
{ name: "arcrun-mcp-server", version: "1.0.0" },
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+570 -31
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T16:25:19.210Z",
"repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
"generated_at": "2026-08-15T15:53:28.612Z",
"repo_head": "cacaa33f7d4e51d0011096250a33bb5107c67074",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
"source_commit": "9fa88065abdc5a795d3847711f04101480621af0",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 588587,
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
"js_bytes": 590499,
"content_sha256": "94a21ad012ee0c48d78e9a74752048917ddb4616f1ea089976834acd887a9d14",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -58,11 +58,11 @@
{
"name": "arcrun-kbdb",
"source_dir": "kbdb",
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
"source_commit": "ceb7638d746f0e8847552aef721f6f53ef87ff0b",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 149797,
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
"js_bytes": 160306,
"content_sha256": "47b09d60f8d122f7ec234d146a99bc68b2661c56ad6a98eca146d99ec5de03ea",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -84,6 +84,308 @@
},
"warnings": []
},
{
"name": "arcrun-code",
"source_dir": "registry/components/code",
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"main_module": "worker.mjs",
"main_file": "arcrun-code/worker.mjs",
"js_bytes": 153758,
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
"modules": [
{
"name": "quickjs.wasm",
"type": "application/wasm",
"file": "arcrun-code/quickjs.wasm",
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "code"
}
},
"warnings": []
},
{
"name": "arcrun-mcp",
"source_dir": "mcp",
"source_commit": "1ccee0055ebdb1f6d74094df3e807f56b849fa6f",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1192612,
"content_sha256": "9bc8dffb34d0f36608a78b2cfeb8ee8b5c793f52b4497f5d4d5f69f70700972e",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"OAUTH_KV"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {}
},
"warnings": []
},
{
"name": "arcrun-array-ops",
"source_dir": ".component-builds/array_ops",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-array-ops/worker.mjs",
"js_bytes": 67802,
"content_sha256": "35ad5be6332dde2299efd10be726300c6cace1eb085ab24f08173536ca7fb950",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-array-ops/component.wasm",
"sha256": "74dc9d99f8b9ef43628b55c85c05beb1af8f6f90d78d07cd132ffe4b865147ae"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "array_ops"
}
},
"warnings": []
},
{
"name": "arcrun-auth-oauth2",
"source_dir": ".component-builds/auth_oauth2",
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
"main_module": "worker.mjs",
"main_file": "arcrun-auth-oauth2/worker.mjs",
"js_bytes": 80472,
"content_sha256": "a9b0d45ee6a63e0eebd45554df16d9fa4e6e717e0c31dac13b630e132f1a96db",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-auth-oauth2/component.wasm",
"sha256": "3622c20644e6c8c39487109e7353e2590bad90b1207139e34dfb0cf1c2696664"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"CREDENTIALS_KV",
"RECIPES"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "auth_oauth2"
}
},
"warnings": []
},
{
"name": "arcrun-auth-service-account",
"source_dir": ".component-builds/auth_service_account",
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
"main_module": "worker.mjs",
"main_file": "arcrun-auth-service-account/worker.mjs",
"js_bytes": 81693,
"content_sha256": "68675ec76f3f060309492e55301936a47f4ae0a5e9b00865eb861e0e631e40e5",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-auth-service-account/component.wasm",
"sha256": "7d0808b4b9a2df4fbb54977eafe34a5bdb99e0cc941d97462c62e26f3651951e"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"CREDENTIALS_KV",
"RECIPES"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "auth_service_account"
}
},
"warnings": []
},
{
"name": "arcrun-auth-static-key",
"source_dir": ".component-builds/auth_static_key",
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
"main_module": "worker.mjs",
"main_file": "arcrun-auth-static-key/worker.mjs",
"js_bytes": 81217,
"content_sha256": "140c163d2e8c609eebf281de25d16e521d85004f8437cc842613c249e44aa7ff",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-auth-static-key/component.wasm",
"sha256": "cd0f69a80ced0979749e0ec30b1981795a49eaa56bdb2ba102cd4b64f4670369"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"CREDENTIALS_KV",
"RECIPES"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "auth_static_key"
}
},
"warnings": []
},
{
"name": "arcrun-cron",
"source_dir": ".component-builds/cron",
"source_commit": "6e92ca03727bf22143499d6c0df06b40cb65c17b",
"main_module": "worker.mjs",
"main_file": "arcrun-cron/worker.mjs",
"js_bytes": 78911,
"content_sha256": "17068f03f686aee6c0aec9d7d748bbb1998edc8918260033f985697bc718aa76",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-cron/component.wasm",
"sha256": "afb3a14f8c8f00f4f5573f44153cb1dcf6fc715b2fb47c2afb0aaf95b74d3cde"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "cron"
}
},
"warnings": []
},
{
"name": "arcrun-date-ops",
"source_dir": ".component-builds/date_ops",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-date-ops/worker.mjs",
"js_bytes": 67781,
"content_sha256": "60ebc1d17ab0b9d3bb66f1c71ec63eb5d35bc97ed9f8b4cd1c1c95f4eeb171b7",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-date-ops/component.wasm",
"sha256": "4e2c1329ce4014aa86a9a6449d8699e67c6c616f5c07088dc21216d4036ca41e"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "date_ops"
}
},
"warnings": []
},
{
"name": "arcrun-filter",
"source_dir": ".component-builds/filter",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-filter/worker.mjs",
"js_bytes": 67739,
"content_sha256": "c7fd5070ff7c0f0341e9dd364805e393f9d68056a846f48e6bc61ef6f4e67187",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-filter/component.wasm",
"sha256": "6a0692b386522918b3b5af79cf7877394d1f02c350018dbaef68ef380dad1b9e"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "filter"
}
},
"warnings": []
},
{
"name": "arcrun-foreach-control",
"source_dir": ".component-builds/foreach_control",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-foreach-control/worker.mjs",
"js_bytes": 67928,
"content_sha256": "e52410c9611a93ac7fe38c7b11291cc18711c7da3370492788c3c55bc0fb03d5",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-foreach-control/component.wasm",
"sha256": "8ae161ab37cc78ef6fae65364a47f7accb572378b395c4637daa2ffa57f14632"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "foreach_control"
}
},
"warnings": []
},
{
"name": "arcrun-http-request",
"source_dir": ".component-builds/http_request",
@@ -117,19 +419,19 @@
"warnings": []
},
{
"name": "arcrun-code",
"source_dir": "registry/components/code",
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"name": "arcrun-if-control",
"source_dir": ".component-builds/if_control",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-code/worker.mjs",
"js_bytes": 153758,
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
"main_file": "arcrun-if-control/worker.mjs",
"js_bytes": 67823,
"content_sha256": "557ecf4ee955105af07b2227b08365eb9de8c39ad8ffb323b101397704f438bd",
"modules": [
{
"name": "quickjs.wasm",
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-code/quickjs.wasm",
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
"file": "arcrun-if-control/component.wasm",
"sha256": "87c81e9cf5e664f60c06a364508dd365d49953e1b7afba6a5753fce5a4c82636"
}
],
"compat_date": "2025-02-19",
@@ -140,35 +442,272 @@
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "code"
"COMPONENT_ID": "if_control"
}
},
"warnings": []
},
{
"name": "arcrun-mcp",
"source_dir": "mcp",
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
"name": "arcrun-merge",
"source_dir": ".component-builds/merge",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1179487,
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
"nodejs_compat"
"main_file": "arcrun-merge/worker.mjs",
"js_bytes": 67718,
"content_sha256": "e4216249436000bbb4db0318cc9028f936ca6537d3d656228cfdd3cb31df25f8",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-merge/component.wasm",
"sha256": "55a4d143246df172f60b5e15e7b26e55279deda9db436e3d65bccc25c86d7cd9"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [
"OAUTH_KV"
],
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "merge"
}
},
"warnings": []
},
{
"name": "arcrun-number-ops",
"source_dir": ".component-builds/number_ops",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-number-ops/worker.mjs",
"js_bytes": 67823,
"content_sha256": "91821a291117e033bfa4f013cd88de09479cd4ac43708e37a39df3f31c72aaa6",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-number-ops/component.wasm",
"sha256": "452d1940f6e2b2b36dc97c145abcb7b5bacdbfa7631c742827c2f6f6db87b4ea"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "number_ops"
}
},
"warnings": []
},
{
"name": "arcrun-set",
"source_dir": ".component-builds/set",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-set/worker.mjs",
"js_bytes": 67676,
"content_sha256": "6cac587802cae7dc1651d6b461232abafe40dcf0fbfe9e0adc5b1d8e35b41cc1",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-set/component.wasm",
"sha256": "f5afcf6e57401d2d83b21fb971ec80ca56d5e8e4bdb74a004f3d118875ed57c4"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "set"
}
},
"warnings": []
},
{
"name": "arcrun-string-ops",
"source_dir": ".component-builds/string_ops",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-string-ops/worker.mjs",
"js_bytes": 67823,
"content_sha256": "c85166e357b76828c7020426d912b591006152ac38f024a095a4d9dcd10dee72",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-string-ops/component.wasm",
"sha256": "41f3243ee18bb9f4fcbaa417bd773347128dec2971132e240eddec19ba6a9da2"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "string_ops"
}
},
"warnings": []
},
{
"name": "arcrun-switch",
"source_dir": ".component-builds/switch",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-switch/worker.mjs",
"js_bytes": 67739,
"content_sha256": "e0e7689dbba735a254556272d6d233396c7fa05cc0fba5835a3e20b0ddf00fca",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-switch/component.wasm",
"sha256": "6d993dc9b1affe16b600aaa7cae5af977108cc08513bb11d9d9db908285fea8b"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "switch"
}
},
"warnings": []
},
{
"name": "arcrun-try-catch",
"source_dir": ".component-builds/try_catch",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-try-catch/worker.mjs",
"js_bytes": 67802,
"content_sha256": "a5fbcbf452ab00269dc857997df3059bee7eb1f2c1b322d249146ab4e5e5bac9",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-try-catch/component.wasm",
"sha256": "10eaddc5ef0d3dc68e3f009970541ac7f5fce30dcdbecec9a333afd5a29e3486"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "try_catch"
}
},
"warnings": []
},
{
"name": "arcrun-validate-json",
"source_dir": ".component-builds/validate_json",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-validate-json/worker.mjs",
"js_bytes": 67886,
"content_sha256": "2d0efa26e9fe38db87eb9585daf72e0f2ba8c859d15d1b65f36798656cc4e83b",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-validate-json/component.wasm",
"sha256": "8142ffd3cc57efbb9128faaf450b646111b0f2e2575cc29a7b562a7b185912ab"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "validate_json"
}
},
"warnings": []
},
{
"name": "arcrun-wait",
"source_dir": ".component-builds/wait",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-wait/worker.mjs",
"js_bytes": 67697,
"content_sha256": "3d1b46e7b1ff056abb2c1aeed738d23d58eaa7c8f36b674e4ea2ed1602015f9e",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-wait/component.wasm",
"sha256": "52ca2d184bf3dcc646bb94f29f7f3e69b5540276363e83a54cb435ff33ca2f3c"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "wait"
}
},
"warnings": []
},
{
"name": "arcrun-rag-ui",
"source_dir": "console-ui/public",
"source_commit": "614fe448124dbd25d8228e37723473f1704f3a7a",
"main_module": "worker.mjs",
"main_file": "arcrun-rag-ui/worker.mjs",
"js_bytes": 524513,
"content_sha256": "5825f427e4211dd4b0eee969c94c8bfa856f65b8a48fc718e137f3b5d074e1ff",
"modules": [],
"compat_date": "2026-07-01",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {}
},
"ui_fingerprint": "82bc636990e60172663f7ce90c75d8b434193b8532b21f1d4b57c5fb330cbe11",
"ui_file_count": 9,
"warnings": []
}
],
"excluded": [
{
"name": "arcrun-claude-api",
"dir": ".component-builds/claude_api",
"reason": "component.wasm 不在版控(.gitignore 明文排除=這顆不是可出貨的零件)"
}
],
"notes": []
}
+16 -5
View File
@@ -65,15 +65,26 @@
## 🔴 第一鐵律:wiki 是判準,不准跳過(2026-07-20/21 leo 兩度點破)
**要查任何東西之前,先搜尋 wiki——用 grep,不是只讀開頭幾行。**
**要查任何東西之前,先照索引走:`system-dev/wiki/INDEX.md` → 下層 `00-INDEX.md` → 卡片。**
🔴 **2026-08-15 leo 糾正:本條原本寫的是「用 grep」,那是錯的。**
grep 是平面搜尋,它繞過索引,於是**索引永遠不會被驗證、也永遠不會變準**——
而 AI 又用「索引可能不準」當理由繼續 grep。這是自我實現的。
實測那天:`matrix/arcrun` 的 wiki 有 64 張卡,**只有 1 張走得到索引**
(唯一那張是 leo 手寫的;63 張機器產的卡從沒加入任何索引),
而 AI 從沒發現——**因為 grep 找得到,破損就永遠不會浮現。**
**grep 不是禁令,是異常訊號**:
1. 先照索引走
2. 索引走不到 → **那是索引壞了,先講出來**(哪一層缺、缺什麼)
3. 講完才准 fallback 用 `grep -rin "<關鍵字>" system-dev/wiki/`
⇒ 每一次 grep 都該留下一筆「索引缺陷」,而不是變成習慣。
(全面禁止 grep 也是錯的——索引壞掉那天 AI 會瞎掉,而且沒人知道。)
> leo:「花很多力氣去產生 wiki,最重要的就是要可以查詢,**結果要查的時候就跳過,那就白寫了**。」
> 「重點是你自己的記憶對嗎?而你有按照規定去切實讀 wiki 嗎?」
```bash
grep -rin "<本題關鍵字>" system-dev/wiki/
```
**三條硬規則**
1. **wiki 與程式碼/歷史文件衝突 → 以 wiki 為準**。程式碼反映「還沒清乾淨」,不等於「還在用」。
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。那是當時狀態,不是永久禁令。
+45
View File
@@ -721,6 +721,22 @@ export async function downloadAndDeploy(
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql${execLogMigPath}`);
}
// 3.8 樹狀 record 模型(0007v7 定稿 2026-08-15):record 有身分、關係是唯一機制、
// entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列
// 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句
// ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時
// 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。
const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql');
if (existsSync(treeMigPath)) {
try {
await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql${treeMigPath}`);
}
}
const cypherExecutorUrl = ctx.workerSubdomain
@@ -751,6 +767,35 @@ export async function downloadAndDeploy(
};
}
/**
* migration duplicate column0007
*
* applyD1Migration /query
* 0007 ADD COLUMN duplicate columnSQLite
* IF NOT EXISTS migration
* duplicate column
* compile-migrations.mjs --
* 0007
*/
async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise<void> {
const statements = sql
.split('\n')
.map((l) => l.replace(/--.*$/, ''))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const stmt of statements) {
try {
await applyD1Migration(ctx, stmt);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
throw new Error(`${stmt.slice(0, 60)}… → ${msg}`);
}
}
}
/**
* D1 SQL migration CF API `/d1/database/{id}/query` wrangler
* init ctx.apiToken + accountIdquery 0001_base.sql
+93 -6
View File
@@ -24,6 +24,19 @@ import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* 清單端點每頁抓幾筆100 CF 這幾支端點通用的安全上限KV 官方上限就是 100
* 這個數字**不影響正確性**`cfListAll` 會一直翻到底它只決定要打幾次 API
*/
const LIST_PER_PAGE = 100;
/**
* 翻頁的安全上限100 × 100 10,000 遠超 CF 的帳號上限
* KV namespace 每帳號 1,000 正常帳號永遠碰不到
* 碰到了就是 CF 那邊的行為變了這種時候**寧可 throw 也不回一份不完整的清單**
*/
const LIST_MAX_PAGES = 100;
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
@@ -57,9 +70,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/**
* HTTP status 交回呼叫端自己判斷要區分404 不存在其他錯誤時用
* `resultInfo` CF 回應裡的 `result_info`不分頁的端點是 `null``cfListAll` 靠它翻頁
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
* @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
@@ -76,7 +90,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
return { ok: true, status: res.status, result: data.result, resultInfo: data?.result_info ?? null };
}
/**
@@ -90,6 +104,75 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
return result;
}
/**
* 把一支列出帳號上有什麼的端點**翻到底**回傳全部項目
*
* 為什麼非翻不可這是 Arcrun#123 的續集不是效能優化
* 三支清單方法原本只打 `?per_page=100`也就是**只看第一頁**同一個截斷
* #123 的修法前後後果**不一樣**
*
* | 被截掉的那顆 | 規則走到哪 | 結果 |
* |---|---|---|
* | #123 修好****worker 綁著它但它落在第二頁 | 2b 綁著的資源不見了 | 產生 blocker**停手**過度保守但安全 |
* | #123 修好****名字落在第二頁 | 2c 這個名字沒被佔走 | **去建 CF title already exists #123 的死路原樣回來** |
*
* 修法把這個洞從叫得太大聲變成**安靜地復發**所以規約是
* **看不完整就不准當作看完了**翻不完或翻出來的數量對不上 CF 自己回報的
* `total_count`一律 throw `planResources` 把它變成 blocker
* README 規則第 3 說不準就整趟停手一顆都不建
*
* 三支端點的分頁行為不一樣這裡刻意不假設它們同款2026-08-14 geek6688 帳號實測
* - `/storage/kv/namespaces`真分頁`result_info` `{page, per_page, count, total_count, total_pages}`
* - `/d1/database`真分頁 `result_info` **沒有 `total_pages`**實測 `{page, per_page, count, total_count}`
* **不准拿 `total_pages` 當終止條件**那個欄位在 D1 上是 `undefined`
* - `/vectorize/v2/indexes`**不分頁**`result_info` `null` `page``per_page` 也被忽略一次回全部
*
* 所以終止條件只用三支都有或三支都沒有的兩件事`result_info` 在不在`total_count` 對不對得上
* 對不分頁的那支這支等於只打一次就回來那兩個被忽略的參數實測無害
* 而萬一 CF 哪天替它補上分頁這支會自己跟著翻不必等下一次災情才想起來改
*
* @param {string} path 不含分頁參數的端點路徑可自帶其他 query
* @param {string} what 出錯訊息裡怎麼稱呼它
* @returns {Promise<any[]>}
*/
async function cfListAll(path, what) {
/** @type {any[]} */
const items = [];
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
const sep = path.includes('?') ? '&' : '?';
const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`);
if (!res.ok) {
throw new Error(`${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`);
}
const batch = Array.isArray(res.result) ? res.result : [];
items.push(...batch);
const info = res.resultInfo;
// 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。
if (!info) return items;
const total = Number(info.total_count);
if (Number.isFinite(total)) {
if (items.length >= total) return items;
// CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。**
if (batch.length === 0) {
throw new Error(
`${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` +
`看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`,
);
}
continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位)
}
// 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。
if (batch.length < LIST_PER_PAGE) return items;
}
throw new Error(
`${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` +
`這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`,
);
}
return {
cfRaw,
@@ -122,7 +205,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @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');
// 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll
const result = await cfListAll('/storage/kv/namespaces', 'KV namespace');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
@@ -131,7 +215,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
// 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。
const result = await cfListAll('/d1/database', 'D1 資料庫');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
@@ -140,8 +225,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
// 這支端點**目前不分頁**`result_info` 是 null),走 cfListAll 等同只打一次;
// 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。
const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index');
return result.map((i) => i.name);
},
/**
+96 -17
View File
@@ -106,6 +106,19 @@
* @property {string} binding
* @property {string} worker 需要它的 worker script = wrangler.toml `name`
* @property {string} createName
* @property {boolean} [createNameIsOurs]
* 呼叫端在此**聲明**`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
* 帳號上若已經有一顆**恰好同名**的資源它只可能是我們上一次沒裝完留下的Arcrun#123
*
* 🔴 這個聲明是接管同名資源**唯一**依據預設 falsefail-closed
* 只有在名字**推導得出而且推導的輸入是使用者自己的身分**時才准聲明 true
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
* slug `SHA-256('arcrun-rag:' + email)` 取前 8 同一個 email 每次算出同一組名字
* 別人算不到也不會不小心撞上
*
* **不准**因為名字看起來像我們的就聲明 true`acr` 那條從 wrangler.toml 讀到的
* createName 是裸 binding `WEBHOOKS` toml 宣告的庫名`arcrun-kbdb`
* 那種名字使用者自己也可能拿去用**證明不了是我們的**所以那條路一律不聲明
*/
/**
@@ -113,7 +126,12 @@
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
* @property {string} from 從哪顆已部署的 worker 上讀到的`reclaimed` 時為空字串
* **沒有任何 worker 綁著它正是接收它的前提**Arcrun#123不是漏填
* @property {boolean} [reclaimed]
* true = 這顆不是從某顆 worker 的綁定讀出來的而是帳號上已經有一顆我們自己命名的同名資源
* 卻沒有人綁著 上一次沒裝完留下的這次把它接回來用Arcrun#123
* 給呼叫端做診斷統計用**使用者不必知道殘骸這個詞**對外一律講沿用你原本的資源
*/
/**
@@ -144,7 +162,11 @@
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* 🔴 接回上次沒裝完留下的那顆`reclaimed`**仍然算 `adopted`**不另開第三種值
* 它本來就是沿用既有資源而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
* 安裝器那句沿用你原本的 N 項資源不會因為多一種值就悄悄漏數
* @property {string} [from]
* @property {boolean} [reclaimed] PlannedAdopt.reclaimedArcrun#123
*/
/**
@@ -251,19 +273,25 @@ export async function planResources(api, requirements, mode) {
else byKey.set(key, [req]);
}
/** @type {Map<ResourceKind, Set<string>>} */
// 帳號上現有的資源,一種只查一次。**名字 → 身分**KV/D1 是 idVectorize 的身分就是名字)。
//
// 為什麼連名字都收下來(本來只留 `.values()`):
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
/** @type {Map<ResourceKind, Map<string, string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = async (kind) => {
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
const listExistingByName = 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;
/** @type {Map<string, string>} */
let map;
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
else if (kind === 'd1') map = await api.listD1Databases();
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
existingCache.set(kind, map);
return map;
};
for (const [, reqs] of byKey) {
@@ -291,10 +319,10 @@ export async function planResources(api, requirements, mode) {
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Set<string>} */
/** @type {Map<string, string>} */
let existing;
try {
existing = await listExisting(kind);
existing = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
@@ -302,7 +330,7 @@ export async function planResources(api, requirements, mode) {
);
continue;
}
if (!existing.has(value)) {
if (![...existing.values()].includes(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
@@ -316,12 +344,62 @@ export async function planResources(api, requirements, mode) {
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding全新帳號
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
//
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
//
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
// ——會弄丟資料的是那個動作,不是這個。
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
// `createNameIsOurs`),沒聲明就停手。
/** @type {Map<string, string>} */
let existingByName;
try {
existingByName = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
`${msg(e)})。不確定就不建——停手。`,
);
continue;
}
const createName = reqs[0].createName;
const sameName = existingByName.get(createName);
if (sameName !== undefined) {
if (!reqs[0].createNameIsOurs) {
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
blockers.push(
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]}` +
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
);
continue;
}
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
continue;
}
create.push({
kind,
binding,
createName: reqs[0].createName,
createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
@@ -401,6 +479,7 @@ export async function applyResourcePlan(api, plan) {
value: a.value,
origin: 'adopted',
from: a.from,
...(a.reclaimed ? { reclaimed: true } : {}),
});
}
/** @type {string[]} */
+101
View File
@@ -135,6 +135,13 @@ class FakeCloudflare implements ResourceApi {
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
// 🔴 Arcrun#123:真的 Cloudflare **不准同名**——
// `a namespace with this account ID and title already exists`。
// 這個假帳號原本沒有模擬這條限制,於是「上一次裝到一半死掉」的帳號在測試裡
// 看起來只是「多建幾顆孤兒」,實際上是**再也裝不起來**。少了這一行,#123 測不出來。
if (this.kv.has(title)) {
throw new Error('a namespace with this account ID and title already exists');
}
const id = `NEW-kvid-${this.createdKv.length}`;
this.kv.set(title, id);
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
@@ -486,6 +493,100 @@ test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});
// ═════════════════════════════════════════════════════════════════════════════
// Arcrun#123 —— 「上一次裝到一半死掉」的帳號,再裝一次要能成功
//
// 這一格與 #97 的差別:#97 是「worker 綁著資源,卻被重新綁到新建的空殼」(會弄丟資料);
// #123 是「資源建好了、worker 一顆都還沒部署」——**沒有任何綁定可以當事實**,
// 而帳號上偏偏已經有一批同名資源 ⇒ 舊規則判「可以新建」⇒ CF 拒絕 ⇒ 這個帳號從此裝不起來。
//
// 封測者 1.4.45 實撞。逃過驗證的原因:我們只測乾淨帳號與完整安裝。
// ═════════════════════════════════════════════════════════════════════════════
/**
* `createName` ****
* `arcrun-rag-<slugFromEmail(email)>-…` createNameIsOurs
* collectRequirements() tomlcreateName binding
*/
function installerRequirements(claimOwnership = true): BindingRequirement[] {
const own = claimOwnership ? { createNameIsOurs: true } : {};
const out: BindingRequirement[] = [];
for (const { requirements } of [collectRequirements()]) {
for (const r of requirements) {
if (r.kind === 'kv_namespace') {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kv-${r.binding.toLowerCase()}`, ...own });
} else if (r.kind === 'd1') {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kbdb`, ...own });
} else {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-embed`, ...own });
}
}
}
return out;
}
test('#123 ①:上次裝到一半死掉的帳號 → 接回上次留下的那批,一顆都不必新建', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true }); // 資源在、worker 一顆都沒有
const kvBefore = cf.kv.size;
const plan = await planResources(cf, installerRequirements(), 'init');
assert.deepEqual(plan.blockers, [], '半殘帳號不該有 blocker——用戶只要再按一次就該裝得起來');
assert.deepEqual(plan.create, [], '一顆都不該新建');
assert.ok(plan.adopt.length > 0 && plan.adopt.every((a) => a.reclaimed === true),
'全部都是「接回上次留下的」');
const resolved = await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
assert.equal(cf.kv.size, kvBefore, '帳號上的顆數不變(沒有再留下一批孤兒)');
// 綁到的是上次建的那幾顆本尊
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.value, 'kvid-webhooks');
assert.equal(resolved.get(bindingKey('d1', 'DB'))!.value, 'd1id-kbdb');
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.origin, 'adopted',
'origin 維持 adopted——呼叫端既有的 adopted/created 統計不會漏數');
});
test('#123 ①對照組:修好之前,同一個帳號會被 CF 用「title already exists」擋死', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
// 不聲明所有權 ⇒ 走的是修好之前的判斷(「沒人綁著就可以新建」)。
// 舊版會直接送 POST → 撞 CF 的同名限制;新版在 plan 階段就停手,兩者都裝不起來,
// 差別在**新版一顆資源都不會被建出來**,而且訊息說得出人話。
const plan = await planResources(cf, installerRequirements(false), 'init');
assert.ok(plan.blockers.length > 0, '證明不了是自己的 → fail-closed 停手');
assert.match(plan.blockers.join('\n'), /RES-NAME-TAKEN/, '要給得出可回報的錯誤碼');
assert.doesNotMatch(plan.blockers.join('\n'), /後台|dashboard/,
'不准叫使用者自己去 Cloudflare 後台處理(#121D88');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '被擋下時一顆都不能被建出來');
});
test('#123 ②紅線:名字真的不是我們的(走 toml 的裸 binding 名)→ 停手,不接管也不新建', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
await cf.createKvNamespace('WEBHOOKS'); // 使用者自己建的、剛好叫這個名字
cf.createdKv.length = 0;
const { requirements } = collectRequirements(); // createName = 裸 binding 名,不得聲明所有權
const plan = await planResources(cf, requirements, 'init');
assert.ok(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '一顆都沒建');
});
test('#123 ③:全新帳號照舊建得出整套(D82 第一步不可退化)', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; // 真正的空帳號
const plan = await planResources(cf, installerRequirements(), 'init');
assert.deepEqual(plan.blockers, [], '全新帳號不該有 blocker');
assert.ok(plan.adopt.length === 0, '全新帳號沒有東西可沿用');
const resolved = await applyResourcePlan(cf, plan);
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length, '9 顆 KV 全部建出來');
assert.equal(cf.createdD1.length, 1, 'D1 建一顆(兩個綁定共用)');
assert.ok([...resolved.values()].every((r) => r.origin === 'created'), '全部都是新建的');
});
test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
const orig = globalThis.fetch;
try {
+6 -1
View File
@@ -840,7 +840,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +
// Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、
// 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板
// 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組' +
(!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0
? '<span class="dim">・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)</span>' : '') + '</span>' +
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
}).join('');
}
@@ -245,6 +245,35 @@ export async function resolveCredentialRefs(
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KVT7)。
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
// ── 全部取到 → 不必打 auth_static_key workerArcrun#1242026-08-14)────────────
//
// 這條路徑上,那顆 worker 唯一比這裡多做的事,是「幫**沒取到**的 name 回頭翻舊 KV」
// T7 雙讀 fallback)。當 nameList 每一個都已經從新家取到明文時,它收到
// `resolved_secrets` 後就是原樣回傳——回填本來就由下面的 `replaceCredentialRefs`
// 在本檔做。**所以那趟往返在「全取到」時不產生任何結果差異。**
//
// 為什麼這不是效能微調,是修 bug`wasmWorkerUrl` 推導出的
// `arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` **在網頁安裝器裝出來的實例上不存在**
// (首裝只裝 cypher-executor / kbdb / http_request / code 四顆,auth primitive 不在其中,
// 也沒有「用到才長」的機制)。於是 fetch 撞 CF 邊緣的 404 `error code: 1042`
// ⇒ 下面的 `!res.ok` throw ⇒ **凡是工作流用到 {{credential.X}} 的節點全部 500**。
// 實測:leo21c 與全新安裝的 youlin 兩台,問答與寫入路徑一字不差地同死。
//
// 🔴 給下一個人:**這個 1042 跟 d2048e295a1462 那兩輪的 1042 不是同一個病。**
// 那兩輪是「same-zone fetch 被 CF 擋」(解法=`global_fetch_strictly_public` flag
// 且 service binding 的解法**已被 revert、不要再拿回來**)。本案是「**目標 worker 根本不存在**」
// ——同一個錯誤碼、兩種原因。照舊帳去動 flag 或 binding 對本案完全無效(flag 早就在了)。
//
// 邊界(rule 02 §2.2)不變:本檔仍然不解密、不展開模板、不組 JWT。
// 明文的來源是 `secret_get`host function,實作 = env[ref]),與改動前完全同一條路;
// 這裡只是不再為了「拿一份自己已經拿到的東西」去 fetch 一顆可能不存在的 worker。
//
// 刻意保留 WASM 路徑:只要有任何一個 name 沒從新家取到(舊租戶、遷移過渡期的 KV 存量),
// 就照舊全量交給 WASM 走 T7 雙讀——**不縮減既有能力,只在等價時省掉那一跳**。
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
return replaceCredentialRefs(data, resolvedSecrets) as Record<string, unknown>;
}
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
const res = await fetch(url, {
method: 'POST',
@@ -51,11 +51,6 @@ 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',
]);
/**
+28 -10
View File
@@ -131,9 +131,16 @@ export function authStorePresent(env: Bindings): boolean {
return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
}
/** 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 */
export function authStoreWritable(env: Bindings): boolean {
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
/**
*
*
* `tokenOverride`2026-08-14arcrun-rag#99`env.CF_SECRETS_API_TOKEN`
* leo
* OAuth token `/console/setup``/portal/admin/bootstrap`
* `routes/credentials.ts putWorkerSecret`
*/
export function authStoreWritable(env: Bindings, tokenOverride?: string): boolean {
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
}
/**
@@ -201,11 +208,21 @@ export function newAuthUserId(): string {
* console 0 users
*
*/
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
if (!authStoreWritable(env)) {
export async function writeAuthStore(env: Bindings, data: AuthStoreData, tokenOverride?: string): Promise<void> {
if (!authStoreWritable(env, tokenOverride)) {
// arcrun-rag#992026-08-14leo 傳實測畫面點破):舊文案叫用戶「重新執行安裝/更新」,
// 但在這個訊息會出現的所有情況下(包含安裝精靈本身,若它沒能把 install token 帶到)那句話
// 都不保證成立——安裝/更新從來不會把 CF_SECRETS_API_TOKEN 種成一個常駐的值。
// 對一個做不到的動作下指令=把人導向死路(他會以為自己操作錯誤,反覆重試)。
// 改成誠實描述現況+給得出去的下一步(回報支援),不再承諾一個我們自己都不確定會生效的動作。
// 只列 CF_SECRETS_API_TOKENCF_ACCOUNT_ID 已查證全新實例一定有
//installer/oauth-prototype/worker.js:1354 無條件注入每一顆部署的 worker),
// 列兩項只會讓下一個人以為兩項都要查,白工一次。
throw new AuthStoreWriteError(
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
'這台實例目前寫不進認證儲存(缺可用的 Cloudflare 寫入憑證:CF_SECRETS_API_TOKEN)。' +
'這是平台端的已知限制,不是你操作錯誤——目前沒有你自己在畫面上能做的下一步,' +
'請把這則訊息完整截圖/複製給支援,並註明你剛才在做什麼(例如:安裝精靈裡建立第一個帳號、' +
'事後新增使用者、或修改密碼),會需要人工協助排除。',
);
}
@@ -232,10 +249,10 @@ export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promis
const existing = shardNames(env);
for (let i = 0; i < shards.length; i++) {
await putWorkerSecret(env, shardNameOf(i), shards[i]);
await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
}
for (const name of existing) {
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
}
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
@@ -320,11 +337,12 @@ function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
export async function mutateAuthStore(
env: Bindings,
fn: (data: AuthStoreData) => void | Promise<void>,
tokenOverride?: string,
): Promise<AuthStoreData> {
await hydrateFromAccelerator(env);
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
await fn(next);
await writeAuthStore(env, next);
await writeAuthStore(env, next, tokenOverride);
return next;
}
+58 -62
View File
@@ -22,19 +22,18 @@
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
// D61ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV`console:credentials`
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
// 讀到就順手搬過去(見 loadCredentials)。
import {
AuthStoreWriteError,
authStoreStatus,
hydrateFromAccelerator,
mutateAuthStore,
readAuthStore,
type AuthConsoleRecord,
} from '../lib/portal-auth-store';
// D61 補充(2026-08-14leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
// D61 把這組管理員帳密搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器
// 照名字重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
// `CF_SECRETS_API_TOKEN`,而這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
// 第一個帳號(arcrun-rag#99)。
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`Arcrun#97)解掉——
// 現在每次安裝/更新都會沿用既有 binding,不會再把 SESSIONS_KV 重指到空資源。既然病根已解,
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳密改回住 SESSIONS_KV**
// `console:credentials`binding,永不需要外部 CF token)。
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
// 零外部憑證需求(只有寫入才要 token)——查到就順手搬回 SESSIONS_KV(見 loadCredentials)。
import { readAuthStore } from '../lib/portal-auth-store';
// Arcrun#108:租戶字串唯一產地。
import { knowledgeOwner } from '../lib/tenant';
@@ -94,52 +93,59 @@ function tenantOf(c: { env: Bindings }): string {
return knowledgeOwner(c.env);
}
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
// ── 帳密的家(2026-08-14 起:SESSIONS_KV 為主,認證儲存為舊實例回退讀路徑)────────────
/**
* console **Workers Secrets**退KV
* best-effort
* console **SESSIONS_KVbinding**退
* D61 CF Workers Secrets
* SESSIONS_KVbest-effort
*/
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
let fromStore = readAuthStore(env).console;
if (!fromStore && (await hydrateFromAccelerator(env))) {
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
fromStore = readAuthStore(env).console;
}
if (fromStore) return { creds: fromStore, source: 'secrets' };
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'kv' | 'legacy-secrets' | 'none' }> {
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
if (!raw) return { creds: null, source: 'none' };
let legacy: StoredCredentials | null = null;
try {
legacy = JSON.parse(raw) as StoredCredentials;
} catch {
return { creds: null, source: 'none' };
if (raw) {
try {
return { creds: JSON.parse(raw) as StoredCredentials, source: 'kv' };
} catch {
/* KV 這份壞了,當作沒有,往下查舊家 */
}
}
// 舊家(D61 的認證儲存):純讀 env 字串,零網路呼叫、不需要任何外部 CF 憑證。
const legacy = readAuthStore(env).console;
if (!legacy) return { creds: null, source: 'none' };
try {
await mutateAuthStore(env, (data) => {
if (!data.console) data.console = legacy as AuthConsoleRecord;
});
// best-effort 搬回 SESSIONS_KV——這是 binding put,本來就不需要外部 token,
// 幾乎不會失敗;失敗也不影響本次用這份舊資料繼續(狀態看 /console/auth-status)。
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
} catch {
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store */
/* 照舊用這份,下次再試著搬一次 */
}
return { creds: legacy, source: 'legacy-kv' };
return { creds: legacy as StoredCredentials, source: 'legacy-secrets' };
}
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
/**
* console ** SESSIONS_KV**binding CF token
* Workers Secrets
*/
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
await mutateAuthStore(env, (data) => {
data.console = record;
});
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
}
/** `/console/auth-status`、`/health` 共用的儲存狀態區塊(不洩漏 email/雜湊,只回統計)。 */
function consoleAuthStoreStatus(env: Bindings): { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean } {
return {
home: 'sessions-kv',
writable: true, // binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
legacy_secrets_present: readAuthStore(env).console !== null,
};
}
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
consoleAuthRouter.get('/console/auth-status', async (c) => {
const { creds, source } = await loadCredentials(c.env);
// D61多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
// 多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
});
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
@@ -171,11 +177,12 @@ consoleAuthRouter.post('/console/setup', async (c) => {
const hash = await hashPassword(password, salt);
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
try {
// 2026-08-14 起:寫 SESSIONS_KVbinding),不再需要安裝精靈遞任何臨時 CF token
// arcrun-rag#99 那個結構性缺口——見檔頭說明——已經隨儲存層搬回 binding 一併解掉)。
await saveCredentials(c.env, record);
} catch (e) {
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
// 寫不進去就誠實回報(不假綠:binding put 幾乎不會失敗,於是沒人處理過這條路)
return c.json({ error: `帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
}
const token = randomHex(32);
@@ -206,8 +213,7 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
try {
await saveCredentials(c.env, record);
} catch (e) {
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
return c.json({ error: `新帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
}
return c.json({ success: true });
});
@@ -216,12 +222,13 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
consoleAuthRouter.post('/console/login', async (c) => {
const { creds: existing } = await loadCredentials(c.env);
if (!existing) {
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
// 明顯失敗(#10「寧可明顯失敗,不要靜默錯置」):這是「這台實例讀不到認證資料」,
// 不是「你帳密打錯」——兩句話混成一句正是 2026-08-09 leo 被誤鎖 15 分鐘的根因。
return c.json(
{
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
code: 'auth_store_empty',
auth_store: authStoreStatus(c.env),
auth_store: consoleAuthStoreStatus(c.env),
},
400,
);
@@ -232,19 +239,8 @@ consoleAuthRouter.post('/console/login', async (c) => {
const password = body?.password ?? '';
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
let creds = existing;
let hash = await hashPassword(password, creds.salt);
if (email !== creds.email || hash !== creds.hash) {
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
if (await hydrateFromAccelerator(c.env)) {
const again = (await loadCredentials(c.env)).creds;
if (again) {
creds = again;
hash = await hashPassword(password, creds.salt);
}
}
}
if (email !== creds.email || hash !== creds.hash) {
const hash = await hashPassword(password, existing.salt);
if (email !== existing.email || hash !== existing.hash) {
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
+22 -6
View File
@@ -92,9 +92,21 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
/**
* CF Workers Scripts secrets API worker per-script secret
* API secret create/update/delete/list D19
*
* `tokenOverride`2026-08-14arcrun-rag#99`writable:false`
* worker `env.CF_SECRETS_API_TOKEN` 07-29
* pending-changes.mdcredential n8n D61
* `/console/setup``/portal/admin/bootstrap`
* **** OAuth token
* `workers-scripts.write` scope credential
* `installer/oauth-prototype/worker.js` `putWorkerSecretDirect`/`seedCredential`
* D36 1 token ****
* cypher
* > worker env
*/
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string, tokenOverride?: string): Promise<void> {
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
if (!token || !env.CF_ACCOUNT_ID) {
throw new Error(
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
'credential-store-migration.md T3acr init/update 應確保這兩項就緒)',
@@ -104,7 +116,7 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s
const res = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: secretRef, text: value, type: 'secret_text' }),
@@ -121,15 +133,19 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s
/**
* CF Workers Scripts secrets API per-script secretT9
* 404
*
* `tokenOverride` `putWorkerSecret` auth store
* token
*/
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
export async function deleteWorkerSecret(env: Bindings, secretRef: string, tokenOverride?: string): Promise<void> {
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
if (!token || !env.CF_ACCOUNT_ID) {
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
}
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` },
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 404) return;
const body = (await res.json().catch(() => null)) as
+18 -1
View File
@@ -1,9 +1,26 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { authStoreStatus } from '../lib/portal-auth-store';
import { readAuthStore } from '../lib/portal-auth-store';
export const healthRouter = new Hono<{ Bindings: Bindings }>();
/**
* 2026-08-14 console SESSIONS_KVportal KBDB
* binding-based CF token console-auth.tsportal.ts
* D61 `legacy_*_present` D61 CF Workers
* Secrets email
*/
function authStoreStatus(env: Bindings): {
console: { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean };
portal_users: { home: 'kbdb'; writable: true; legacy_secrets_present: boolean };
} {
const legacy = readAuthStore(env);
return {
console: { home: 'sessions-kv', writable: true, legacy_secrets_present: legacy.console !== null },
portal_users: { home: 'kbdb', writable: true, legacy_secrets_present: legacy.users.length > 0 },
};
}
// t162leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
// 但本端點過去只回 {ok:true}**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
+17 -4
View File
@@ -81,20 +81,33 @@ kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => {
// ── records(以租戶 namespace 為 owner_id 隔離)────────────────────────────────
// POST /kbdb/records — 填一筆 recordtemplate + values)。owner_id 自動注入。
// POST /kbdb/records — 填一筆 recordtemplate + valuesentry_ids)。owner_id 自動注入。
//
// `entry_ids`Arcrun#128)= slot 指向**既有** entry 的 id,不新建、不複製;與 values 並存
// (給字串照舊新建)。這裡維持純轉發,判斷與擋人全在基本盤 kbdb(薄殼鐵律,見檔頭):
// · 兩者都沒給/型別不對 → base 回 400
// · 指到別人的 entry → base 擋(它比對被參照 entry 的 owner_id 與這裡注入的租戶身份,
// 所以「呼叫端自己指定 entry_id」這條新路徑不會變成跨租戶的門)
// 🔴 為什麼通道要一起開:#129wiki template)與 #130(三元組正規化)的寫入端走這扇門。
// 基本盤補好而通道不開=能力在、沒人打得到——同 PATCH 那次(b6ef0f0)的教訓。
kbdbProxyRouter.post('/kbdb/records', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || !body.template || !body.values) {
return c.json({ error: 'template values 必填' }, 400);
if (!body || !body.template || (!body.values && !body.entry_ids)) {
return c.json({ error: 'template 必填,values 與 entry_ids 至少要有一個' }, 400);
}
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records`, {
method: 'POST',
headers,
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }),
body: JSON.stringify({
template: body.template,
...(body.values ? { values: body.values } : {}),
...(body.entry_ids ? { entry_ids: body.entry_ids } : {}),
owner_id: owner,
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
+149 -77
View File
@@ -31,19 +31,25 @@ import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential, hasCredential } from './credentials';
// D61Leo/arcrun-rag#55ADR D61):**帳號不再住知識資料庫**。
// 讀寫一律先走 lib/portal-auth-storeCF Workers Secrets,不靠任何 binding),
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
// D61 補充(2026-08-14leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
// D61 把帳號搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器照名字
// 重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
// `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
// 第一個帳號、也永遠加不了第二個(arcrun-rag#99)。
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`Arcrun#97)解掉——
// 現在每次安裝/更新都會沿用既有 binding,不會再把 KBDB D1 重指到空資源。既然病根已解,
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳號改回住 KBDB**
// (走 base HTTP APID38 零 SQLbinding,永不需要外部 CF token)。
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
// 零外部憑證需求(只有寫入才要 token)——登入成功就順手搬進 KBDB(見 promoteToKbdb)。
import {
AuthStoreWriteError,
authStoreRecentlyWritten,
authStoreStatus,
findAuthUserByEmail,
findAuthUserById,
hydrateFromAccelerator,
isAuthStoreId,
mutateAuthStore,
newAuthUserId,
readAuthStore,
type AuthUserRecord,
} from '../lib/portal-auth-store';
@@ -114,7 +120,9 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
try {
return await fn();
} catch (e) {
// D61認證儲存寫不進去要**看得出來是這件事**(不是 KBDB 的錯,也不是密碼的錯)
// 帳號還住在舊家(D61 認證儲存)時,寫入需要外部 CF_SECRETS_API_TOKEN——
// 新家(KBDB)不需要,但尚未搬遷的既有帳號仍可能撞到這格,要看得出來是這件事
// (不是 KBDB 的錯,也不是密碼的錯),且會在該帳號下次登入時自動搬進 KBDB 而解除。
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
}
@@ -193,7 +201,7 @@ export async function ensurePortalTemplates(
return { created, existing, errors };
}
// ── D61 認證儲存 ⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)─────
// ── 認證儲存(D61 舊家)⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)──
function authUserToRecord(u: AuthUserRecord): PortalRecord {
return {
@@ -227,33 +235,41 @@ function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUser
}
/**
* KBDB
* best-effort
* #55****
* D61 CF Workers Secrets KBDB
* best-effortKBDB
* 沿 #55****
* record_id null沿 record_id
* session
*/
async function promoteLegacyUser(env: Bindings, rec: PortalRecord): Promise<void> {
async function promoteToKbdb(env: Bindings, rec: PortalRecord): Promise<string | null> {
try {
const email = (rec.values.email ?? '').toLowerCase();
if (!email) return;
if (findAuthUserByEmail(env, email)) return;
await mutateAuthStore(env, (data) => {
if (data.users.some((u) => u.email === email)) return;
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
if (!email) return null;
const already = await findKbdbUserRecordId(env, email);
if (already) return already; // 更早一次登入已經搬過了,不重複建
return await createKbdbUserRecord(env, email, {
display_name: rec.values.display_name ?? '',
status: rec.values.status ?? 'active',
role: rec.values.role ?? 'user',
password_hash: rec.values.password_hash ?? '',
libraries: rec.values.libraries ?? '[]',
created_at: rec.values.created_at ?? new Date().toISOString(),
updated_at: rec.values.updated_at ?? new Date().toISOString(),
});
} catch {
/* 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來) */
return null; // 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來)
}
}
/** email → user record_id。**新家優先**;找不到才回退舊家(KBDB),並順手搬過去。 */
/** email → user record_id。**KBDB新家優先**;找不到才回退舊家(認證儲存)。 */
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
const inStore = findAuthUserByEmail(env, email);
if (inStore) return inStore.id;
return findLegacyUserRecordId(env, email);
const inKbdb = await findKbdbUserRecordId(env, email);
if (inKbdb) return inKbdb;
return findAuthUserByEmail(env, email)?.id ?? null;
}
/** 舊家(KBDB的 email → record_iddesign §2.3 head entry O(1) 查找)。 */
async function findLegacyUserRecordId(env: Bindings, email: string): Promise<string | null> {
/** KBDB 的 email → record_iddesign §2.3 head entry O(1) 查找)。 */
async function findKbdbUserRecordId(env: Bindings, email: string): Promise<string | null> {
const ns = portalNamespace(env);
const params = new URLSearchParams({
page_name: email,
@@ -269,7 +285,8 @@ async function findLegacyUserRecordId(env: Bindings, email: string): Promise<str
}
async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
// D61:住新家的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到
// 舊家(D61 認證儲存)的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到
// 這正是它當初被選為「不可能因重裝而不見」的理由——見 lib/portal-auth-store.ts 檔頭)。
if (isAuthStoreId(recordId)) {
const u = findAuthUserById(env, recordId);
return u ? authUserToRecord(u) : null;
@@ -282,7 +299,9 @@ async function getRecordById(env: Bindings, recordId: string): Promise<PortalRec
}
async function patchRecordValues(env: Bindings, recordId: string, values: Record<string, string>): Promise<PortalRecord> {
// D61:住新家的帳號改寫進 Workers Secrets改密碼/停用/改權限都在這條路上)
// 舊家(D61 認證儲存)的帳號改寫進 Workers Secrets需要 CF_SECRETS_API_TOKEN
// 沒有 token 就誠實拋 AuthStoreWriteError → run() 轉 502。這批帳號會在下次登入時
// 自動搬進 KBDB(見 promoteToKbdb),搬完之後就落進下面的 KBDB PATCH 分支。
if (isAuthStoreId(recordId)) {
let updated: AuthUserRecord | null = null;
await mutateAuthStore(env, (data) => {
@@ -329,24 +348,25 @@ function daemonActiveKey(env: Bindings): string {
}
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
// D61帳號清單=新家為主,舊家KBDB)尚未搬走的補在後面(同 email 以新家為準)。
// 家讀不到不算失敗——認證已經不靠它了,這裡只是把還沒搬完的人也列出來。
// 帳號清單=KBDB新家為主,認證儲存(舊家)尚未搬走的補在後面(同 email 以新家為準)。
// 家讀不到(KBDB 不可達)不算失敗——舊家仍是完整真相源之一,這裡只是把還沒搬完的人也列出來。
if (template === USER_TEMPLATE) {
const fromStore = readAuthStore(env).users.map(authUserToRecord);
const seen = new Set(fromStore.map((r) => (r.values.email ?? '').toLowerCase()));
let legacy: PortalRecord[] = [];
let fromKbdb: PortalRecord[] = [];
try {
legacy = await listLegacyRecordsByTemplate(env, template);
fromKbdb = await listKbdbRecordsByTemplate(env, template);
} catch {
legacy = [];
fromKbdb = [];
}
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()))];
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? '').toLowerCase()));
const fromLegacy = readAuthStore(env).users.map(authUserToRecord)
.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()));
return [...fromKbdb, ...fromLegacy];
}
return listLegacyRecordsByTemplate(env, template);
return listKbdbRecordsByTemplate(env, template);
}
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」走這條,那些本來就住知識庫)。 */
async function listLegacyRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」走這條,那些本來就住知識庫)。 */
async function listKbdbRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template}${res.status}`);
@@ -363,27 +383,44 @@ interface CreateUserInput {
}
/**
* **D61 Workers Secrets KBDB**
* AuthStoreWriteError 502退
* 退
* portal_user record namespace email head entrydesign §2.3
* `createPortalUser` `promoteToKbdb`
*/
async function createKbdbUserRecord(env: Bindings, email: string, values: Record<string, string>): Promise<string> {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, '/records', {
method: 'POST',
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } }),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_user)→ ${res.status}`);
const body = (await res.json()) as { record?: { record_id: string } };
const recordId = body.record?.record_id;
if (!recordId) throw new KbdbError('POST /records 回應缺 record_id');
// head entrypage_name=emailindexed)→ content=record_idO(1) 登入查找
const head = await kbdbFetch(env, '/entries', {
method: 'POST',
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns }),
});
if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`);
return recordId;
}
/**
* **2026-08-14 KBDB**binding base HTTP APID38 SQL
* `CF_SECRETS_API_TOKEN`D61
*/
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
const now = new Date().toISOString();
const id = newAuthUserId();
await mutateAuthStore(env, (data) => {
data.users.push({
id,
email: input.email.toLowerCase(),
display_name: input.display_name,
status: 'active',
role: input.role,
libraries: input.libraries,
password_hash: input.password_hash,
created_at: now,
updated_at: now,
});
return createKbdbUserRecord(env, input.email.toLowerCase(), {
display_name: input.display_name,
status: 'active',
role: input.role,
password_hash: input.password_hash,
libraries: JSON.stringify(input.libraries),
created_at: now,
updated_at: now,
});
return id;
}
// ── user 值域 helpers ──────────────────────────────────────────────────────
@@ -596,15 +633,15 @@ async function clearLoginFail(env: Bindings, email: string): Promise<void> {
}
/**
* D61/
*
* KBDB /
* #10
*/
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
if (readAuthStore(env).users.length > 0) return false;
if (readAuthStore(env).users.length > 0) return false; // 舊家還有尚未搬遷的帳號
try {
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
} catch {
return true; // 舊家讀不到 家空 = 這台實例確實沒有可用的登入資料
return true; // KBDB 讀不到 家空 = 這台實例確實沒有可用的登入資料
}
}
@@ -655,7 +692,7 @@ portalRouter.post('/portal/login', (c) =>
const { recordId, rec, ok } = await findAndVerifyUser(c.env, email, password);
if (!recordId || !rec) {
// D61 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
// 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
// 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事,不准混成同一句話——
// 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘,而他的密碼從頭到尾都是對的。
// ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。
@@ -666,7 +703,7 @@ portalRouter.post('/portal/login', (c) =>
'這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的,' +
'請重新執行安裝/更新以重新建立管理員帳號。',
code: 'auth_store_empty',
auth_store: authStoreStatus(c.env),
auth_store: { home: 'kbdb', writable: true, users: 0 },
},
503,
);
@@ -682,14 +719,20 @@ portalRouter.post('/portal/login', (c) =>
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
// D61 自癒:這次是拿舊家(KBDB)的帳號登進來的 → 順手搬進認證儲存
// 下次換庫/換租戶代號就不會再把他鎖在門外。
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
// 自癒:這次是拿舊家(D61 認證儲存)的帳號登進來的 → 順手搬進 KBDB(新家)
// 且**這次登入發出的 session 就直接指向新 record_id**(搬遷成功的話)——不必等
// 下一次登入,密碼變更/admin 編輯這類寫入這次就已經走得到不需要外部 token 的 KBDB 路。
// 搬不動(KBDB 不可達)就沿用舊 record_id,讀路徑仍然通,只是寫入仍會走舊家那格。
let sessionRecordId = recordId;
if (isAuthStoreId(recordId)) {
const migrated = await promoteToKbdb(c.env, rec);
if (migrated) sessionRecordId = migrated;
}
await clearLoginFail(c.env, email);
const token = randomHex(32);
// session 值只存 record_iddesign §4.3)——權限/狀態每請求回讀 record,不快取進 session
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), {
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
expirationTtl: sessionTtl(c.env),
});
return c.json({
@@ -1073,6 +1116,8 @@ portalRouter.post('/portal/admin/bootstrap', (c) =>
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
// 2026-08-14 起:createPortalUser 寫 KBDBbinding),不再需要安裝精靈遞任何臨時
// CF tokenarcrun-rag#99 那個結構性缺口已隨儲存層搬回 binding 一併解掉)。
const recordId = await createPortalUser(c.env, {
email,
display_name: displayName,
@@ -1264,7 +1309,8 @@ function toPublicLibrary(rec: PortalRecord) {
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。
// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。
// body {page_name, text}(舊 daemon)或 {page_name, text, prompt}Arcrun#134 起:
// daemon 自帶提示詞,回應改回 {success, output} 原文)。認證=X-Arcrun-API-Key(見下)。
//
// 🔴 t181leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」):
@@ -1303,41 +1349,67 @@ portalRouter.post('/portal/daemon/extract', (c) =>
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const body = (await c.req.json().catch(() => null)) as
| { page_name?: string; text?: string }
| { page_name?: string; text?: string; prompt?: string }
| null;
const pageName = String(body?.page_name ?? '').trim();
const srcText = String(body?.text ?? '');
if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400);
// 🔴 Arcrun#1342026-08-15):新版 daemon **自帶完整提示詞**`prompt` 欄位)。
//
// 為什麼:卡片格式契約原本存在兩份——daemon 端 gemmaPromptGo)與本端點(TS),
// 靠註解叮嚀「兩邊要一起改」同步。2026-08-15 daemon 端改成「LLM 只回 JSON 判斷、
// wikishape.go 機械組卡」(InkStoneCo#44 ④),本端點沒跟上 ⇒ 免金鑰預設路
// (=多數用戶實際走的路)繼續產舊格式卡——**叮嚀式同步實證失效,這張票就是證據**。
//
// ⇒ 格式契約唯一真相源改為 daemon(提示詞+JSON 解析+組卡同住 collector/
// 不可能再彼此漂移);本端點在 prompt 模式下只是「用實例自己的 env.AI 跑一段
// 生成」的執行器,**不認得也不需要認得卡的形狀**——與 cypher TS「只做 routing
// +提供能力、不長業務邏輯」的分工一致。
// 安全面不變:認證同上(租戶識別 key)、只生成、不寫資料、用戶自己的 AI 額度;
// `text` 本來就是任意內容,本欄位沒有放大任何攻擊面。
// 相容:舊 daemon 不帶 prompt ⇒ 走下方 legacy 路(行為與回應完全不變)。
const daemonPrompt = String(body?.prompt ?? '').trim();
if (!daemonPrompt && (!pageName || !srcText.trim()))
return c.json({ error: 'page_name 與 text 必填' }, 400);
if (!c.env.AI) {
// 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠)
return c.json({ error: '這個部署沒有綁定 Workers AIwrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
}
// 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
// 兩邊要一起改;daemon 端在 collector/extract_gemma.go。
// legacy 提示詞:只服務**還沒更新的舊 daemon**(無 prompt 欄位)。舊 daemon 期待
// 「# <頁名>」開頭的 markdown 卡(收端 lint 新舊雙軌仍接受);新格式不再從這裡出生,
// 所以本段刻意凍結、不再演進——要改卡的形狀,去 daemon 端 collector/ 改那唯一一份。
// 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。
const REL = '>'.repeat(2);
const prompt =
daemonPrompt ||
`把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` +
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
try {
// 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140
// llama-4-scout 2373ms/答案最完整;對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿)。
// prompt 模式放寬 max_tokens 到 8192:對齊 gemma 路的 generationConfig——
// JSON 契約要裝下「文件總覽+N 個原子概念」,2048 會把 JSON 攔腰截斷(解析必失敗)。
const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
messages: [{ role: 'user', content: prompt }],
max_tokens: 2048,
max_tokens: daemonPrompt ? 8192 : 2048,
temperature: 0.2,
} as never)) as { response?: string } | undefined;
const card = String(out?.response ?? '').trim();
if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
const raw = String(out?.response ?? '').trim();
if (!raw) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
if (daemonPrompt) {
// 原文回傳(`output` 欄位):淨化與解析屬於契約,契約住 daemon ⇒ 這裡一個字都不動。
// daemon 以「回應裡有沒有 output」分辨雲端新舊:舊雲端只會回 `card`(見下),
// 新 daemon 據此 fallback 到 legacy 卡處理——兩個方向的版本歪斜都有路可走。
return c.json({ success: true, output: raw });
}
// 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard
const marker = `# ${pageName}`;
const idx = card.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' });
const idx = raw.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? raw.slice(idx) : raw).trim() + '\n' });
} catch (e) {
return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502);
}
@@ -1,59 +0,0 @@
/**
* 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);
});
});
@@ -1,17 +1,21 @@
/**
* console-auth.ts D61 SESSIONS_KV
* console-auth.ts D61 SESSIONS_KV
*
* portal-auth-store.ts per-isolate overlay
* console overlay.console ****
* worker
* tests/console-auth.test.ts
* KV
* 2026-08-14 D61 leo confirmCconsole SESSIONS_KVbinding
* D61 CF Workers Secrets
* CF_SECRETS_API_TOKEN
* SESSIONS_KVbest-effortbinding put
*
* portal-auth-store.ts
* worker console-auth.test.ts
* SESSIONS_KV
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { mutateAuthStore } from '../src/lib/portal-auth-store';
import type { Bindings } from '../src/types';
const CF_API = 'https://api.cloudflare.com';
const CREDS_KEY = 'console:credentials';
beforeAll(() => {
fetchMock.activate();
@@ -27,18 +31,12 @@ function json(method: string, path: string, body?: unknown) {
});
}
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
/** 種一筆進 D61 認證儲存(CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP route。 */
function mockLegacySecretsWrite(): void {
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, (opts) => {
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
.reply(200, { success: true });
}
/** console-auth.ts export sha256(salt+password) 3
@@ -57,45 +55,45 @@ const EMAIL = 'legacy-owner@example.com';
const PASSWORD = 'legacy-owner-pw-1';
const SALT = 'deadbeef00112233';
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
// 🔴 全部收在**同一個 `it()`** 裡(不拆成多則):`isolatedStorage`vitest-pool-workers 預設開)
// 在每一個 `it()` 前重置 SESSIONS_KV,但 D61 認證儲存的模組級記憶體變數 `overlay` 不受影響
// (見 lib/portal-auth-store.ts 檔頭)。若拆成多則,「搬回 SESSIONS_KV 後再打一次直接命中
// 新家」這一步在下一個 `it()` 會因為 KV 被重置而又落回 legacy-secrets 分支,驗不出「新家優先」
// 這件事——所以要在同一次測試、同一份 KV 狀態內連續打兩次才驗得出來。
describe('舊實例相容:console 帳密只在認證儲存(尚未搬回 SESSIONS_KV', () => {
it('GET /console/auth-status 讀到認證儲存這筆、順手搬回 SESSIONS_KV;再打一次直接命中新家;帳密登得進去', async () => {
const hash = await legacyHash(PASSWORD, SALT);
await env.SESSIONS_KV.put(
CREDS_KEY,
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
);
const { puts } = mockAuthStoreWrite();
mockLegacySecretsWrite();
await mutateAuthStore(env as unknown as Bindings, (data) => {
data.console = { email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' };
});
const res = await json('GET', '/console/auth-status');
expect(res.status).toBe(200);
const data = (await res.json()) as {
configured: boolean;
credentials_source: string;
auth_store: { console_configured: boolean };
auth_store: { legacy_secrets_present: boolean };
};
expect(data.configured).toBe(true);
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
// 故 authStoreStatus 已經反映搬遷後的狀態
expect(data.auth_store.console_configured).toBe(true);
expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
expect(data.auth_store.legacy_secrets_present).toBe(true);
const shards = puts();
expect(shards.length).toBe(1);
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
expect(shard.console.email).toBe(EMAIL);
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
});
const kvRaw = await env.SESSIONS_KV.get('console:credentials');
expect(kvRaw).toBeTruthy();
const stored = JSON.parse(kvRaw!) as { email: string; hash: string };
expect(stored.email).toBe(EMAIL);
expect(stored.hash).toBe(hash); // 原樣搬過去,不重新雜湊
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
const res = await json('GET', '/console/auth-status');
const data = (await res.json()) as { credentials_source: string };
expect(data.credentials_source).toBe('secrets');
});
// 搬回後再打一次:SESSIONS_KV 已經有了,直接命中新家(不用再查認證儲存)
const res2 = await json('GET', '/console/auth-status');
const data2 = (await res2.json()) as { credentials_source: string };
expect(data2.credentials_source).toBe('kv');
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
// 用搬回去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)
const login = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
expect(login.status).toBe(200);
expect((await login.json() as { success: boolean }).success).toBe(true);
});
});
+63 -82
View File
@@ -1,81 +1,64 @@
/**
* console-auth.ts D61console ADR D61 / Leo/arcrun-rag#55
* console-auth.ts 2026-08-14 console SESSIONS_KV
* D61 pending-changes.md D1/KVleo confirmC
*
* /console/setup/console/login SESSIONS_KV `console:credentials`
* TTLKV binding KV
* console-auth.ts KV
* D61 CF Workers SecretsSESSIONS_KV 退
* /console/setup/console/login D61ADR D61 / Leo/arcrun-rag#55
* CF Workers Secrets binding
* Workers Secrets `CF_SECRETS_API_TOKEN` token
* arcrun-rag#99
* `shared/resource-rule`Arcrun#97 SESSIONS_KVbinding token
* 退 tests/console-auth-legacy.test.ts
*
* D61
*
* 1. auth-status configured:falselogin
* 2. POST /console/setup CF Workers Secrets KV
* 3. 409D61
* 2. POST /console/setup SESSIONS_KV
* 3. 409
*
* 4. 200 401
* 5. /console/setup/reset
* 5. /console/setup/reset SESSIONS_KV
*
* `https://api.cloudflare.com/.../secrets`PUT fetchMock host
* portal-auth.test.ts mockAuthStoreWritewrangler.test.toml
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
* CF API mockSESSIONS_KV bindingwrangler.test.toml mock KV
*
* portal-auth-store.ts per-isolate overlay
* /console/setup reset overlay.console ****
* KV/D1 storage
* isolatedStorage
* /console/setup
* KV overlay
* tests/console-auth-legacy.test.ts worker
* 🔴 `@cloudflare/vitest-pool-workers`
* `isolatedStorage`** `it()`** KV/D1 storage bindings
* SESSIONS_KV
* POST /console/setup 沿 `it()`
* D61 `overlay` SESSIONS_KV
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { SELF, env } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
const CF_API = 'https://api.cloudflare.com';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
function json(method: string, path: string, body?: unknown) {
return SELF.fetch(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, (opts) => {
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
/** 每則測試自建一組帳密(POST /console/setup),回傳供後續斷言使用。 */
async function setupOwner(email: string, password: string): Promise<void> {
const res = await json('POST', '/console/setup', { email, password });
expect(res.status).toBe(200);
}
const OWNER_EMAIL = 'owner@example.com';
const OWNER_PW = 'owner-first-pw-1';
// ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════
// ═══════════════ 1. 全新實例(尚未設定過任何管理員帳密)═══════════════
describe('全新實例(尚未設定過任何管理員帳密)', () => {
it('GET /console/auth-status → configured:false,不洩漏 email', async () => {
const res = await json('GET', '/console/auth-status');
expect(res.status).toBe(200);
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { present: boolean } };
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { home: string } };
expect(data.configured).toBe(false);
expect(data.credentials_source).toBe('none');
expect(data.auth_store.home).toBe('sessions-kv');
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
});
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(明顯失敗)', async () => {
const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
expect(res.status).toBe(400);
const data = (await res.json()) as { code: string; error: string };
@@ -91,36 +74,31 @@ describe('全新實例(尚未設定過任何管理員帳密)', () => {
});
});
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
// ═══════════════ 2. 首次設定:成功寫進 SESSIONS_KV ═══════════════
describe('POST /console/setup — 首次設定', () => {
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
const { puts } = mockAuthStoreWrite();
it('成功:寫進 SESSIONS_KV(binding,不需要任何外部 CF token),回 session_token', async () => {
const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; session_token: string; tenant: string };
expect(data.success).toBe(true);
expect(typeof data.session_token).toBe('string');
// 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
expect(shards[0].text).not.toContain(OWNER_PW);
const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } };
expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫
expect(typeof shard.console.salt).toBe('string');
expect(typeof shard.console.hash).toBe('string');
// D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發)
expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull();
const raw = await env.SESSIONS_KV.get('console:credentials');
expect(raw).toBeTruthy();
expect(raw).not.toContain(OWNER_PW); // 明碼絕不落地
const stored = JSON.parse(raw!) as { email: string; salt: string; hash: string };
expect(stored.email).toBe(OWNER_EMAIL); // 存小寫
expect(typeof stored.salt).toBe('string');
expect(typeof stored.hash).toBe('string');
});
});
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
// ═══════════════ 3. 已設定過 → 409(明顯失敗:說得出「沒有被採用」)═══════════════
describe('POST /console/setup — 已設定過(重複設定)', () => {
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
expect(res.status).toBe(409);
const data = (await res.json()) as {
@@ -130,22 +108,29 @@ describe('POST /console/setup — 已設定過(重複設定)', () => {
expect(data.password_applied).toBe(false);
expect(data.error).toContain('沒有被採用');
expect(data.reset_path).toBe('/console/setup/reset');
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗,原帳密仍有效
const attackerLogin = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
expect(attackerLogin.status).toBe(401);
const ownerLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
expect(ownerLogin.status).toBe(200);
});
it('GET /console/auth-status → configured:truecredentials_source:secrets(新家優先命中)', async () => {
it('GET /console/auth-status → configured:truecredentials_source:kv', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('GET', '/console/auth-status');
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { writable: boolean } };
expect(data.configured).toBe(true);
expect(data.credentials_source).toBe('secrets');
expect(data.auth_store.console_configured).toBe(true);
expect(data.credentials_source).toBe('kv');
expect(data.auth_store.writable).toBe(true);
});
});
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
// ═══════════════ 4. 登入對錯 ═══════════════
describe('POST /console/login', () => {
it('帳密正確 → 200,發 session token', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; session_token: string };
@@ -154,30 +139,27 @@ describe('POST /console/login', () => {
});
it('密碼錯 → 401', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' });
expect(res.status).toBe(401);
});
it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => {
const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
expect(res.status).toBe(401);
});
});
// ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════
// ═══════════════ 5. /console/setup/reset:換密碼,寫進 SESSIONS_KV ═══════════════
describe('POST /console/setup/reset', () => {
const NEW_PW = 'brand-new-owner-pw-1';
it('舊密碼錯 → 401,不寫入', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/setup/reset', {
current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW,
});
expect(res.status).toBe(401);
});
it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
const { puts } = mockAuthStoreWrite();
it('舊密碼對 → 200,新 hash 寫進 SESSIONS_KV;換完後舊密碼立即失效、新密碼生效', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/setup/reset', {
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
});
@@ -185,11 +167,10 @@ describe('POST /console/setup/reset', () => {
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地
const shard = JSON.parse(shards[0].text) as { console: { email: string } };
expect(shard.console.email).toBe(OWNER_EMAIL);
const raw = await env.SESSIONS_KV.get('console:credentials');
expect(raw).not.toContain(NEW_PW); // 明碼不落地
const stored = JSON.parse(raw!) as { email: string };
expect(stored.email).toBe(OWNER_EMAIL);
// 舊密碼立即失效
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
@@ -0,0 +1,126 @@
/**
* POST /kbdb/records `entry_ids` Arcrun#128
*
* kbdb createRecord `entry_ids`slot **** entry id
* proxy `values` values 400
* X-Arcrun-API-Key #129 wiki #130
* b6ef0f0 PATCH
*
* IO kbdb-records-patch-proxy.test.ts
* 1. X-Arcrun-API-Key 401 KBDB
* 2. entry_ids values 400
* 3. body entry_ids ** owner_id**caller owner_id
* 4. values entry_ids
* 5. 400
* 6. base 400
*
* KBDB fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('POST /kbdb/recordsentry_ids)— 租戶閘與參數', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }),
});
expect(res.status).toBe(401);
});
it('values 與 entry_ids 都沒給 → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki' }),
});
expect(res.status).toBe(400);
});
});
describe('POST /kbdb/recordsentry_ids)— 轉發', () => {
it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
// 沒有 values 這個 key(不要憑空塞一個空物件給基本盤)
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }),
})
.reply(200, {
success: true,
record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' },
});
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
// caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例)
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
expect(data.success).toBe(true);
expect(data.record.values.gloss).toBe('既有');
});
it('values 與 entry_ids 混用 → 兩個都轉過去', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }),
})
.reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }),
});
expect(res.status).toBe(200);
});
it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }),
})
.reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }),
});
expect(res.status).toBe(200);
});
it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records', method: 'POST' })
.reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }),
});
expect(res.status).toBe(400);
const data = (await res.json()) as { success: boolean; error: string };
expect(data.success).toBe(false);
expect(data.error).toContain('owner mismatch');
});
});
+51 -32
View File
@@ -16,20 +16,20 @@
* KBDB fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnectUI worker curl PR
*
* D61ADR D61 / Leo/arcrun-rag#55 fixturerec_admin/rec_u1/rec_admin2
* 沿record_id auth: 開頭 portal.ts
* isAuthStoreId(recordId) auth: 開頭的 id KBDB D61
* ****POST /portal/admin/users
* POST /portal/admin/bootstrap createPortalUserCF Workers
* Secrets `https://api.cloudflare.com/.../secrets`PUT mockAuthStoreWrite
* 2026-08-14 D61 leo confirmC KBDBbinding
* pending-changes.md D1/KV fixture
* rec_admin/rec_u1/rec_admin2沿record_id auth: 開頭
* portal.ts isAuthStoreId(recordId) auth: 開頭的 id
* KBDB D61
* ****POST /portal/admin/usersPOST /portal/admin/bootstrap
* createPortalUser KBDBPOST /records + POST /entries head
* `https://api.cloudflare.com/.../secrets`
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
const KBDB = 'https://kbdb.test';
const CF_API = 'https://api.cloudflare.com';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
let storedHash: string;
@@ -49,19 +49,21 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
});
}
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
/** 建帳號的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
let recordBody = '';
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
recordBody = String(opts.body);
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
return { recordBody: () => recordBody };
}
function mockHeadLookup(email: string, recordId: string | null) {
@@ -202,11 +204,12 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id', () => {
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
describe('POST /portal/admin/users(一次性密碼)', () => {
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼D61', async () => {
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
mockHeadLookup('new@example.com', null); // email 未占用
const { recordBody } = mockCreateUser('rec_new');
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
const res = await json(
'POST',
'/portal/admin/users',
@@ -218,23 +221,21 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
expect(typeof data.generated_password).toBe('string');
expect(data.generated_password!.length).toBe(16);
expect('password_hash' in data.user).toBe(false);
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].text).not.toContain(data.generated_password!);
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
const stored = shard.users.find((u) => u.email === 'new@example.com');
expect(stored).toBeDefined();
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
// 一次性密碼不落地:KBDB 收到的 record body 只有 hash、無明碼
expect(recordBody()).not.toContain(data.generated_password!);
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
});
it('自帶 password → 回應**無** generated_password', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('own@example.com', null);
mockAuthStoreWrite();
mockCreateUser('rec_own');
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
const res = await json(
'POST',
'/portal/admin/users',
@@ -747,4 +748,22 @@ describe('POST /portal/daemon/extractt181Workers AI 萃卡,免金鑰)'
expect(raw).not.toContain('gemini_api_key');
expect(raw).not.toContain('credential');
});
// 🔴 Arcrun#134:新 daemon 自帶提示詞(卡片格式契約的唯一真相源在 daemon 端
// collector/——提示詞+JSON 解析+wikishape 組卡同住一處,才不會像 t181 那樣
// 兩份 prompt 靠註解叮嚀同步而漂移)。雲端在 prompt 模式下只是執行器。
//
// 測試環境刻意沒有 [ai] binding ⇒ 走到 AI 分支必回 501。這裡拿 501 當「驗證已通過、
// 已進入 AI 分支」的探針:若有人把 prompt 模式改回要求 page_name/text,這則會看到 400。
it('帶 prompt 時不再要求 page_name/text#134:契約住 daemon 端)', async () => {
const res = await json('POST', '/portal/daemon/extract', { prompt: '任意契約內容' }, KEY);
expect(res.status).toBe(501); // 不是 400 ⇒ prompt 單獨成立
});
// 相容守衛:舊 daemon(無 prompt 欄位)行為凍結——缺欄仍 400(上面那則),
// 齊欄則同樣走到 AI 分支(501 探針)。若這則變 400,代表 legacy 驗證被動到了。
it('legacy(無 prompt)齊欄仍走原路(相容不回退)', async () => {
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' }, KEY);
expect(res.status).toBe(501);
});
});
+191 -119
View File
@@ -4,31 +4,29 @@
* =tasks.md P2
* 1. KDFpbkdf2-sha256$100000$ CF Workers runtime 100k2026-07-14
* false600k hash
* 2. bootstrap console session 401 admin ****D61
* admin 409
* 2. bootstrap console session 401 admin **KBDB** admin 409
* 3. token**** 401 403 email 401
* 4. 5 429KV TTL
* 5. session record session
* 6. hash 100k slot
* 7. role admin admin 403admin ** password_hash**
*
* D61ADR D61 / Leo/arcrun-rag#55
* 2026-08-14 D61 leo confirmCpending-changes.md
* D1/KVCF Workers SecretsD61 **KBDB**binding base
* HTTP API CF token
* 8.
* 9. KBDB
* 9. D61 KBDB
* ** session record_id**KBDB
*
* KBDB fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect namespace email worker
* curl PR owner_id=leo::portal
*
* D61 KBDB CF Workers Secrets
* `https://api.cloudflare.com/.../secrets`PUT fetchMock host
* wrangler.test.toml CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
import { AUTH_ID_PREFIX, isAuthStoreId, mutateAuthStore } from '../src/lib/portal-auth-store';
import { portalRouter } from '../src/routes/portal';
import type { Bindings, ExecutionContext } from '../src/types';
@@ -56,38 +54,13 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
});
}
/**
* D61
* CF Workers Scripts secrets API PUT body
* `puts()` {name, text}[]
*
* seed env`cloudflare:test` `env`
* `vitest` context `SELF.fetch()` worker isolate ****
* mutate `env.XXX` SELF wrangler.test.toml
* ** KBDB fetchMock**
* ****bootstrap/ portal-auth-store
* per-isolate overlay overlay \*\*
* test KV/D1 storage isolatedStorage
* ****
*/
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, (opts) => {
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
}
// ── KBDB mock helpers ──────────────────────────────────────────────────────
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1 */
function mockHeadLookup(email: string, recordId: string | null) {
/**
* head entry GET /entries?page_name=&entry_type=portal_user&owner_id=ns&limit=1
* `times``findUserRecordId``promoteToKbdb` email
*/
function mockHeadLookup(email: string, recordId: string | null, times = 1) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock
.get(KBDB)
@@ -96,7 +69,61 @@ function mockHeadLookup(email: string, recordId: string | null) {
p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
method: 'GET',
})
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 });
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 })
.times(times);
}
/** 建 portal_user 的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
return { recordBody: () => recordBody };
}
/** POST /records 建立失敗(模擬 KBDB 拒寫,用於「搬遷失敗不擋登入」的測試)。 */
function mockCreateUserFails(status = 500) {
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(status, { success: false, error: 'kbdb write failed (test)' });
}
/**
* ****D61 CF Workers Secrets lib HTTP
* routeconsoleAuthRouter/portalRouter CF Secrets PUT
* wrangler.test.toml CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
*/
async function seedLegacySecretsUser(overrides: Partial<{
email: string; display_name: string; status: string; role: string; libraries: string[]; password_hash: string;
}> = {}): Promise<void> {
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, { success: true });
const now = new Date().toISOString();
await mutateAuthStore(env as unknown as Bindings, (data) => {
data.users.push({
id: `auth:${crypto.randomUUID().replace(/-/g, '')}`,
email: (overrides.email ?? EMAIL).toLowerCase(),
display_name: overrides.display_name ?? '舊實例同仁',
status: overrides.status ?? 'active',
role: overrides.role ?? 'user',
libraries: overrides.libraries ?? ['general'],
password_hash: overrides.password_hash ?? storedHash,
created_at: now,
updated_at: now,
});
});
}
function mockGetRecord(recordId: string, values: Record<string, string>) {
@@ -176,20 +203,20 @@ describe('PBKDF2 模組(lib/portal-auth', () => {
// ═══════════════ 1.5 D61:整台實例沒有任何認證資料 ═══════════════
//
// 🔴 這個 describe 必須留在檔案裡「第一個會入認證儲存的測試」之前(下面 2. bootstrap
// 的「console session OK」那則)——見 mockAuthStoreWrite 檔頭註解:portal-auth-store.ts
// 的 per-isolate overlay 是模組級全域變數,同一支測試檔案跑起來不會在測試之間重置,
// 一旦有測試寫入過,後面的測試都會看到那筆資料,「乾淨無帳號」的前提就不成立了。
describe('D61整台實例沒有任何認證資料(arcrun-rag#55leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
// 🔴 這個 describe 必須留在檔案裡「第一個會入認證儲存(舊家)的測試」之前——見
// seedLegacySecretsUser 檔頭註解:portal-auth-store.ts 的 per-isolate overlay 是模組級全域
// 變數,同一支測試檔案跑起來不會在測試之間重置,一旦有測試種過舊家資料,後面的測試都會看到
// 那筆資料,「乾淨無帳號」的前提就不成立了。
describe('整台實例沒有任何認證資料(arcrun-rag#55leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前)
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
// KBDB(新家)此刻回空——head lookup 查無此人+by-template 列表也空
// 認證儲存(舊家)此刻也還是空的(本測試特意排在任何種子測試之前)——兩邊都沒有帳號,
// 才是「這台實例真的沒有認證資料」。
mockHeadLookup('anyone@example.com', null);
mockListByTemplate('portal_user', []);
const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
expect(res.status).toBe(503);
const data = (await res.json()) as { error: string; code: string; auth_store: { present: boolean; users: number } };
const data = (await res.json()) as { error: string; code: string; auth_store: { users: number } };
expect(data.code).toBe('auth_store_empty');
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
@@ -209,12 +236,12 @@ describe('POST /portal/admin/bootstrap', () => {
expect(res.status).toBe(401);
});
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB', async () => {
it('console session OK → 建第一個 admin:寫進 KBDB,不需要任何外部 CF token', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', []); // 尚無 admin新家空,舊家也空)
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
mockListByTemplate('portal_user', []); // 尚無 adminKBDB 空,認證儲存舊家也空)
mockHeadLookup('admin@example.com', null); // email 未占用
const { recordBody } = mockCreateUser('rec_admin_new');
const res = await json(
'POST',
@@ -225,25 +252,18 @@ describe('POST /portal/admin/bootstrap', () => {
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.success).toBe(true);
expect(typeof data.record_id).toBe('string');
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61
expect(data.record_id).toBe('rec_admin_new'); // 住 KBDB(新 record_id 來自 KBDB 回應)
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1
// D61:一次寫入=一片,落進認證儲存(Workers Secrets),不再有 KBDB record/head entry
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
const shard = JSON.parse(shards[0].text) as {
users: Array<{ email: string; role: string; status: string; libraries: string[]; password_hash: string }>;
};
expect(shard.users.length).toBe(1);
const stored = shard.users[0];
expect(stored.email).toBe('admin@example.com');
expect(stored.role).toBe('admin');
expect(stored.status).toBe('active');
expect(stored.libraries).toEqual(['*']);
expect(stored.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(shards[0].text).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
// 一次 POST /records=一筆,body 含 owner_id 子 namespace,明碼絕不落地
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.email).toBe('admin@example.com');
expect(rec.values.role).toBe('admin');
expect(rec.values.status).toBe('active');
expect(JSON.parse(rec.values.libraries)).toEqual(['*']);
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(recordBody()).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
});
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
@@ -263,12 +283,10 @@ describe('POST /portal/admin/bootstrap', () => {
// ═══════════════ 3. 登入對錯 ═══════════════
describe('POST /portal/login', () => {
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組 **KBDB 原生** fixture(非 auth: 開頭 id)——
// 2026-08-14 起 KBDB 是新家,這批帳號本來就住在該住的地方,**不會**觸發任何搬遷嘗試
// isAuthStoreId('rec_1') 為 false)。搬遷本身的驗證另開一組使用**專屬、不共用**email
// 的 describe(見檔案最後「舊實例登入自癒」),避免污染這裡的既有 fixture
it('成功:發 session token;回 display_name/role/libraries**無任何租戶字串欄位**', async () => {
mockHeadLookup(EMAIL, 'rec_1');
mockGetRecord('rec_1', activeUserValues());
@@ -286,11 +304,6 @@ describe('POST /portal/login', () => {
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
expect(sess).toBeTruthy();
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
// D61promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mockdisableNetConnect 之下
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
});
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
@@ -305,6 +318,10 @@ describe('POST /portal/login', () => {
it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => {
mockHeadLookup('ghost@example.com', null);
// instanceHasNoAuthData 在「查無此帳號」時會確認「是不是整台實例都沒帳號」——
// 這裡要證明的是「查無此人」而非「這台實例是空的」,故 by-template 要回非空列表
// (這台實例確實有別的帳號,只是不是 ghost@example.com)。
mockListByTemplate('portal_user', [{ record_id: 'rec_1', values: activeUserValues() }]);
const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' });
expect(res.status).toBe(401);
const data = (await res.json()) as { error: string };
@@ -529,57 +546,52 @@ describe('t130 — triplet template seedPORTAL_TEMPLATE_SEEDS 補 triplete
});
});
// ═══════════════ D61舊實例登入自癒(搬進新家)═══════════════
// ═══════════════ 舊實例登入自癒(認證儲存舊家 → 搬進 KBDB 新家)═══════════════
//
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
// per-isolate overlay 是模組級全域變數,seedLegacySecretsUser 種一次資料就會留在同一支測試
// 檔案的後續測試裡(見該函式檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
// 不會有更後面的測試共用這個 email 而被污染。
describe('D61舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
describe('舊實例登入自癒(帳號只在認證儲存舊家,登入成功後搬進 KBDB 新家', () => {
const LEGACY_EMAIL = 'legacy-promote@example.com';
it('登入成功;promoteLegacyUser 把這筆帳號寫進認證儲存(一片、含正確 email/hash)', async () => {
mockHeadLookup(LEGACY_EMAIL, 'rec_legacy_1');
mockGetRecord('rec_legacy_1', activeUserValues({ email: LEGACY_EMAIL }));
const { puts } = mockAuthStoreWrite();
it('登入成功;自動把這筆帳號寫進 KBDB(含正確 email/hash),且**這次的 session 就指向新 record_id**', async () => {
await seedLegacySecretsUser({ email: LEGACY_EMAIL });
// KBDB 查無此人:一次給 findUserRecordId(登入查找)、一次給 promoteToKbdb(搬遷前的
// 「已經搬過了嗎」確認)——兩次都要 mock,見 mockHeadLookup 的 times 參數說明。
mockHeadLookup(LEGACY_EMAIL, null, 2);
const { recordBody } = mockCreateUser('rec_legacy_promoted_1');
const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
const data = (await res.json()) as { success: boolean; session_token: string };
expect(data.success).toBe(true);
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
const promoted = shard.users.find((u) => u.email === LEGACY_EMAIL);
expect(promoted).toBeDefined();
expect(promoted!.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.email).toBe(LEGACY_EMAIL);
expect(rec.values.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
// 這次登入發出的 session 已經指向新 record_id(不必等下一次登入才生效)
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_legacy_promoted_1');
});
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
// 直接呼叫 router、帶一份缺寫入路徑的 envhealth.test.ts 已有的直呼叫慣例),
// 證明 promoteLegacyUser 的失敗被 best-effort 吞掉,不影響登入本身。
const email = 'legacy-promote-writeless@example.com';
mockHeadLookup(email, 'rec_legacy_2');
mockGetRecord('rec_legacy_2', activeUserValues({ email }));
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
const res = await portalRouter.fetch(
new Request('http://localhost/portal/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: PASSWORD }),
}),
fakeEnv,
{} as ExecutionContext,
);
it('KBDB 拒寫(搬遷失敗)——照樣登入成功,session 沿用舊家 record_id搬不動不擋門', async () => {
const email = 'legacy-promote-writefail@example.com';
await seedLegacySecretsUser({ email });
mockHeadLookup(email, null, 2); // 登入查找 + 搬遷前確認,KBDB 兩次都查無此人
mockCreateUserFails(500); // 搬遷寫入本身失敗(模擬 KBDB 不可達/拒寫)
const res = await json('POST', '/portal/login', { email, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
const data = (await res.json()) as { success: boolean; session_token: string };
expect(data.success).toBe(true);
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
// 搬不動 → session 沿用舊家(認證儲存)的 record_id"auth:" 開頭),讀路徑仍然通
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
const sessRecordId = (JSON.parse(sess!) as { record_id: string }).record_id;
expect(isAuthStoreId(sessRecordId)).toBe(true);
});
});
@@ -587,7 +599,7 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
//
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
// 而 overlay 是模組級全域變數、不隨 test 重置(見 seedLegacySecretsUser 檔頭長註解)。
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
@@ -682,3 +694,63 @@ describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
});
});
// ═══════════════ arcrun-rag#992026-08-14):全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════
//
// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死(leo 本人+封測者都撞到「裝得起來,
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。止血版(安裝精靈遞一把
// 臨時 OAuth token)只解掉「建第一個帳號」這一格;2026-08-14 confirm 走 C 之後,帳號改回
// 住 KBDBbinding),**從根拔掉整個問題**`x-arcrun-install-token` 這條路已經不存在了,
// 因為建帳號這件事本來就不再需要任何 CF API 憑證,無論裝機當下還是裝完之後都一樣。
//
// 下面這則是回歸守衛:**一台連 `CF_SECRETS_API_TOKEN``CF_ACCOUNT_ID` 都沒有的全新實例**
// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號,全程都要成功——
// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴,這裡會紅。
describe('arcrun-rag#99:全新實例(連 CF_SECRETS_API_TOKENCF_ACCOUNT_ID 都沒有)不再卡死', () => {
it('bootstrap 建第一個 admin、再用 admin session 加第二個 user,全程零 CF token', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token-fresh', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', []); // 尚無 admin
mockHeadLookup('fresh-install-admin@example.com', null);
const { recordBody: bootstrapBody } = mockCreateUser('rec_fresh_admin');
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
const bootstrapRes = await portalRouter.fetch(
new Request('http://localhost/portal/admin/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner-token-fresh' },
body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-3' }),
}),
fakeEnv,
{} as ExecutionContext,
);
expect(bootstrapRes.status).toBe(200);
const bootstrapData = (await bootstrapRes.json()) as Record<string, unknown>;
expect(bootstrapData.success).toBe(true);
expect(bootstrapData.record_id).toBe('rec_fresh_admin');
expect(JSON.parse(bootstrapBody()).values.password_hash.startsWith('pbkdf2-sha256$')).toBe(true);
expect(bootstrapBody()).not.toContain('bootstrap-pw-3'); // 明碼絕不落地
// 安裝精靈離場之後:管理員事後手動加第二個人(POST /portal/admin/users),
// 這條路本來就沒帶過任何臨時 token,過去在 D61 底下永遠 502——現在也要成功。
await env.SESSIONS_KV.put('portal_sess:tok-fresh-admin', JSON.stringify({ record_id: 'rec_fresh_admin' }));
mockGetRecord('rec_fresh_admin', activeUserValues({ email: 'fresh-install-admin@example.com', role: 'admin' }));
mockHeadLookup('second-user@example.com', null);
const { recordBody: secondBody } = mockCreateUser('rec_fresh_second');
mockGetRecord('rec_fresh_second', activeUserValues({ email: 'second-user@example.com' }));
const addUserRes = await portalRouter.fetch(
new Request('http://localhost/portal/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-fresh-admin' },
body: JSON.stringify({ email: 'second-user@example.com', password: 'second-user-pw-1' }),
}),
fakeEnv,
{} as ExecutionContext,
);
expect(addUserRes.status).toBe(200);
const addUserData = (await addUserRes.json()) as { success: boolean };
expect(addUserData.success).toBe(true);
expect(JSON.parse(secondBody()).owner_id).toBe(NS);
});
});
+58
View File
@@ -0,0 +1,58 @@
# 00-INDEXarcrun/docs
> 機器整理的 wiki。**原稿在上一層**,這裡不放原文。有爭議時以原稿為準。
## 子節點
- **user_requirements**`../user_requirements/.wiki/00-INDEX.md`
主題:u6u 四層架構、credential 三層模型|概念:Polaris 意圖層、零件宇宙、AuthBroker…
`#規格 #架構 #credential` 4 篇(含 1 篇指針) 更 2026-08-15
## 文件(本層每一篇一張)
- [[HANDOFF-config-scope-and-vectorize]] — 一份交辦文件,指出 arcrun 設定分層的兩條缺口,萃出兩個概念。
`#文件 #設定` 建 2026-06-15 更 2026-08-15
- [[HANDOFF-matrix-rearrange]] — Matrix 重整交給 arcrun 的待辦清單,用指針式考古不複製素材。
`#文件 #遷移` 建 2026-06-13 更 2026-08-15
- [[HANDOFF-mira-repair]] — 一份修復交辦,把調查做完了:哪些不用改、哪些要改、哪些要降級。
`#文件 #事故` 建 2026-06-03 更 2026-08-15
- [[HANDOFF-self-host-harness]] — 一份把「今天要做的三件事」連同已查證實況整理好的交辦,核心是戰法轉向。
`#文件 #策略` 建 2026-06-01 更 2026-08-15
- [[component-pr-review-standard]] — 零件與 binding 的 PR 審核規範,五段 checklist,任一否就打回。
`#文件 #流程` 建 2026-06-03 更 2026-08-15
## 主題(跨文件聚類產生)
- [[結構性閘]] — 把錯誤路徑做成「根本碰不到」,而不是叮嚀人不要走。
`#方法論 #流程 #架構` 建 2026-06-03 更 2026-08-15
## 本層概念(9
- [[AI 讀不到的隱形分層]] — arcrun 是給 AI 操作的工具,而 harness 說明 41 行對 scope 零字提及。
`#設定 #AI 協作` 建 2026-06-15 更 2026-08-15
- [[Mira workflow 斷鏈]] — Mira 六個 workflow 全斷,但真因不是 arcrun 壞了,是 Mira 當初把東西錯做成假零件。
`#事故 #零件` 建 2026-06-03 更 2026-08-15
- [[cypher 雙份分岔]] — matrix 與 arcrun 各有一份 cypher-executor 且已分岔——整合後只留 arcrun 一份。
`#架構 #遷移` 建 2026-06-13 更 2026-08-15
- [[scope 誤部風險]] — 部署到哪個 CF 帳號,取決於你當下站在哪個資料夾——而這件事沒有任何地方寫著。
`#設定 #風險` 建 2026-06-15 更 2026-08-15
- [[self-hosted 戰法轉向]] — 2026-06-01 從 SaaS 改成 self-hosted 開源——它改的不只是商業模式,是「成功」的定義。
`#策略 #部署` 建 2026-06-01 更 2026-08-15
- [[假零件降級]] — 打固定 endpoint 的東西不是零件是 recipe——33 顆降到 22 顆,代價是所有引用它的人斷鏈。
`#零件 #架構` 建 2026-06-03 更 2026-08-15
- [[成功的定義]] — 舊:在我的帳號上能跑。新:任何人在自己的帳號上跑得通,而且寫錯會被擋住。
`#策略 #驗收` 建 2026-06-01 更 2026-08-15
- [[規則記不住]] — 規定說幾次都沒用——這不是紀律問題,是機制缺席。
`#流程 #AI 協作` 建 2026-06-03 更 2026-08-15
- [[零件 PR 審核閘]] — 靠「AI 記住規則」防架構錯誤不 scale,所以把錯誤擋在 PR 這道結構性閘上。
`#流程 #架構 #審核` 建 2026-06-03 更 2026-08-15
## 說明
> 上面四節是**機器讀的**,只准放連結/路徑;說明寫在這一節,**不要用連結語法**。
> 每一行的摘要/標籤/日期都是從卡的 frontmatter 複製過來的 ⇒ index 可機械產生。
>
> 🔴 **`user_requirements/arcrun-landing-page/` 不在任何清單上,這是對的**——
> 它只有 `.jsx``.html``.css`,**沒有可掃文件 ⇒ 依規範它不是節點**,不建 `.wiki`
>
> 本層只長出一個主題(結構性閘),因為另外幾張卡各自獨立、還湊不出第二個主題。
+42
View File
@@ -0,0 +1,42 @@
---
tags: [設定, AI 協作]
gloss: arcrun 是給 AI 操作的工具,而 harness 說明 41 行對 scope 零字提及。
created: 2026-06-15
updated: 2026-08-15
---
# AI 讀不到的隱形分層
← [[HANDOFF-config-scope-and-vectorize]]
## 摘要
真實事故:mira 被交代遷移時沒看懂 scope 存在,差點誤部官方帳號。病根不在它笨,在它讀的東西裡沒有這一層。
## 重點
- **arcrun 的操作者是 AI 不是人**mindset §2),所以「AI 讀得到什麼」就是「系統有什麼」
- 而 harness block 全文 41 行,**對 scope/帳號歸屬/誤部風險 0 字**
- ⇒ mira 把「搬資料」跟「用哪個帳號」混為一談——**那是兩件獨立的事**
- 這是 [[結構性閘]] 的另一面:規則沒被寫在會被讀到的地方,等於不存在
## 實體
- **harness block**(介面)— AI 讀 arcrun 時看到的說明,`cli/harness/CLAUDE.block.md`
- **遷移 ≠ scope**(判準)— 搬資料與用哪個帳號是兩件獨立的事。
## 關聯
### 內文知識關係
- harness block >> 沒有提及 >> scope
- AI >> 只知道 >> harness block 寫的東西
### 卡片關係
- AI 讀不到的隱形分層 >> 屬於 >> [[HANDOFF-config-scope-and-vectorize]]
- AI 讀不到的隱形分層 >> 屬於 >> [[結構性閘]]
- AI 讀不到的隱形分層 >> 被誤部風險放大 >> [[scope 誤部風險]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> AI 讀不到的隱形分層
@@ -0,0 +1,34 @@
---
tags: [文件, 設定]
gloss: 一份交辦文件,指出 arcrun 設定分層的兩條缺口,萃出兩個概念。
created: 2026-06-15
updated: 2026-08-15
---
# HANDOFF-config-scope-and-vectorize
← [[00-INDEX]]
## 摘要
2026-06-15 頂層總管交辦。由 mira self-hosted dogfood 踩出來的兩條框架缺口,兩條都走 SDD 協議。
## 重點
- **缺口 1** 是功能面:讀分層完整但只能寫全域 ⇒ [[scope 誤部風險]]
- **缺口 2 被作者標為最重要**:AI 讀的說明裡完全沒有這一層 ⇒ [[AI 讀不到的隱形分層]]
- 文件還帶了第三塊(KBDB Vectorize 開關),但那部分在本次萃取中沒有獨立成概念
## 實體
- **HANDOFF-config-scope-and-vectorize**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- HANDOFF-config-scope-and-vectorize >> 涵蓋 >> [[AI 讀不到的隱形分層]]
- HANDOFF-config-scope-and-vectorize >> 涵蓋 >> [[scope 誤部風險]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> HANDOFF-config-scope-and-vectorize
+32
View File
@@ -0,0 +1,32 @@
---
tags: [文件, 遷移]
gloss: Matrix 重整交給 arcrun 的待辦清單,用指針式考古不複製素材。
created: 2026-06-13
updated: 2026-08-15
---
# HANDOFF-matrix-rearrange
← [[00-INDEX]]
## 摘要
頂層重整的產物。第一條就是關掉 matrix 版 cypher-executor。
## 重點
- **主要問題**[[cypher 雙份分岔]],含逐檔的勘查結果
- **指針式考古**:素材真身留在 `_archive/`,這份只給路徑——**不複製進來,避免第二份真相**
## 實體
- **HANDOFF-matrix-rearrange**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- HANDOFF-matrix-rearrange >> 涵蓋 >> [[cypher 雙份分岔]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-matrix-rearrange.md` >> 提及 >> HANDOFF-matrix-rearrange
+34
View File
@@ -0,0 +1,34 @@
---
tags: [文件, 事故]
gloss: 一份修復交辦,把調查做完了:哪些不用改、哪些要改、哪些要降級。
created: 2026-06-03
updated: 2026-08-15
---
# HANDOFF-mira-repair
← [[00-INDEX]]
## 摘要
arcrun 端的 CC 寫給 mira 端的 CC。特色是「調查已做完,照著改即可,不必重跑」。
## 重點
- **事故**[[Mira workflow 斷鏈]]——六個 workflow 全掃過
- **真因**:[[假零件降級]],而 mira 當初錯做了假零件
- **這份文件本身示範了一件事**:交辦時把調查結果一起交出去,接手的人才不會重跑一遍
## 實體
- **HANDOFF-mira-repair**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- HANDOFF-mira-repair >> 涵蓋 >> [[Mira workflow 斷鏈]]
- HANDOFF-mira-repair >> 涵蓋 >> [[假零件降級]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-mira-repair.md` >> 提及 >> HANDOFF-mira-repair
+34
View File
@@ -0,0 +1,34 @@
---
tags: [文件, 策略]
gloss: 一份把「今天要做的三件事」連同已查證實況整理好的交辦,核心是戰法轉向。
created: 2026-06-01
updated: 2026-08-15
---
# HANDOFF-self-host-harness
← [[00-INDEX]]
## 摘要
2026-06-01 撰寫。它最重要的一段不是任務,是背景:戰法已經變了。
## 重點
- **背景**[[self-hosted 戰法轉向]]
- **後果**[[成功的定義]] 被整個改寫
- **它的體例值得學**:先講「戰法已轉變(最重要的背景)」,再講今天做什麼
## 實體
- **HANDOFF-self-host-harness**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- HANDOFF-self-host-harness >> 涵蓋 >> [[self-hosted 戰法轉向]]
- HANDOFF-self-host-harness >> 涵蓋 >> [[成功的定義]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-self-host-harness.md` >> 提及 >> HANDOFF-self-host-harness
+40
View File
@@ -0,0 +1,40 @@
---
tags: [事故, 零件]
gloss: Mira 六個 workflow 全斷,但真因不是 arcrun 壞了,是 Mira 當初把東西錯做成假零件。
created: 2026-06-03
updated: 2026-08-15
---
# Mira workflow 斷鏈
← [[HANDOFF-mira-repair]]
## 摘要
整修後 mira 的 workflow 斷了。調查結論:prod 活著、降級的 recipe 都在 KV,大部分只需小改不必重寫。
## 重點
- **不是 arcrun 壞了**——`cypher.arcrun.dev` 活著,降級後的 recipe 都在 prod KV(已驗)
- **是 Mira 當初自己把一堆東西錯做成假零件** ⇒ [[假零件降級]] 一動它就斷
- **教訓**:引用「不該存在的東西」的人,會在那個東西被修正時付出代價
## 實體
- **六個 workflow**(受害範圍)— mira 全部的 workflow 都掃過了。
- **降級後的 recipe**(現況)— kbdb_gettelegram_sendgmail_send 等都在 prod KV。
## 關聯
### 內文知識關係
- Mira >> 錯做成 >> 假零件
- 假零件降級 >> 導致 >> Mira workflow 斷鏈
### 卡片關係
- Mira workflow 斷鏈 >> 屬於 >> [[HANDOFF-mira-repair]]
- Mira workflow 斷鏈 >> 肇因於 >> [[假零件降級]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-mira-repair.md` >> 提及 >> Mira workflow 斷鏈
@@ -0,0 +1,34 @@
---
tags: [文件, 流程]
gloss: 零件與 binding 的 PR 審核規範,五段 checklist,任一否就打回。
created: 2026-06-03
updated: 2026-08-15
---
# component-pr-review-standard
← [[00-INDEX]]
## 摘要
它的開場白就是整份文件的論點:靠 AI 記住規則不 scale,所以把錯誤擋在結構上。
## 重點
- **論點**[[規則記不住]]
- **手法**:[[零件 PR 審核閘]],五段 A–E 逐條過
- 每一段都附了**真實反例**`km_wiki_card_parse` 被否、drainer 本地綠但撞 1042)——**反例比規則本身有用**
## 實體
- **component-pr-review-standard**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- component-pr-review-standard >> 涵蓋 >> [[規則記不住]]
- component-pr-review-standard >> 涵蓋 >> [[零件 PR 審核閘]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../component-pr-review-standard.md` >> 提及 >> component-pr-review-standard
+37
View File
@@ -0,0 +1,37 @@
---
tags: [架構, 遷移]
gloss: matrix 與 arcrun 各有一份 cypher-executor 且已分岔——整合後只留 arcrun 一份。
created: 2026-06-13
updated: 2026-08-15
---
# cypher 雙份分岔
← [[HANDOFF-matrix-rearrange]]
## 摘要
勘查結果:matrix 版獨有 5 檔(皆非 SaaS 遺留,是 self-hosted 核心)、兩邊都有但內容不同 10 檔要逐檔比對。
## 重點
- **matrix 獨有的 5 檔是要保留的**:版本選擇策略、缺件自動生成、雙模式路由、WASM 執行、proxy
- **10 檔內容分岔**要逐檔比對合併,取較完整的一邊但保留 arcrun 較新的 auth 演進
- **同一個東西有兩份且都在演化 ⇒ 必然分岔**,這是要儘早合併的理由
## 實體
- **diverged 副本**(狀態)— 同源但各自演化後內容不同的兩份。
## 關聯
### 內文知識關係
- 兩份副本 >> 各自演化 >> 內容分岔
### 卡片關係
- cypher 雙份分岔 >> 屬於 >> [[HANDOFF-matrix-rearrange]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-matrix-rearrange.md` >> 提及 >> cypher 雙份分岔
+57
View File
@@ -0,0 +1,57 @@
{
"schema": 3,
"note": "卡→原文寫在卡的『### 出處』;原文→卡寫在這裡。多對多。",
"docs": [
{
"doc_id": "d_000",
"node": "(根)",
"path": "HANDOFF-config-scope-and-vectorize.md",
"提及": [
"HANDOFF-config-scope-and-vectorize",
"AI 讀不到的隱形分層",
"結構性閘",
"scope 誤部風險"
]
},
{
"doc_id": "d_001",
"node": "(根)",
"path": "HANDOFF-matrix-rearrange.md",
"提及": [
"HANDOFF-matrix-rearrange",
"cypher 雙份分岔"
]
},
{
"doc_id": "d_002",
"node": "(根)",
"path": "HANDOFF-mira-repair.md",
"提及": [
"假零件降級",
"Mira workflow 斷鏈",
"HANDOFF-mira-repair"
]
},
{
"doc_id": "d_003",
"node": "(根)",
"path": "HANDOFF-self-host-harness.md",
"提及": [
"成功的定義",
"self-hosted 戰法轉向",
"HANDOFF-self-host-harness"
]
},
{
"doc_id": "d_004",
"node": "(根)",
"path": "component-pr-review-standard.md",
"提及": [
"零件 PR 審核閘",
"規則記不住",
"結構性閘",
"component-pr-review-standard"
]
}
]
}
+41
View File
@@ -0,0 +1,41 @@
---
tags: [設定, 風險]
gloss: 部署到哪個 CF 帳號,取決於你當下站在哪個資料夾——而這件事沒有任何地方寫著。
created: 2026-06-15
updated: 2026-08-15
---
# scope 誤部風險
← [[HANDOFF-config-scope-and-vectorize]]
## 摘要
arcrun 的設定讀取分三層(env > 專案 > 全域),但寫入只能寫全域,而「我現在會部到哪」沒有任何提示。
## 重點
- **讀分層完整、寫只能全域**`saveConfig()` 寫死 `~/.arcrun/config.yaml``acr init` 四處呼叫全寫全域
- ⇒ 想用專案層的人被迫污染全域,**怕誤打官方帳號**
- **最陰險的是 fallback**:任一層都沒 `mode` 就靜默當 `local`,不報錯——你會看到「成功」但雲端沒有 trace
- 真正的病灶不是缺功能,是 [[AI 讀不到的隱形分層]]
## 實體
- **scope 分層**(機制)— env > 專案 `.arcrun.yaml` > 全域 `~/.arcrun/config.yaml`
- **靜默 fallback**(陷阱)— 沒設 mode 就當 local,不報錯。
## 關聯
### 內文知識關係
- scope 分層 >> 決定 >> 部署到哪個帳號
- 靜默 fallback >> 造成 >> 看到成功但沒有雲端 trace
### 卡片關係
- scope 誤部風險 >> 屬於 >> [[HANDOFF-config-scope-and-vectorize]]
- scope 誤部風險 >> 根因是 >> [[AI 讀不到的隱形分層]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> scope 誤部風險
+39
View File
@@ -0,0 +1,39 @@
---
tags: [策略, 部署]
gloss: 2026-06-01 從 SaaS 改成 self-hosted 開源——它改的不只是商業模式,是「成功」的定義。
created: 2026-06-01
updated: 2026-08-15
---
# self-hosted 戰法轉向
← [[HANDOFF-self-host-harness]]
## 摘要
richblack 的決定。連帶把 self-hosted 一鍵起得來從「重要但非阻擋項」升為第一優先。
## 重點
- **改的是驗收標準**,見 [[成功的定義]]
- ⇒ 優先序跟著翻轉:self-host 起得來變成當日第一優先
- **戰法一變,原本排在後面的東西可能立刻變成阻擋項**——這是排序時要先問「戰法變了嗎」的理由
## 實體
- **self-hosted 開源策略**(戰法)— 使用者在自己的 CF 帳號上跑,而非平台託管。
## 關聯
### 內文知識關係
- 戰法轉向 >> 改變 >> 優先序
- self-hosted >> 取代 >> SaaS
### 卡片關係
- self-hosted 戰法轉向 >> 屬於 >> [[HANDOFF-self-host-harness]]
- self-hosted 戰法轉向 >> 改寫了 >> [[成功的定義]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-self-host-harness.md` >> 提及 >> self-hosted 戰法轉向
+41
View File
@@ -0,0 +1,41 @@
---
tags: [零件, 架構]
gloss: 打固定 endpoint 的東西不是零件是 recipe——33 顆降到 22 顆,代價是所有引用它的人斷鏈。
created: 2026-06-03
updated: 2026-08-15
---
# 假零件降級
← [[HANDOFF-mira-repair]]
## 摘要
arcrun 大整修:把「打固定 API 卻被做成零件」的假零件降級成 recipe 並刪掉零件目錄。判準是 DECISIONS §1:零件=endpoint 薄殼。
## 重點
- **判準**:零件是可複用的原語;**打固定 API 的是 recipe**
- **33 → 22 顆**,被降級的解析方式變了 ⇒ 造成 [[Mira workflow 斷鏈]]
- 這正是 [[零件 PR 審核閘]] 要在源頭擋掉的東西——**降級是補救,審核才是預防**
## 實體
- **假零件**(反模式)— 打固定 API 卻被做成零件的東西。
- **recipe**(正解)— 具名封裝的單一 API 呼叫。
## 關聯
### 內文知識關係
- 假零件 >> 應降級為 >> recipe
- 降級 >> 改變了 >> 零件名的解析方式
### 卡片關係
- 假零件降級 >> 屬於 >> [[HANDOFF-mira-repair]]
- 假零件降級 >> 被擋於 >> [[零件 PR 審核閘]]
- 假零件降級 >> 觸發了 >> [[Mira workflow 斷鏈]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-mira-repair.md` >> 提及 >> 假零件降級
+40
View File
@@ -0,0 +1,40 @@
---
tags: [策略, 驗收]
gloss: 舊:在我的帳號上能跑。新:任何人在自己的帳號上跑得通,而且寫錯會被擋住。
created: 2026-06-01
updated: 2026-08-15
---
# 成功的定義
← [[HANDOFF-self-host-harness]]
## 摘要
戰法轉向後,harness 的成功定義被整個改寫。新定義多了一個常被忽略的下半句。
## 重點
- **舊**:在 richblack 的 prod 帳號(`cypher.arcrun.dev`)上能跑
- **新**:任何 CC 在自己的 CF 帳號 `acr init --self-hosted` 後跑通一個含 recipe 的 workflow
- 🔴 **而且「寫錯時會被程式擋住」是定義的一部分**——不是加分項
- ⇒ 這句話直接推導出 [[零件 PR 審核閘]] 這類 [[結構性閘]] 的必要性
## 實體
- **新成功定義**(驗收)— 任何人在自己帳號跑通,且寫錯會被擋。
## 關聯
### 內文知識關係
- 新定義 >> 包含 >> 寫錯會被擋住
- 舊定義 >> 只涵蓋 >> 作者自己的帳號
### 卡片關係
- 成功的定義 >> 屬於 >> [[HANDOFF-self-host-harness]]
- 成功的定義 >> 被改寫於 >> [[self-hosted 戰法轉向]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-self-host-harness.md` >> 提及 >> 成功的定義
+37
View File
@@ -0,0 +1,37 @@
---
tags: [方法論, 流程, 架構]
gloss: 把錯誤路徑做成「根本碰不到」,而不是叮嚀人不要走。
created: 2026-06-03
updated: 2026-08-15
---
# 結構性閘
← [[00-INDEX]]
## 摘要
這個主題把三張卡收在一起,因為它們是同一個手法的三個場合:不靠自覺,靠結構。
## 重點
- 把 [[零件 PR 審核閘]] 收進來,是因為它明講了理由——**零件本來就要走 PR,所以把檢查放在那裡,錯誤路徑就碰不到**
- 而 [[規則記不住]] 是這個手法的前提:**規定說幾次都沒用,這不是紀律問題是機制缺席**
- [[AI 讀不到的隱形分層]] 則是同一件事的反面——**規則沒被放在會被讀到的地方,等於不存在**
- ⇒ 三張卡合起來給出一條可複用的判準:**看到「說過很多次還是會犯」,就去找那個缺席的閘**
## 實體
- **結構性閘**(手法)— 讓錯誤路徑在結構上碰不到,而非靠遵守。
## 關聯
### 卡片關係
- 結構性閘 >> 收攏 >> [[AI 讀不到的隱形分層]]
- 結構性閘 >> 收攏 >> [[規則記不住]]
- 結構性閘 >> 收攏 >> [[零件 PR 審核閘]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../component-pr-review-standard.md` >> 提及 >> 結構性閘
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> 結構性閘
+40
View File
@@ -0,0 +1,40 @@
---
tags: [流程, AI 協作]
gloss: 規定說幾次都沒用——這不是紀律問題,是機制缺席。
created: 2026-06-03
updated: 2026-08-15
---
# 規則記不住
← [[component-pr-review-standard]]
## 摘要
審核規範開宗明義的一句:「靠 AI 記住規則防架構錯誤不 scale」。它是整份文件存在的理由。
## 重點
- **規定寫得再清楚,沒有機制驗證就等於沒有**
- ⇒ 對策不是寫更多規定,是**把錯誤路徑做成碰不到**
- 這條可以推廣到任何「說過很多次還是會犯」的問題——**去找那個缺席的閘**
## 實體
- **不 scale**(性質)— 規則數量增加時,靠記憶遵守的成功率下降。
## 關聯
### 內文知識關係
- 靠記憶遵守規則 >> 不 scale
- 缺席的機制 >> 才是 >> 真正的病灶
### 卡片關係
- 規則記不住 >> 屬於 >> [[component-pr-review-standard]]
- 規則記不住 >> 屬於 >> [[結構性閘]]
- 規則記不住 >> 治的是 >> [[零件 PR 審核閘]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../component-pr-review-standard.md` >> 提及 >> 規則記不住
+46
View File
@@ -0,0 +1,46 @@
---
tags: [流程, 架構, 審核]
gloss: 靠「AI 記住規則」防架構錯誤不 scale,所以把錯誤擋在 PR 這道結構性閘上。
created: 2026-06-03
updated: 2026-08-15
---
# 零件 PR 審核閘
← [[component-pr-review-standard]]
## 摘要
零件與 service binding 本來就要走 PR,所以把檢查放在那裡,讓錯誤路徑「根本碰不到」。五段 checklist,任一否就打回。
## 重點
- **A 反過度工程**:判準是「三個月後會有第二個 workflow 用它嗎」,不會就不是零件
- **B binding 層級**(最常踩):跨-worker 編排一律走 cypher bindingservice binding 只准組 wasm
- **D 驗證誠實**:要打**部署後的真端點**,本地 miniflare 會假綠——實例是 drainer 本地綠、production 撞 1042
- **E 部署鐵律**wrangler 直推,不用 `acr update`(會假綠蓋掉改動)
- 它治的是 [[規則記不住]],手法屬於 [[結構性閘]]
## 實體
- **PR 審核閘**(機制)— 零件與 binding 變更的強制檢查點。
- **假綠**(反模式)— 本地測試通過但生產環境會失敗。
- **三個月判準**(判準)— 未來三個月會不會有第二個使用者。
## 關聯
### 內文知識關係
- PR 審核 >> 擋下 >> 架構錯誤
- 本地 miniflare >> 產生 >> 假綠
- 三個月判準 >> 決定 >> 是不是零件
### 卡片關係
- 零件 PR 審核閘 >> 實作 >> [[規則記不住]]
- 零件 PR 審核閘 >> 屬於 >> [[component-pr-review-standard]]
- 零件 PR 審核閘 >> 屬於 >> [[結構性閘]]
- 零件 PR 審核閘 >> 擋的正是 >> [[假零件降級]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../component-pr-review-standard.md` >> 提及 >> 零件 PR 審核閘
+55
View File
@@ -0,0 +1,55 @@
# 00-INDEXuser_requirements
`../../.wiki/00-INDEX.md`
## 子節點
- **ADR-lib-and-landingPage**`../ADR-lib-and-landingPage/.wiki/00-INDEX.md` — 3 篇(未萃)
- **simplify_mvp**`../simplify_mvp/.wiki/00-INDEX.md` — 1 篇(未萃)
- **u6u-long-term**`../u6u-long-term/.wiki/00-INDEX.md` — 1 篇+子節點 SourceDocs 3 篇(未萃)
## 文件(本層每一篇一張)
- [[credential_parts]] — 29KB 的 credential 規格,本次只萃了 §0–§2,其餘尚未分段。
`#文件 #credential` 建 2026-05-25 更 2026-08-15
- [[u6u_design]] — 一份兩千字的設計筆記,密度很高,萃出五個概念。
`#文件 #設計` 建 2026-05-20 更 2026-08-15
- `../wishlist.md`**空**:指針檔,全文都在說「已移至頂層」。真身在 `InkStoneCo/docs/1-vision/product-wishlist.md`
## 主題(跨文件聚類產生)
- [[讓生態自己長]] — u6u 對「零件從哪來、誰把關、爛的怎麼淘汰」的整套答案:三件事互相咬合。
`#生態 #零件 #方法論` 建 2026-05-20 更 2026-08-15
## 本層概念(7
- [[AI 的 marketplace]] — marketplace 給 AI 不給人:強制用完回評價,被評差的零件其他 AI 自動避開。
`#生態 #零件` 建 2026-05-20 更 2026-08-15
- [[n8n 的每服務一零件]] — n8n 為每個服務寫一個 credential 零件——這份文件開宗明義說那是錯的。
`#credential #反模式` 建 2026-05-25 更 2026-08-15
- [[四個 primitive 不是每服務一個零件]] — 做四個 TinyGo/WASM primitive,每個服務只要一份 YAML recipe 用戶自己的 secret。
`#credential #架構` 建 2026-05-25 更 2026-08-15
- [[自動審核取代人工]] — call API 的只要 call 通就過,功能性的只要通過它自己設的 Gherkin 就過。
`#流程 #零件` 建 2026-05-20 更 2026-08-15
- [[視覺優先開發]] — 用戶拉一個按鈕,後面自然綁上 webhook——不用解釋 webhook 是什麼。
`#產品 #介面` 建 2026-05-20 更 2026-08-15
- [[解釋 webhook 的困難]] — 要跟一般用戶解釋什麼是 webhook 很難——這是低門檻產品真正的門檻。
`#產品 #介面` 建 2026-05-20 更 2026-08-15
- [[零件長尾誰來補]] — 沒有人會為了 delete table 去寫完整的 Google Sheets 零件——但缺的那個端點會被下一個需要的人補上。
`#生態 #零件` 建 2026-05-20 更 2026-08-15
## 說明
> 上面四節是機器讀的;說明寫這裡,不要用連結語法。
>
> 🔴 **`arcrun-landing-page/` 沒有出現在子節點清單裡,這是對的**——
> 它只有 `.jsx``.html``.css`,**沒有可掃文件 ⇒ 依規範不是節點**。
>
> 🔴 **三個子節點標「未萃」是誠實的殘缺**:本次真實測試只做了 `docs/` 與本層兩個節點。
> 標出來,使用者才分得出「沒東西」和「還沒做」。
>
> 🔴 **`wishlist.md` 是「空」的第三種**:不是沒東西(待辦)、不是紀錄(報價單),是**指針**。
> 依規範標 `no_concept` **不產卡**——我第一版給它產了卡,`wiki-lint` 當場報孤島卡抓到。
>
> 🔴 **`credential_parts.md` 是 29KB、12 個 H2**,超過 20KB 分段門檻。
> 本次只萃了 §0–§2,其餘八段**如實標明未做**——一張卡蓋不住 29KB。
@@ -0,0 +1,42 @@
---
tags: [生態, 零件]
gloss: marketplace 給 AI 不給人:強制用完回評價,被評差的零件其他 AI 自動避開。
created: 2026-05-20
updated: 2026-08-15
---
# AI 的 marketplace
← [[u6u_design]]
## 摘要
u6u 的零件市集設計。它跟一般 marketplace 的差別在使用者是 AI,所以回饋可以被強制。
## 重點
- **強制回評價**——AI 不像人會忘記或懶得評
- **被評差幾次的零件,其他 AI 自動避開** ⇒ 淘汰不需要人介入
- 它配的是 [[自動審核取代人工]],兩者合起來讓 [[零件長尾誰來補]] 有解
## 實體
- **AI marketplace**(機制)— 使用者是 AI 的零件市集,回饋可強制。
- **自然淘汰**(效果)— 差評零件被自動避開。
## 關聯
### 內文知識關係
- AI >> 被強制 >> 回覆評價
- 低評零件 >> 被 >> 其他 AI 避開
### 卡片關係
- AI 的 marketplace >> 屬於 >> [[u6u_design]]
- AI 的 marketplace >> 屬於 >> [[讓生態自己長]]
- AI 的 marketplace >> 機制是 >> [[自動審核取代人工]]
- AI 的 marketplace >> 解的是 >> [[零件長尾誰來補]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> AI 的 marketplace
@@ -0,0 +1,34 @@
---
tags: [文件, credential]
gloss: 29KB 的 credential 規格,本次只萃了 §0–§2,其餘尚未分段。
created: 2026-05-25
updated: 2026-08-15
---
# credential_parts
← [[00-INDEX]]
## 摘要
這份文件超過本規範的 20KB 分段門檻,共 12 個 H2 段落。本次示範「只萃前段、其餘誠實標明未做」。
## 重點
- 已萃:[[四個 primitive 不是每服務一個零件]]、[[n8n 的每服務一零件]](來自 §0 TL;DR 與 §12
- 🔴 **未萃**:§3 四個 primitive 詳細規格/§4 Recipe Schema/§5 繼承/§6 WASM 介面/§7 AuthBroker API/§8 Storage/§9 授權 UI/§10 TODO/§11 風險
- **這正是「大檔要按 H2 分段」那個洞的實例**——一張卡蓋不住 29KB
## 實體
- **credential_parts**(原稿)— 本卡對應的那一份文件。
## 關聯
### 卡片關係
- credential_parts >> 涵蓋 >> [[n8n 的每服務一零件]]
- credential_parts >> 涵蓋 >> [[四個 primitive 不是每服務一個零件]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../credential_parts.md` >> 提及 >> credential_parts
@@ -0,0 +1,37 @@
---
tags: [credential, 反模式]
gloss: n8n 為每個服務寫一個 credential 零件——這份文件開宗明義說那是錯的。
created: 2026-05-25
updated: 2026-08-15
---
# n8n 的每服務一零件
← [[credential_parts]]
## 摘要
被點名的反模式。它的問題是零件數量隨服務數量線性成長,而認證方式其實只有幾種。
## 重點
- **零件數 ∝ 服務數**,但認證方式其實只有四種
- ⇒ 正解是 [[四個 primitive 不是每服務一個零件]]
## 實體
- **每服務一零件**(反模式)— n8n 的 credential 做法。
## 關聯
### 內文知識關係
- 零件數 >> 隨服務數 >> 線性成長
### 卡片關係
- n8n 的每服務一零件 >> 否定 >> [[四個 primitive 不是每服務一個零件]]
- n8n 的每服務一零件 >> 屬於 >> [[credential_parts]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../credential_parts.md` >> 提及 >> n8n 的每服務一零件
@@ -0,0 +1,37 @@
---
tags: [文件, 設計]
gloss: 一份兩千字的設計筆記,密度很高,萃出五個概念。
created: 2026-05-20
updated: 2026-08-15
---
# u6u_design
← [[00-INDEX]]
## 摘要
開場一句「u6u 是一個 AI Friendly 的 n8n」,後面是條列的設計主張,最後一段是觀念想法。
## 重點
- **生態那三條**被收成 [[讓生態自己長]]
- **介面策略**是 [[視覺優先開發]],它迴避 [[解釋 webhook 的困難]]
- ⚠️ 文件最後一句「考慮讓他自己 OWN,企業版資料獨立」**沒有展開,本次未萃成卡**
## 實體
- **u6u_design**(原稿)— 本卡對應的那一份文件。
## 關聯
### 卡片關係
- u6u_design >> 涵蓋 >> [[AI 的 marketplace]]
- u6u_design >> 涵蓋 >> [[自動審核取代人工]]
- u6u_design >> 涵蓋 >> [[視覺優先開發]]
- u6u_design >> 涵蓋 >> [[解釋 webhook 的困難]]
- u6u_design >> 涵蓋 >> [[零件長尾誰來補]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> u6u_design
@@ -0,0 +1,44 @@
---
tags: [credential, 架構]
gloss: 做四個 TinyGo/WASM primitive,每個服務只要一份 YAML recipe 用戶自己的 secret。
created: 2026-05-25
updated: 2026-08-15
---
# 四個 primitive 不是每服務一個零件
← [[credential_parts]]
## 摘要
credential 設計的核心主張。它是三層模型:primitive(程式)/recipe(公共設定)/secret(私有)。
## 重點
- **四個 primitive** 涵蓋所有認證方式,不隨服務數量成長
- **recipe 存平台 KV(公共)、secret 存 tenant KV(私有)**runtime 由 AuthBroker 組裝
- 它明確反對 [[n8n 的每服務一零件]]
- ⚠️ 本卡只涵蓋這份 29KB 文件的 §0–§2;`§3 四個 primitive 詳細規格``§4 Recipe Schema` 等**尚未分段萃取**
## 實體
- **primitive**(零件)— TinyGo/WASM 寫的認證原語,共四個。
- **recipe**(設定)— 每服務一份 YAML,存公共 KV。
- **AuthBroker**(組裝者)— runtime 把 recipe 與 secret 組成可用的 HTTP client。
## 關聯
### 內文知識關係
- primitive >> 加上 >> recipe 與 secret
- AuthBroker >> 組裝 >> 可用的 HTTP client
- recipe >> 存於 >> 公共 KV
- secret >> 存於 >> tenant KV
### 卡片關係
- 四個 primitive 不是每服務一個零件 >> 反的是 >> [[n8n 的每服務一零件]]
- 四個 primitive 不是每服務一個零件 >> 屬於 >> [[credential_parts]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../credential_parts.md` >> 提及 >> 四個 primitive 不是每服務一個零件
@@ -0,0 +1,41 @@
---
tags: [流程, 零件]
gloss: call API 的只要 call 通就過,功能性的只要通過它自己設的 Gherkin 就過。
created: 2026-05-20
updated: 2026-08-15
---
# 自動審核取代人工
← [[u6u_design]]
## 摘要
把「零件能不能上架」的判斷交給可執行的檢查,省掉人工審核。
## 重點
- **打 API 的**:成功 call 通就算過
- **功能性的**:通過投稿者自己寫的 Gherkin 就算過
- ⚠️ 這條跟後來的 `component-pr-review-standard` 有張力——那份明講 **Gherkin 全綠 ≠ 零件安全**(投稿者可寫避重就輕的 Gherkin)
- ⇒ **兩份文件寫於不同時期,後者修正了前者**。真正框死破壞力的是純 WASI 沙箱,不是 Gherkin
## 實體
- **Gherkin 自審**(機制)— 投稿者自寫驗收條件,通過即上架。
## 關聯
### 內文知識關係
- call API 成功 >> 即通過 >> 審核
- Gherkin 全綠 >> 不等於 >> 零件安全
### 卡片關係
- 自動審核取代人工 >> 屬於 >> [[u6u_design]]
- 自動審核取代人工 >> 屬於 >> [[讓生態自己長]]
- 自動審核取代人工 >> 支撐 >> [[AI 的 marketplace]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 自動審核取代人工
@@ -0,0 +1,41 @@
---
tags: [產品, 介面]
gloss: 用戶拉一個按鈕,後面自然綁上 webhook——不用解釋 webhook 是什麼。
created: 2026-05-20
updated: 2026-08-15
---
# 視覺優先開發
← [[u6u_design]]
## 摘要
u6u 的低門檻策略:讓抽象概念從具體操作裡浮現,而不是先教會使用者術語。
## 重點
- **先做前端按鈕、輸入框,後端邏輯跟著綁上去**
- **點下去就看到工作流** ⇒ 概念從操作裡長出來
- 它迴避的是 [[解釋 webhook 的困難]]
- 延伸:cypher 放大就是 graph,**每個功能可以摺疊成一點,也可以 zoom in 展開調整** ⇒ 同一個碎型結構
## 實體
- **視覺優先**(策略)— 先給看得見的操作,再讓概念浮現。
- **摺疊與展開**(互動)— 功能可收成一點也可展開調整。
## 關聯
### 內文知識關係
- 前端按鈕 >> 綁定 >> webhook
- cypher 放大 >> 就是 >> graph
### 卡片關係
- 視覺優先開發 >> 屬於 >> [[u6u_design]]
- 視覺優先開發 >> 迴避了 >> [[解釋 webhook 的困難]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 視覺優先開發
@@ -0,0 +1,37 @@
---
tags: [產品, 介面]
gloss: 要跟一般用戶解釋什麼是 webhook 很難——這是低門檻產品真正的門檻。
created: 2026-05-20
updated: 2026-08-15
---
# 解釋 webhook 的困難
← [[u6u_design]]
## 摘要
一句被順帶寫下、但其實是整個介面策略起點的觀察。
## 重點
- **門檻不在功能,在術語**
- ⇒ 對策不是寫更好的說明文件,是 [[視覺優先開發]]——讓他不必知道那個詞
## 實體
- **術語門檻**(障礙)— 使用者卡在概念名稱而非功能本身。
## 關聯
### 內文知識關係
- 術語 >> 構成 >> 使用門檻
### 卡片關係
- 解釋 webhook 的困難 >> 屬於 >> [[u6u_design]]
- 解釋 webhook 的困難 >> 被迴避於 >> [[視覺優先開發]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 解釋 webhook 的困難
@@ -0,0 +1,36 @@
---
tags: [生態, 零件, 方法論]
gloss: u6u 對「零件從哪來、誰把關、爛的怎麼淘汰」的整套答案:三件事互相咬合。
created: 2026-05-20
updated: 2026-08-15
---
# 讓生態自己長
← [[00-INDEX]]
## 摘要
這個主題把三張卡收在一起,因為它們是同一個迴圈的三段:怎麼補、怎麼把關、怎麼淘汰。
## 重點
- 起點是 [[零件長尾誰來補]]——**不預先做完整,缺的時候由需要的人補**
- 把關交給 [[自動審核取代人工]],因為人工審核會變成瓶頸
- 淘汰交給 [[AI 的 marketplace]]**而它能成立的唯一原因是使用者是 AI**(回饋可以被強制)
- ⇒ **三段缺一段這個迴圈就停**:沒人補、沒人審、或爛的不會消失,生態都長不起來
## 實體
- **生態迴圈**(結構)— 補件、把關、淘汰三段互相咬合。
## 關聯
### 卡片關係
- 讓生態自己長 >> 收攏 >> [[AI 的 marketplace]]
- 讓生態自己長 >> 收攏 >> [[自動審核取代人工]]
- 讓生態自己長 >> 收攏 >> [[零件長尾誰來補]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 讓生態自己長
@@ -0,0 +1,41 @@
---
tags: [生態, 零件]
gloss: 沒有人會為了 delete table 去寫完整的 Google Sheets 零件——但缺的那個端點會被下一個需要的人補上。
created: 2026-05-20
updated: 2026-08-15
---
# 零件長尾誰來補
← [[u6u_design]]
## 摘要
u6u 對「零件覆蓋率」的答案:不追求完整,追求需要時補得上,而搜尋會把碎片整合起來。
## 重點
- **每個 API call 獨立,但搜尋會整合**——有人做了 create table,下一個人要 delete 時發現沒有就直接做一個
- **下次搜 google sheets 就同時看到兩個端點** ⇒ 覆蓋率由需求長出來,不由規劃決定
- 這正是 [[AI 的 marketplace]] 要服務的形狀
## 實體
- **長尾覆蓋**(問題)— 沒人會預先寫完整的 API 包裝。
- **搜尋整合**(機制)— 碎片端點在搜尋時被聚合成一組。
## 關聯
### 內文知識關係
- 需求 >> 決定 >> 覆蓋率
- 搜尋 >> 整合 >> 獨立的端點
### 卡片關係
- 零件長尾誰來補 >> 對策是 >> [[AI 的 marketplace]]
- 零件長尾誰來補 >> 屬於 >> [[u6u_design]]
- 零件長尾誰來補 >> 屬於 >> [[讓生態自己長]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 零件長尾誰來補
+138
View File
@@ -0,0 +1,138 @@
-- 0007 — record 有身分,關係是唯一機制(樹狀 record 模型第一刀)
-- 規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm
--
-- 模型一句話(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
-- · record = 池中一顆有身分的 entry(沿用原 record_id 字串當 id,既有引用不失效)
-- · 欄位是關係、歸屬也是關係——同一種機制:一列關係 = src/rel/dst 三個型別化指標欄
-- (「池上型別化指標欄」,parent_id 是這族欄位的既有先例。紅線:不准用關係實作關係、
-- 不准把指標塞回 content/JSON——那是 D91 的位置換個門進來)
-- · 舊 entry_values 四欄的下場:record_id → record entry 自己的 identry_id → 指標終點(dst
-- slot_name → 指標謂詞(rel field entry)/template_id → 一條屬於關係(rel sys_belongs
-- · 那張表該死的理由:它是「關係」的第二套實作——同一件事兩個實作必然漂移(D92)
--
-- 資料遷移形狀(v7 §3):既有 value entries 一列都不動(id 穩定、向量索引不失效)、
-- 全部純 INSERT、零 UPDATE。以舊儲存格列 id 衍生關係列 id ⇒ INSERT OR IGNORE 天然冪等。
--
-- 冪等設計(三條套用路徑:官方 wrangler migrationscli deploy.ts 每次重跑/安裝器逐句重放):
-- · 除了三句 ADD COLUMN 之外,每一句都是語句級冪等(IF NOT EXISTSINSERT OR IGNORE
-- · SQLite 沒有「ADD COLUMN IF NOT EXISTS」——重跑時那三句會報 duplicate column
-- 套用端必須把「duplicate column」視為已套用(cli deploy.ts 的 applyD1MigrationTolerant、
-- 安裝器逐句 try/catch 本就容錯)。官方路徑走 wrangler migrations 追蹤表,只跑一次。
-- · 不走「重建整張 entries」的做法:那雖然能純語句冪等,但 deploy.ts 每次部署都重跑
-- migration ⇒ 每次部署全表複製一輪,直接吃掉 D1 每日列寫入額度。
-- · entry_values 拆表用 0006 的「墊表、搬、拆」手法:重跑時先墊一份空殼,搬 0 筆,再拆,無害。
--
-- 施工窗口注意:本檔跑完 entry_values 就不存在了,必須與讀寫端改版(kbdb/src 同一批)一起部署。
-- 舊 worker 碰新庫會炸 entry_values 不存在——這是刻意的(leo:「不做長期雙讀相容層,一次翻」)。
-- ============================================================
-- 1. 關係的物理載體:池上型別化指標欄 + 一對方向索引
-- ============================================================
ALTER TABLE entries ADD COLUMN src_id TEXT; -- kbdb-sql-ok: 牆內 migration 本體(v7 定案載體「池上型別化指標欄」),重跑由套用端容錯 duplicate column
ALTER TABLE entries ADD COLUMN rel_id TEXT; -- kbdb-sql-ok: 同上
ALTER TABLE entries ADD COLUMN dst_id TEXT; -- kbdb-sql-ok: 同上
-- 一對方向索引(v7 §2-1):src 端服務「r1 的所有欄位」,(dst,rel) 端服務
-- 「某張 sheet 的所有 record」(rel=屬於, dst=sheet)與「誰指到 e1」(dst=e1)。
-- partial index:內容 entry(指標欄全 NULL)不進索引,索引大小 ≈ 關係列數。
CREATE INDEX IF NOT EXISTS idx_entries_rel_src ON entries(src_id) WHERE src_id IS NOT NULL; -- kbdb-sql-ok: 牆內 migration 本體,普通欄位索引(0001 十條索引的同族)
CREATE INDEX IF NOT EXISTS idx_entries_rel_dst ON entries(dst_id, rel_id) WHERE dst_id IS NOT NULL; -- kbdb-sql-ok: 同上
-- ============================================================
-- 2. 啟動常數(v7 §2-3:一組、極小、只讀)
-- ============================================================
-- 保留根:屬於鏈的終點。某顆 entry ─屬於→ sys_root 它是一張 sheet。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_root', 'root', 'system', NULL);
-- 「屬於」謂詞:record ─屬於→ sheet(歸屬關係的 rel)。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_belongs', 'belongs', 'system', NULL);
-- 「欄位屬於表」謂詞:field ─field_of→ sheetsheet 的欄位名冊,與 record 歸屬分開,
-- 免得「某張 sheet 的所有 record」把欄位也撈進來)。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_field_of', 'field_of', 'system', NULL);
-- ============================================================
-- 3. 墊表(0006 手法):本檔尾端會拆掉 entry_values,重跑時先墊空殼
-- 讓下面的搬遷語句永遠合法(搬 0 筆),最後再拆一次。
-- ============================================================
CREATE TABLE IF NOT EXISTS entry_values ( -- kbdb-sql-ok: 表退場施工步驟①保底存在(0006 同款),非資料存取違規
id TEXT PRIMARY KEY,
record_id TEXT NOT NULL,
template_id TEXT NOT NULL,
slot_name TEXT NOT NULL,
entry_id TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch()),
UNIQUE(record_id, slot_name)
);
-- ============================================================
-- 4. sheetfield 落池(templates 表遷移期雙軌:欄位定義的真相源暫仍在 templates 表,
-- sheet/field entry 是它在池中的身分——第二刀把 templates 表整個退役)
-- ============================================================
-- 每個 template 一顆 sheet entryid 沿用 template id,既有引用不失效)
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id, created_at, updated_at)
SELECT t.id, t.name, 'sheet', NULL, t.created_at, t.updated_at FROM templates t;
-- sheet ─屬於→ 保留根(「什麼讓一顆 entry 成為 sheet」的答案:這條關係)
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT 'relb_' || t.id, 'relation', t.id, 'sys_belongs', 'sys_root' FROM templates t;
-- 每個 slot 一顆 field entryid 決定性衍生:fld_<template>_<slot>,欄名=關係的謂詞)
INSERT OR IGNORE INTO entries (id, content, entry_type)
SELECT 'fld_' || t.id || '_' || j.value, j.value, 'field'
FROM templates t, json_each(t.slots_json) j;
-- field ─field_of→ sheet(欄位名冊)
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT 'relf_' || t.id || '_' || j.value, 'relation', 'fld_' || t.id || '_' || j.value, 'sys_field_of', t.id
FROM templates t, json_each(t.slots_json) j;
-- 保險網:entry_values 裡實際用過、但 slots_json 沒宣告的 slot(宣告與實作漂移的實證),
-- 一樣補出 field entry 與名冊,資料一筆都不能掉。
INSERT OR IGNORE INTO entries (id, content, entry_type)
SELECT DISTINCT 'fld_' || ev.template_id || '_' || ev.slot_name, ev.slot_name, 'field' FROM entry_values ev;
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT DISTINCT 'relf_' || ev.template_id || '_' || ev.slot_name, 'relation',
'fld_' || ev.template_id || '_' || ev.slot_name, 'sys_field_of', ev.template_id
FROM entry_values ev;
-- ============================================================
-- 5. record 有身分:每筆 record 一顆池中 entryid 原 record_id 字串)
-- 歸屬 owner 沿用「第一個非 NULL 的 slot entry owner」——與舊讀端的推導逐字同義。
-- INSERT OR IGNORE 的另一層意義:record_id 已經是池中既有 entrylibrary_map 的
-- block 即 record 慣例)時,那顆 entry 本人就是身分,不另建。
-- ============================================================
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, created_at, updated_at)
SELECT ev.record_id, 'record',
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
MIN(ev.created_at), MIN(ev.created_at)
FROM entry_values ev GROUP BY ev.record_id;
-- record ─屬於→ sheet(舊 template_id 欄的下場:變成一條關係)
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
SELECT 'relb_' || ev.record_id || '_' || ev.template_id, 'relation',
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
ev.record_id, 'sys_belongs', ev.template_id, MIN(ev.created_at)
FROM entry_values ev GROUP BY ev.record_id, ev.template_id;
-- ============================================================
-- 6. 每個儲存格 → 一條關係列(src=record、rel=欄位謂詞、dst=原 value entry
-- 關係列 id 以舊儲存格列 id 衍生(relv_<ev.id>)⇒ 重跑天然冪等。
-- ============================================================
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
SELECT 'relv_' || ev.id, 'relation', e.owner_id,
ev.record_id, 'fld_' || ev.template_id || '_' || ev.slot_name, ev.entry_id, ev.created_at
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id;
-- ============================================================
-- 7. 拆表(本提案的完成證明:關係的第二套實作不再有位置)
-- ============================================================
DROP TABLE IF EXISTS entry_values; -- kbdb-sql-ok: 表退場施工(0006 同款),v7 定案「兩套機制合成一套」的落地
+20
View File
@@ -73,10 +73,20 @@ export interface ListEntriesResult {
// 當 count 回傳,容易被誤讀成「總共只有這幾筆」。total 才是真總數,count 仍保留=本頁筆數。
}
// ── 機制列隔離(0007 樹狀 record 模型)───────────────────────────────
// 關係列/裸身分/sheet/field/啟動常數是模型的機械零件,不是使用者的「一筆知識」。
// caller 沒指定 entry_type 時預設排除,免得 owner-scoped 列表被 content=NULL 的關係列灌爆;
// caller 明白指定 entry_type(含指定成機制型別)→ 尊重他要的,不攔。
// 判「是不是關係列」認指標欄(src_id IS NOT NULL),不認 entry_type 標記——
// entry_type 在 v7 §7 白名單終局會整欄消失,這條謂詞到時只剩前半。
const NOT_MACHINERY_PREDICATE =
"(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise<ListEntriesResult> {
const conds: string[] = [];
const params: unknown[] = [];
if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); }
else { conds.push(NOT_MACHINERY_PREDICATE); }
if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); }
if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); }
if (f.page_name) { conds.push('page_name = ?'); params.push(f.page_name); }
@@ -131,7 +141,14 @@ export async function updateEntry(db: D1Database, id: string, patch: UpdateEntry
}
export async function deleteEntry(db: D1Database, id: string): Promise<void> {
// 舊世界靠 FKentry_values.entry_id REFERENCES entries)擋「刪掉還被 record 指著的
// entry」;新模型(0007)關係列的 dst_id 沒有 FK → 這條不變量改由牆自己保,
// 否則會產出指向不存在 id 的孤兒關係列(孤兒巡檢見 relation-orphans.ts)。
const ref = await db.prepare('SELECT id FROM entries WHERE dst_id = ? LIMIT 1').bind(id).first<{ id: string }>();
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} — delete the record (or its slot) first`);
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
// 這顆 entry 自己發出的關係列(它是 record 身分時的格子與歸屬)失去意義,一併拆
await db.prepare('DELETE FROM entries WHERE src_id = ?').bind(id).run();
}
/**
@@ -583,6 +600,9 @@ export async function searchEntries(
const params: unknown[] = [...plan.scoreParams];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
// 機制列隔離(0007):沒指定 entry_type 時,sheet/field 這類有 content 的機制節點
// 不進關鍵字搜尋(搜 "status" 不該撈回一顆欄位謂詞 entry);關係列 content=NULL 本就 0 分。
else { conds.push(NOT_MACHINERY_PREDICATE); }
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
+118 -50
View File
@@ -38,6 +38,13 @@ export interface LibraryMapRow {
narrative: string | null;
top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用)
triplet_count: number;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」——
// entriesingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AIMCP 渲染層)分得清
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
// (見本檔 liveEntryCountsByLibrary 的計數口徑)。
entry_count: number;
updated_at: number;
}
@@ -50,6 +57,7 @@ export interface LibraryMapDetail {
relation_profile: RelationStat[];
bridges: Bridge[];
triplet_count: number;
entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解)
commit_hash: string | null;
status: string;
updated_at: number;
@@ -105,36 +113,45 @@ export async function ensureTripletLibrarySlot(db: D1Database, tripletTemplate:
// ---- 聚合 SQLM2 recompute ----
// recordentry_values 縱表)→ 一列一 triplet 的 pivot。MAX(CASE …) 是 SQLite 縱轉橫慣用法;
// owner filter 直接下在 pivot 前(record 的所有 slot entries 同 ownercreateRecord 寫入時同值)。
// record → 一列一 triplet 的 pivot0007 樹狀 record 模型:格子=關係列)。
// b=歸屬關係列(rel=sys_belongs, dst=template 的 sheet entry)=record 成員名單;
// r=該 record 的格子關係列;v=格子指到的內容 entry。欄位謂詞 id 決定性衍生
// fld_<template>_<slot>,與 0007 遷移、record-crud fieldEntryId 同一條規則)⇒
// 直接用字串串接比對,不必 JOIN field entry。MAX(CASE …) 縱轉橫慣用法照舊;
// owner filter 下在歸屬關係列的 owner_idcreateRecord 寫入時同值,0007 遷移同一推導)。
// 參數簽名與舊版逐字相同:[template_id, owner?]。
function tripletPivotSql(ownerFiltered: boolean): string {
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject,
MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object,
MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`;
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`;
}
// library_map 自身 record 的 pivot(讀端+supersede 查找共用)。
// library_map 自身 record 的 pivot(讀端+supersede 查找共用;同上 0007 形狀)。
function mapPivotSql(ownerFiltered: boolean): string {
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative,
MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities,
MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile,
MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges,
MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count,
MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(ev.created_at) AS ts
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`;
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(r.created_at) AS ts
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`;
}
function parseJsonArray<T>(raw: string | null | undefined): T[] {
@@ -285,6 +302,10 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
superseded.push(row.rid);
}
// entry_countArcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的
// 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
return {
map: {
record_id: blockEntry.id,
@@ -295,6 +316,7 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
relation_profile: relationProfile,
bridges,
triplet_count: tripletCount,
entry_count: entryCount,
commit_hash: input.commit_hash ?? null,
status: 'active',
updated_at: blockEntry.created_at,
@@ -347,18 +369,49 @@ async function liveTripletCountsByLibrary(
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id
SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id
) AS tr
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
WHERE COALESCE(tr.status, 'active') = 'active'
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數
//Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳:
// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣
// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。
//
// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」):
// - entry_type='value'record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record
// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。
// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史
// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。
async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [owner_id] : [];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(*) AS n
FROM entries
WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value'
AND src_id IS NULL
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
@@ -388,10 +441,12 @@ async function knownLibraryNames(db: D1Database, owner_id?: string): Promise<Lib
const libParams: unknown[] = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
const libRows = await db
.prepare(
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`,
`SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`,
)
.bind(...libParams)
.all<{ name: string | null }>();
@@ -453,20 +508,26 @@ interface MapPivotRow {
ts: number;
}
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count),MCP instructions
// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_countentry_count),MCP
// instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→
// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容
// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary
// 註解),讀端不該把其中一個的 0 當成另一個的答案。
export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return [];
const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id];
const res = await db
.prepare(
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>();
const [res, entryCounts] = await Promise.all([
db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>(),
liveEntryCountsByLibrary(db, owner_id),
]);
// 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
const byLib = new Map<string, LibraryMapRow>();
for (const r of res.results ?? []) {
@@ -476,6 +537,7 @@ export async function listLibraryMaps(db: D1Database, owner_id?: string): Promis
narrative: r.narrative || null,
top_entities: parseJsonArray<TopEntity>(r.top_entities).slice(0, 3).map((t) => t.name),
triplet_count: Number(r.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(r.library) ?? 0,
updated_at: r.ts,
});
}
@@ -501,7 +563,12 @@ export async function getLibraryMapDetail(
.first<MapPivotRow>();
if (!row) return null;
// record_idmap block entry idrecompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。
const blockEntry = await getEntry(db, row.rid);
// entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖
// 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。
const [blockEntry, entryCounts] = await Promise.all([
getEntry(db, row.rid),
liveEntryCountsByLibrary(db, owner_id),
]);
return {
record_id: row.rid,
library,
@@ -511,6 +578,7 @@ export async function getLibraryMapDetail(
relation_profile: parseJsonArray<RelationStat>(row.relation_profile),
bridges: parseJsonArray<Bridge>(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(library) ?? 0,
commit_hash: row.commit_hash || null,
status: row.status ?? 'active',
updated_at: row.ts,
+314 -99
View File
@@ -1,5 +1,15 @@
// Template + Record CRUD. A "record" = multiple entries composed via a template's slots.
// Base, D1 only. (Ported clean from KBDB; no vectorize/triplet imports.)
// Template + Record CRUD — 樹狀 record 模型(v7 定稿,2026-08-15 confirm)。
//
// 模型(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
// · record 池中一顆有身分的 entryrecord_id 就是它的 id
// · 一格 一條關係列(src=record、rel=field entry、dst=value entry)——池上型別化指標欄
// · 歸屬 一條關係列(src=record、rel=sys_belongs、dst=sheet
// · 欄位是關係、歸屬也是關係——同一種機制。entry_values 表已拆(0007),
// 它是「關係」的第二套實作(D92:同一件事兩個實作必然漂移)。
//
// 遷移期雙軌(第二刀收):templates 表仍是「欄位定義」的真相源(slots_jsondescription),
// sheetfield entry 是它們在池中的身分;createTemplate/updateTemplate 同步維護兩邊,
// 維護語句全部 INSERT OR IGNORE(決定性 id)⇒ 冪等、可自癒。
import type { Template } from '../types';
import { createEntry } from './entry-crud';
@@ -7,7 +17,46 @@ function uid(prefix: string): string {
return `${prefix}_${crypto.randomUUID()}`;
}
// ---- Templates ----
// ── 啟動常數(0007 seed;一組、極小、只讀)─────────────────────────────
export const SYS_ROOT = 'sys_root'; // 屬於鏈的終點:entry ─屬於→ sys_root 它是 sheet
export const SYS_BELONGS = 'sys_belongs'; // 歸屬謂詞:record ─屬於→ sheet
export const SYS_FIELD_OF = 'sys_field_of';// 欄位名冊謂詞:field ─field_of→ sheet
/** field entry 的決定性 id(0007 遷移與執行期寫入共用同一條衍生規則,兩邊永遠對得上)。 */
export function fieldEntryId(templateId: string, slot: string): string {
return `fld_${templateId}_${slot}`;
}
/** 啟動常數自癒(冪等;空庫或部分遷移的實例第一次寫入時補齊)。 */
async function ensureAnchors(db: D1Database): Promise<void> {
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES
('${SYS_ROOT}', 'root', 'system', NULL),
('${SYS_BELONGS}', 'belongs', 'system', NULL),
('${SYS_FIELD_OF}', 'field_of', 'system', NULL)`,
)
.run();
}
/** 這批 slot 的 field entry 名冊關係(field ─field_of→ sheet)自癒建立(冪等)。 */
async function ensureFieldEntries(db: D1Database, templateId: string, slots: string[]): Promise<void> {
for (const slot of slots) {
const fid = fieldEntryId(templateId, slot);
await db
.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`)
.bind(fid, slot)
.run();
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)`,
)
.bind(`relf_${templateId}_${slot}`, fid, templateId)
.run();
}
}
// ---- Templates(遷移期雙軌:表=欄位定義真相源,池中 sheet/field entry=身分)----
export interface CreateTemplateInput {
name: string;
@@ -23,6 +72,19 @@ export async function createTemplate(db: D1Database, input: CreateTemplateInput)
.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`)
.bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null)
.run();
// 池中身分:sheet entryid 沿用 template id)+ sheet ─屬於→ 保留根 + 欄位名冊
await ensureAnchors(db);
await db
.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`)
.bind(id, input.name)
.run();
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')`,
)
.bind(`relb_${id}`, id)
.run();
await ensureFieldEntries(db, id, input.slots);
const row = await getTemplate(db, id);
if (!row) throw new Error('createTemplate: row not found after insert');
return row;
@@ -49,188 +111,341 @@ export async function updateTemplate(db: D1Database, id: string, patch: { descri
if (cols.length === 0) return getTemplate(db, id);
cols.push('updated_at = unixepoch()');
await db.prepare(`UPDATE templates SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
// 新增的 slot 要有 field entry(欄名=關係的謂詞),否則後續寫格子會指到不存在的謂詞
if (patch.slots !== undefined) await ensureFieldEntries(db, id, patch.slots);
return getTemplate(db, id);
}
// ---- Records (entry_values composed by template) ----
// ---- Records(關係列組成;entry_values 已拆)----
export interface CreateRecordInput {
template: string; // template id or name
values: Record<string, string>; // slot_name -> content
values?: Record<string, string>; // slot_name -> content**新建**一筆 entry 當這個 slot 的值)
/**
* slot_name -> **** entry identries entry
* Arcrun#128
* value entry id entry****
*/
entry_ids?: Record<string, string>;
owner_id?: string | null;
record_id?: string;
}
/** 被參照 entry 的 id -> 它現在的 content(回傳值要帶真內容,不是空殼)。 */
type ReferencedContent = Map<string, string | null>;
/**
* entry****
* 1. id FK
* 2. entry_id
* 3. entry
* INSERT
* 90 id D1 100沿
*/
async function loadReferencedEntries(
db: D1Database,
entryIds: Record<string, string>,
recordOwnerId: string | null,
): Promise<ReferencedContent> {
const ids = [...new Set(Object.values(entryIds))];
if (ids.length === 0) return new Map();
const rows: { id: string; content: string | null; owner_id: string | null }[] = [];
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const res = await db
.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => '?').join(',')})`)
.bind(...chunk)
.all<{ id: string; content: string | null; owner_id: string | null }>();
rows.push(...(res.results ?? []));
}
const found = new Map(rows.map((r) => [r.id, r]));
const missing = ids.filter((id) => !found.has(id));
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(', ')}`);
// 歸屬不同 → 擋。owner_id 為 null 的 entry 視為無主/共用(既有資料多半如此),放行。
if (recordOwnerId != null) {
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
if (foreign.length > 0) {
throw new Error(
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(', ')} != ${recordOwnerId}`,
);
}
}
return new Map(rows.map((r) => [r.id, r.content]));
}
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
*/
/** record 的歸屬。新模型直接存在 record 身分 entry 上(不再從 slot entries 推導)。 */
owner_id: string | null;
}
/** record 的歸屬關係(record ─屬於→ sheet,排除 sheet 自己的 ─屬於→ 保留根)。 */
async function recordBelongs(db: D1Database, recordId: string): Promise<{ dst_id: string } | null> {
const row = await db
.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`)
.bind(recordId)
.first<{ dst_id: string }>();
return row ?? null;
}
async function insertCellRelation(
db: D1Database,
recordId: string,
templateId: string,
slot: string,
dstEntryId: string,
ownerId: string | null,
): Promise<void> {
await db
.prepare(
`INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)`,
)
.bind(uid('relv'), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId)
.run();
}
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
const tpl = await getTemplate(db, input.template);
if (!tpl) throw new Error(`template not found: ${input.template}`);
const slots: string[] = JSON.parse(tpl.slots_json);
const recordId = input.record_id ?? uid('rec');
const values = input.values ?? {};
const entryIds = input.entry_ids ?? {};
const refSlots = Object.keys(entryIds);
const ownerId = input.owner_id ?? null;
for (const slot of slots) {
if (!(slot in input.values)) continue;
// 同一個 slot 不准同時給字串又給 id:兩者的意思相反(複製一份 vs 指向既有),不猜。
const both = refSlots.filter((s) => s in values);
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(', ')}`);
// entry_ids 指到 template 沒有的 slot → 報錯(不學 values 那條「靜默略過」——
// 指標設了卻無聲消失是最難查的失敗)。
const unknown = refSlots.filter((s) => !slots.includes(s));
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(', ')}`);
// 全部檢查(存在/歸屬)先跑完再寫,失敗=一列都沒寫。
const referenced = await loadReferencedEntries(db, entryIds, ownerId);
// record 身分:池中一顆 entry。record_id 已是池中既有 entryblock 即 record 慣例,
// 如 library_map 的 map block)→ 那顆 entry 本人就是身分,不另建、不覆蓋。
await db
.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`)
.bind(recordId, ownerId)
.run();
// 歸屬=一條關係(舊 template_id 欄的下場)
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)`,
)
.bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id)
.run();
// 欄位謂詞自癒(決定性 id,冪等)——只補這次真的要寫的 slot
const writtenSlots = slots.filter((s) => s in entryIds || s in values);
await ensureFieldEntries(db, tpl.id, writtenSlots);
for (const slot of writtenSlots) {
if (slot in entryIds) {
// 指向既有 entry:只插一條關係列,不碰內容(這就是指標)。
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
continue;
}
const entry = await createEntry(db, {
content: input.values[slot],
content: values[slot],
entry_type: 'value',
owner_id: input.owner_id ?? null,
owner_id: ownerId,
});
await db
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
.run();
await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
}
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
// 回傳值:舊路徑照舊原樣回 input.values,指標來的 slot 補上那條既有 entry 的現有內容。
const out: Record<string, string> = { ...values };
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? '';
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: ownerId };
}
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
// "Deprecate by flipping a slot value" — base append-only is NOT broken: we change the
// underlying entries.content of the slot's entry, we do not alter table structure / add columns / delete rows.
// - slot already on the record → UPDATE the linked entries.content.
// - slot valid for the record's template but not yet present → create entry + entry_value (idempotent grow).
// - slot not in the template's slots_json → reject (records must stay template-shaped).
// Returns null if the record does not exist.
// Update an existing record's slot values(行為契約與 entry_values 時代一字不變):
// - slot 已有格子 → UPDATE 指到的 entries.content(**只有指標才連動**:所有指著同一顆的都看到新值)
// - slot 在 template 裡但還沒有格子 → 新建 entry + 一條關係列(grow
// - slot 不在 template → reject
// 回 null = record 不存在(沒有歸屬關係)。
export async function updateRecord(
db: D1Database,
recordId: string,
values: Record<string, string>,
): Promise<RecordResult | null> {
// Existing slot → entry_id + template_id for this record.
// JOIN entries 帶回 owner_idgrow 路徑建新 entry 時要沿用 record 既有 owner_id
//portal-auth design §2.2 附帶修復——原本漏帶 → 孤兒 entryowner_id=NULL),
// owner-scoped 查詢(searchByTemplate / searchEntries)看不到該 slot 值)。
const evRes = await db
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null; // record does not exist
const templateId = belongs.dst_id;
// 既有格子:slot(謂詞 entry 的 content)→ 指到的 entry id(重複 slot 允許 → 全部收)
const cellRes = await db
.prepare(
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, 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 = ?`,
`SELECT f.content AS slot_name, r.dst_id AS entry_id
FROM entries r JOIN entries f ON r.rel_id = f.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`,
)
.bind(recordId)
.all<{ slot_name: string; entry_id: string; template_id: string; owner_id: string | null }>();
const evRows = evRes.results ?? [];
if (evRows.length === 0) return null; // record does not exist
.all<{ slot_name: string; entry_id: string }>();
const cells = cellRes.results ?? [];
const slotToEntries = new Map<string, string[]>();
for (const c of cells) {
const list = slotToEntries.get(c.slot_name) ?? [];
list.push(c.entry_id);
slotToEntries.set(c.slot_name, list);
}
const templateId = evRows[0].template_id;
// record 的歸屬=其既有 slot entries 的 owner_idcreateRecord 寫入時同一值)。
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
// record 的歸屬=身分 entry 的 ownergrow 建新 entry 時沿用,防孤兒 entry
const identity = await db.prepare('SELECT owner_id FROM entries WHERE id = ?').bind(recordId).first<{ owner_id: string | null }>();
const recordOwnerId = identity?.owner_id ?? null;
const tpl = await getTemplate(db, templateId);
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
for (const [slot, content] of Object.entries(values)) {
if (!allowed.includes(slot)) {
throw new Error(`slot not in template: ${slot}`);
}
const entryId = slotToEntry.get(slot);
if (entryId) {
// flip the slot value: update the linked entry's content (table structure untouched)
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
const entryIds = slotToEntries.get(slot);
if (entryIds && entryIds.length > 0) {
// flip the slot value: update the linked entry's content(指標連動語意)
for (const entryId of entryIds) {
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
}
} else {
// valid template slot not yet on this record → grow it (create entry + link)
// owner_id 帶 record 既有歸屬(design §2.2 附帶修復,防孤兒 entry)
// valid template slot not yet on this record → growentry 關係列)
await ensureFieldEntries(db, templateId, [slot]);
const entry = await createEntry(db, { content, entry_type: 'value', owner_id: recordOwnerId });
await db
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
.bind(uid('ev'), recordId, templateId, slot, entry.id)
.run();
await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId);
}
}
return getRecord(db, recordId);
}
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null;
const res = await db
.prepare(
`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 = ?`,
`SELECT f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`,
)
.bind(recordId)
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
const rows = res.results ?? [];
if (rows.length === 0) return null;
.all<{ slot: string; content: string }>();
const values: Record<string, string> = {};
for (const r of rows) values[r.slot] = r.content;
// 歸屬取第一個非 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 };
for (const r of res.results ?? []) values[r.slot] = r.content;
const identity = await db.prepare('SELECT owner_id FROM entries WHERE id = ?').bind(recordId).first<{ owner_id: string | null }>();
return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null };
}
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
const tpl = await getTemplate(db, template);
if (!tpl) return [];
// owner_id 過濾在 SQL 做:record 的歸屬存在底層 entries.owner_idcreateRecord 寫入時帶)。
// 給了 owner_id → JOIN entries 限定該 owner(租戶隔離,cypher proxy 強制注入);
// 沒給 → 不限(內部/全域查詢)。先前 `|| true` 是 stub,會洩漏跨租戶資料(2026-06-14 修)。
const cap = Math.min(limit, 500);
// record ids:歸屬關係(rel=屬於, dst=sheet)就是成員名單——(dst_id, rel_id) 索引直達。
// owner 過濾下在歸屬關係列的 owner_idcreateRecord 寫入時同值,0007 遷移同一推導)。
const res = owner_id
? await db
.prepare(
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND e.owner_id = ?
ORDER BY ev.created_at DESC LIMIT ?`,
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
)
.bind(tpl.id, owner_id, cap)
.all<{ record_id: string }>()
: await db
.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`)
.prepare(
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
)
.bind(tpl.id, cap)
.all<{ record_id: string }>();
// 批次撈齊所有 record 的 slot 值(2026-07-18 修 N+1:原本逐筆 getRecord=每筆 1 次 D1
// 往返,100 筆 triplet ≈ 19 秒——graph/總圖/rag_chat 的「查詢 20-30 秒」病根就是這裡)。
// D1 綁定參數上限 100 → id 以 90 一組分批 IN 查詢;輸出保持原排序(created_at DESC)。
const ids = (res.results ?? []).map((r) => r.record_id);
if (ids.length === 0) return [];
// 批次撈齊格子與身分(N+1 教訓照舊:D1 綁定參數上限 100 → 90 一組)。
const byId = new Map<string, RecordResult>();
for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null });
for (let i = 0; i < ids.length; i += 90) {
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, 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; 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: {}, 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;
const [cellRes, identRes] = await Promise.all([
db
.prepare(
`SELECT r.src_id AS record_id, f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'`,
)
.bind(...chunk)
.all<{ record_id: string; slot: string; content: string }>(),
db
.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`)
.bind(...chunk)
.all<{ id: string; owner_id: string | null }>(),
]);
for (const r of cellRes.results ?? []) {
const rec = byId.get(r.record_id);
if (rec) rec.values[r.slot] = r.content;
}
for (const r of identRes.results ?? []) {
const rec = byId.get(r.id);
if (rec) rec.owner_id = r.owner_id;
}
}
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
}
/** 刪除一筆 record:先刪 entry_valuesFK),再刪底層 entries。回 false 表示 record 不存在。 */
/**
* record entry entry
* false record
*
* 🔴 ** entry **Arcrun#128
* entry record
* 🔴 **block record **record_id blocklibrary_map
* entry entry_type 'record' block
*/
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
const evRes = await db
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
const belongs = await recordBelongs(db, recordId);
if (!belongs) return false;
const cellRes = await db
.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`)
.bind(recordId)
.all<{ entry_id: string }>();
const rows = evRes.results ?? [];
if (rows.length === 0) return false;
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
for (const { entry_id } of rows) {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run();
.all<{ dst_id: string }>();
const dsts = (cellRes.results ?? []).map((r) => r.dst_id);
// 這筆 record 發出的所有關係列(格子+歸屬)一次拆掉
await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run();
// 裸身分 entryentry_type='record')才刪;被別的關係指著就留(變回池中普通 entry)
await db
.prepare(
`DELETE FROM entries WHERE id = ?1 AND entry_type = 'record'
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)`,
)
.bind(recordId)
.run();
// 指到的內容 entry:沒有任何關係指著、自己也不發出關係、且不是機制節點 → 才刪
for (const dst of dsts) {
await db
.prepare(
`DELETE FROM entries WHERE id = ?1
AND entry_type NOT IN ('sheet', 'field', 'system')
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)`,
)
.bind(dst)
.run();
}
return true;
}
+54
View File
@@ -0,0 +1,54 @@
// 關係列孤兒巡檢(0007 樹狀 record 模型的配套,v7 §5 明訂「要寫成明確的巡檢項,不能默認」)。
//
// 為什麼要有這支(不是順手加的):舊模型的孤兒偵測靠 entry_values 的外鍵形狀——
// 2026-06-24 那次 11 萬筆誤寫的清理就是拿 FK LEFT JOIN 找斷鏈
// system-dev/docs/5-records/2026-06-24-official-kbdb-cleanup-leo-misdelete.md)。
// 0007 拆掉 entry_values 後,關係列的 src/rel/dst 指標**沒有 FK**(同一張表指自己,
// SQLite 自參照 FK 會把插入順序綁死,且 D1 逐句執行無交易可 defer)⇒ 斷鏈不再被
// 資料庫擋下,改由本巡檢主動找:**孤兒=指標指向不存在 id 的關係列**。
// 掃法與舊 FK 形狀同款(LEFT JOIN 找斷鏈),v7 押的「形狀可承接」在這裡兌現。
//
// 什麼情況會產生孤兒(誠實列):
// 1. DELETE /entries/:id 的舊資料時代殘骸(新 deleteEntry 已擋 dst 被指著的刪除)
// 2. 遷移時 template 已被刪但 entry_values 還留著格子(0007 保險網補 field entry
// 但 dst=template 的名冊關係可能指到不存在的 sheet)
// 3. 未來任何繞過牆的直接寫入(本巡檢就是抓它們的網)
import type { D1Database } from '@cloudflare/workers-types';
export interface RelationOrphan {
relation_id: string;
role: 'src' | 'rel' | 'dst';
missing_id: string;
}
export interface RelationOrphanReport {
orphans: RelationOrphan[];
count: number; // 本次回報筆數(受 limit 截斷)
truncated: boolean; // true = 還有更多,加大 limit 或先清這批再掃
}
export async function scanRelationOrphans(db: D1Database, limit = 200): Promise<RelationOrphanReport> {
const cap = Math.min(Math.max(limit, 1), 1000);
const res = await db
.prepare(
`SELECT relation_id, role, missing_id FROM (
SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id
FROM entries r LEFT JOIN entries t ON t.id = r.src_id
WHERE r.src_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'rel', r.rel_id
FROM entries r LEFT JOIN entries t ON t.id = r.rel_id
WHERE r.rel_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'dst', r.dst_id
FROM entries r LEFT JOIN entries t ON t.id = r.dst_id
WHERE r.dst_id IS NOT NULL AND t.id IS NULL
) LIMIT ?`,
)
.bind(cap + 1)
.all<RelationOrphan>();
const rows = res.results ?? [];
const truncated = rows.length > cap;
const orphans = truncated ? rows.slice(0, cap) : rows;
return { orphans, count: orphans.length, truncated };
}
+10
View File
@@ -39,6 +39,16 @@ app.use('*', async (c, next) => {
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
app.get('/health', (c) => c.json({ ok: true }));
// 關係列孤兒巡檢(0007 配套;v7 §5「新模型的孤兒=指標指向不存在 id 的關係列」,
// 舊 entry_values FK 形狀的承接——2026-06-24 清理事故用的就是同款 LEFT JOIN 斷鏈掃描)。
// 唯讀,不自動清:清哪些要人裁(同 embed 孤兒清理的慣例,發現與處置分開)。
app.get('/maintenance/relation-orphans', async (c) => {
const { scanRelationOrphans } = await import('./actions/relation-orphans');
const limit = Number(c.req.query('limit') ?? '200');
const report = await scanRelationOrphans(c.env.DB, Number.isFinite(limit) ? limit : 200);
return c.json({ success: true, ...report });
});
app.route('/entries', entryRoutes);
app.route('/templates', templateRoutes);
app.route('/records', recordRoutes);
+27 -19
View File
@@ -5,11 +5,25 @@ import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord }
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
// POST /records — { template, values:{slot:content}, owner_id? }
// POST /records — { template, values:{slot:content}?, entry_ids:{slot:既有entry_id}?, owner_id? }
//
// values = 給字串 → **新建**一筆 entry 當這個 slot 的值(原本就有的路,行為不變)
// entry_ids = 給既有 entry 的 id → **指向**水池裡那條 entry,不複製(Arcrun#128 的外鍵)
// 兩者可混用;至少要有一個。**只給 entry_ids 是合法的**——例如一則 wiki 的五段在 KBDB 裡
// 本來就已經是五筆既有 entry,建 record 只是替它們取名字,不該再生出任何新 entry。
const isStringMap = (v: unknown): boolean =>
!!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v as object).every((x) => typeof x === 'string');
recordRoutes.post('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.template || !body.values) {
return c.json({ success: false, error: 'template and values required' }, 400);
if (!body || !body.template || (!body.values && !body.entry_ids)) {
return c.json({ success: false, error: 'template and values (or entry_ids) required' }, 400);
}
if (body.values !== undefined && !isStringMap(body.values)) {
return c.json({ success: false, error: 'values must be an object of {slot: string}' }, 400);
}
if (body.entry_ids !== undefined && !isStringMap(body.entry_ids)) {
return c.json({ success: false, error: 'entry_ids must be an object of {slot: entry_id}' }, 400);
}
try {
const rec = await createRecord(c.env.DB, body);
@@ -22,27 +36,21 @@ recordRoutes.post('/', async (c) => {
// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。
// t1422026-07-29):政府驗收——顯示每個庫整理出幾條知識關聯。
// 計法:依 triplet 型 record 的 'library' slot 值分組計數。無 library slot 的舊三元組歸 general。
// 使用子查詢先取 distinct triplet record IDs(針對 owner),再 LEFT JOIN library slot
// 避免 N+1(全部一次 SQL 完成,不逐筆 getRecord)。
// 0007 之後:record 成員名單=歸屬關係列(rel=sys_belongs, dst=triplet sheet
// library 格子=rel 為決定性欄位謂詞 idfld_<template>_library)的關係列——
// 一次 SQL 完成(無 N+1),owner 過濾下在歸屬關係列的 owner_id。
recordRoutes.get('/triplet-stats', async (c) => {
const owner = c.req.query('owner_id') || '';
// 子查詢:找到屬於這個 owner 的所有 triplet recordsLEFT JOIN library slot 取庫名
const rows = await c.env.DB.prepare(
`SELECT
COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
COALESCE(NULLIF(lib_v.content, ''), 'general') AS library,
COUNT(*) AS triplet_count
FROM (
SELECT DISTINCT ev.record_id
FROM entry_values ev
JOIN templates t ON ev.template_id = t.id
JOIN entries e ON ev.entry_id = e.id
WHERE t.name = 'triplet'
AND (?1 = '' OR e.owner_id = ?1)
) AS tr
LEFT JOIN entry_values lev
ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
FROM entries b
JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet'
LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library')
LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id
WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1)
GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general')
ORDER BY library`,
)
.bind(owner)
+15 -1
View File
@@ -49,7 +49,16 @@ export type EntryType =
| 'execution_log'
| 'execution_log_usage'
| 'embed_backfill_usage'
| 'kbdb_maintenance_usage';
| 'kbdb_maintenance_usage'
// 樹狀 record 模型(0007v7 定稿 2026-08-15):機制節點與關係列。
// 誠實註記:entry_type 在 v7 §7 的白名單終局裡會整欄消失(型別只能由關係推導),
// 這裡先沿用它做遷移期的機制列標記——程式邏輯一律以指標欄(src_id IS NOT NULL)判斷
// 「是不是關係列」,不依賴這個標記。
| 'relation' // 一列關係:src ─rel→ dst(池上型別化指標欄)
| 'record' // record 的裸身分 entryblock 即 record 時身分是那顆 block,不是這個型別)
| 'sheet' // 一張表(template 在池中的身分,id 沿用 template id
| 'field' // 一個欄位(欄名=關係的謂詞)
| 'system'; // 啟動常數(sys_root / sys_belongs / sys_field_of
export interface Entry {
id: string;
@@ -65,6 +74,11 @@ export interface Entry {
is_embedded: number;
confidence: number | null;
metadata_json: string | null;
// 關係的物理載體(0007):池上型別化指標欄。內容 entry 三欄全 NULL
// 關係列三欄全非 NULL(src ─rel→ dst)。紅線:指標永不塞回 content/JSOND91)。
src_id: string | null;
rel_id: string | null;
dst_id: string | null;
created_at: number;
updated_at: number;
}

Some files were not shown because too many files have changed in this diff Show More