Compare commits

..

82 Commits

Author SHA1 Message Date
uncle6me-web c4cee35adb feat(auth): 認證與資料分離——搬動知識資料時登入不再跟著壞掉(D61 / arcrun-rag#55)
leo 2026-08-10 下令:「登入認證資料要分離⋯⋯就算只有我一個人存在單獨的 json 檔也好,
它不能被改資料庫的連結導致無法登入。」「昨天不能登入 portal,今天不能登入 mcp,
這根本就是一個問題。」

病:portal 帳號住 KBDB(owner_id = {CONSOLE_TENANT}::portal),console 管理員帳密住
SESSIONS_KV。兩者都靠 binding 指過去,重裝/遷移一定會被重新指一次 ⇒ 保險箱的鑰匙
放在保險箱裡。2026-08-09 leo 資料一個位元組都沒動,卻被鎖在門外。

修:認證搬到 CF Workers per-script Secrets(掛在 script 上,與 bindings 兩套資源,
重部不會洗掉;journeys/gemini-key-lost-on-reinstall.md 與 installer worker.js:1148 皆有實證)。
- 新增 lib/portal-auth-store.ts:自足的 JSON,讀取零網路呼叫,>4.6KB 自動溢位分片
- portal.ts 的帳號讀寫全部改走它;KBDB 只留為舊實例的回退讀路徑,登入成功順手搬過去
- console-auth.ts 的第二份認證資料同樣搬離 KV
- 不牴觸 D38:KBDB 三張核心表不增不減,本案是把東西搬出去
- 沿用 credentials.ts 既有的 putWorkerSecret/deleteWorkerSecret,不另造第二套寫入路徑(D36)

明顯失敗(把 #10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
- 「這台實例讀不到任何登入資料」回 503 + code=auth_store_empty,且**不計入 5 次鎖定**
  (08-09 leo 就是被系統自己的誤判鎖了 15 分鐘)
- /console/setup 遇既有帳號改說「你剛才輸入的密碼沒有被採用」,不再只說「已設定過」
- /health 與 /console/auth-status 吐 auth_store 狀態(住哪、寫不寫得進去)

stage 實測撞到並修掉的坑:改 secret 會產生 worker 新版本,既有 isolate 讀到的還是舊 env
⇒ 「建好帳號立刻登入」有 15 秒以上 401,還被算進鎖定。加一層短 TTL 的 KV 加速器
(非真相源,只在 secret 查不到/密碼對不上時問一次),換 KV 不影響不變量。

驗收:stage(youlin)把知識資料庫換成另一顆空的 + 換租戶代號 + SESSIONS_KV 換成空的,
三樣一起換之後 portal / MCP /authorize / console 三條登入路徑仍全綠(複跑 3 次)。
對照組(舊版程式碼同樣換庫):登入回「email 或密碼錯誤」,5 次後鎖 15 分鐘。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:13:37 +08:00
uncle6me-web ae81d22775 fix(harness): 世代閘的 ON_TRUE 判準過時,反過來擋下正確教材
引擎自 2026-08-01 起已支援條件邊(cypher-executor/src/graph-executor.ts
case 'ON_TRUE'/'ON_FALSE'/'ON_BRANCH',VALID_EDGE_TYPES 亦已列入;31 個
cypher-executor 測試全過)。registry/skills/write_intent_workflow.md(單一
真相源)也已在同日更正為教 ON_TRUE/ON_FALSE/ON_BRANCH 是合法邊。

但 cli/scripts/check-harness-generation.mjs 的世代閘還停在舊世代判準:
只要 SKILL.md 出現正面示範的 ON_TRUE 就擋——這道閘本身才是落後的一方,
把已經寫對的教材當錯誤攔下,害乾淨 `npm run build` 必敗。

同源的過時內容還藏在三個手動維護的 harness 原始檔(非腳本產物):
CLAUDE.block.md/commands/arcrun.md/hooks/arcrun-guard.sh 都寫著
「引擎沒有條件邊」「沒有 ON_TRUE/ON_FALSE/ON_FAILURE」,一併更正。

真正不存在的邊是 ON_FAILURE(VALID_EDGE_TYPES 只有 ON_FAIL),把 mustNot
判準從 ON_TRUE 換成 ON_FAILURE,並新增 must 規則要求 ON_TRUE 必須出現,
防止教材日後又被改回「條件邊不存在」的舊世代說法。

skills/arcrun-mindset/SKILL.md 是由 registry 於建置期重建的產物
(build-harness-skill.mjs),本次改動只跑 `npm run build:harness`
重建、不手改。

驗證:故意把 SKILL.md 的 ON_FAILURE 改成正面示範,確認閘仍會擋下
(exit 1),還原後 `npm run build` 連跑兩次皆全綠且冪等(SKILL.md
md5 不變)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:03:43 +08:00
uncle6me-web 9740794050 docs(3-specs): 立案 Arcrun App ↔ Portal 掛載協定 v0 提案(Leo/Arcrun#82)
設計全文住在票上,本檔只留指針+影響分析,等 leo confirm。
病根實查:Portal「有哪些頁」在單檔 HTML 裡寫了四遍,出貨時整包內嵌成
單檔 worker,安裝器只會整顆換掉、無任何掛載點概念 ⇒ 加一個能力=改核心。

未 confirm 前不開新 SDD、不動功能程式碼(現行 active=workflow-discovery)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:02:08 +08:00
uncle6me-web 453dec60b1 fix(scripts): template 來源改指活帳號,404 不再靜默寫入垃圾內容
scripts/install.sh 與 scripts/update.sh 的更新來源指向已被 GitHub suspend 的
uncle6me-web 帳號,實測全部 404 ⇒ 自動更新靜默失效。改成與 system-dev-template
本體、以及本 repo 現行世代 system-dev/scripts/ 一致的正解:預設公開 GitHub
youlinhsieh/system-dev-template,可用 TEMPLATE_SOURCE 環境變數覆寫,不改檔。

同時補上 looks_like_error_page 判斷(沿用 template repo 已修好的寫法):curl
對 404 常回傳非空的錯誤頁內容,只判斷「非空」會把它當成正常檔案寫入且不報錯。
現在錯誤頁會被偵測、檔案不寫入、FAILED 清單於結尾列出、腳本以非零狀態碼結束。

實測:
- 修正後來源多個路徑回真實 HTTP 200(CLAUDE.md/VERSION/self-update 兩支腳本)。
- 刻意把 TEMPLATE_SOURCE 指回死帳號重跑兩支腳本:全部項目進 FAILED 清單、
  無任何檔案被錯誤頁污染、exit code 1。
- 兩支腳本 bash -n 語法檢查通過。

已知缺口(不在本次範圍,留待另決):template repo 內部檔案佈局已從
.claude/wiki/、docs/ 搬到 system-dev/ 前綴,這兩支舊世代腳本仍用舊路徑,
其中一部分檔案(wiki 範本、docs/README、SDD 範本等)在新帳號上仍 404——
現在會清楚回報失敗而不是靜默吞掉,但尚未逐一改點新路徑。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:54:01 +08:00
uncle6me-web bfc98fe41a 官網與 npm 上的 GitHub 連結指向已停用帳號,全是 404(arcrun-rag#41)
延續同一次盤查(判準:素不相識的人點進來會不會被帶到到不了的地方)。
這批比 README 更顯眼——是 arcrun.dev 官網上那顆「GitHub」鈕,以及 npm 套件頁的
Repository 欄。逐一 curl 實測全部 404:

- landing(官網三個檔五處):github.com/richblack/arcrun → 404(帳號已 suspend)
  → github.com/youlinhsieh/Arcrun(200)
  其中 integrations 頁還指 richblack/arcrun/blob/main/CONTRIBUTING.md =雙重死
  (帳號沒了,而且這個 repo 從來就只有 CONTRIBUTING-components.md)
  → youlinhsieh/Arcrun/blob/main/CONTRIBUTING-components.md(實測 200)
- cli/package.json 的 repository.url:github.com/uncle6me-web/Arcrun.git → 404
  (同樣是 suspend 掉的舊帳號)→ youlinhsieh/Arcrun。這欄會直接顯示在
  npmjs.com/package/arcrun 的 Repository 連結上,裝了 CLI 的人就是從那裡點過來。

只換字串,沒有邏輯變動;package.json 已驗證仍是合法 JSON。

⚠️ 同一批掃出但**本次未動**(behavior-affecting,另案處理):
scripts/install.sh:29 與 scripts/update.sh:26 的 template 來源仍指
raw.githubusercontent.com/uncle6me-web/… =實測 404 ⇒ 自動更新靜默失效。
這正是 agent-memory 記過「uncle6me-web 曾害 template update.sh 自動更新靜默
死亡」的同一顆雷,修在 template repo 卻沒修到 Arcrun 這份 copy。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:28:05 +08:00
uncle6me-web be9b92eb28 README:四個對外連結修掉,別再把點進來的人帶到死地方(arcrun-rag#41)
leo 08-09:「整個 github 的 readme 都是過時的,如果用戶真的連過來會被誤導」。
逐一 curl 實測,不靠假設:

- Arcrun RAG 連結指 git.uncle6.me/Leo/arcrun-rag = **實測 404**(那顆是 private;
  同主機的 Leo/Arcrun 反而匿名 200 ⇒ 判準是逐一實測,不是「Gitea 一律 private」)
  → 改指公開鏡像 github.com/youlinhsieh/arcrun-rag(200)
- 致謝的 @richblack → github.com/richblack **實測 404**(帳號已 suspend),
  改 @youlinhsieh(200)
- 結尾 [CONTRIBUTING.md] → 檔案不存在,線上實測 raw 404;
  repo 裡真正有的是 CONTRIBUTING-components.md(線上 200)
- 「給 AI 操盤手:開始前讀 .claude/rules/06-mindset.md」→ `.claude` 整個在公開
  排除清單裡,公開 repo 根本沒這個檔 ⇒ 對外=指了個不存在的路。改指 llms.txt
  (公開、線上 200,第 23-26 行就是那套世界觀),並說明裝 harness 後才會有 Skill。

另把 08-08 留的內部說明 HTML 註解換成對外講得通的一句話:目前沒有公開試玩站,
想看產出去看 arcrun-rag-demo-knowledge 公開鏡像(實測 200)。註解寫給自己人看,
但它躺在對外 README 的原始碼裡。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:25:42 +08:00
uncle6me-web 19c82df05f fix(portal): 管理員忘記密碼自救援出口(arcrun-rag#25)
唯一 admin 忘記 portal 密碼就永久卡死:requirePortalAdmin 系列端點全部要
先有 portal session 才進得去,bootstrap 又只能跑一次——登入頁只會叫他
「聯絡管理員」,而他自己就是管理員,沒有下一步。

新增 POST /portal/admin/recover-password,複用 bootstrap 已在用的 console
owner session 當人閘(與 portal 密碼完全獨立存放的另一組帳密)。畫面入口:
/console → 設定 → 「Portal 帳號密碼救援」;/portal 登入頁加一行連結指過去。

本機真瀏覽器 E2E 驗證(wrangler dev 18787/18788 + 本機靜態伺服,真的走一輪
forgot-password 狀態):first-time setup 建帳號 → 故意打錯密碼確認鎖死
(email 或密碼錯誤)→ 點連結進 /console → 用 console 密碼登入 → 設定頁輸入
portal email → 產生新密碼 BPq2Rs4p7dBWMd6d → 回 /portal 用新密碼登入成功。

cypher-executor 4 個新測試 + 既有 59/60 綠(唯一失敗是既有 pre-existing
/portal HTML 殼 404,與本次無關,git stash 驗證過)。
2026-08-09 15:06:17 +08:00
uncle6me-web 23d36b311a portal 設定頁補顯示 MCP 連接網址(arcrun-rag#7)
封測者原話:「說明叫我把 MCP 加進 claude.ai connector,但我找不到網址」。文件一直寫
「登入 portal → 設定頁,那裡可以直接複製」,但畫面上從沒真的顯示過(grep 0 命中),
用戶照文件走一定撲空。

設定頁新增「接上你的 AI(MCP)」面板:純前端把 apiBase 的 worker 名字從
arcrun-cypher-executor 換成 arcrun-mcp(同一顆自架帳號 workers.dev 子網域,對齊
cli/src/lib/deploy.ts:386-392 部署時的組法),不符形狀就誠實顯示「尚未偵測到」,
不亂猜連不到的網址。複製按鈕從既有 copyOriginUrl 拆出共用 copyText(btn, url)。

端到端本機瀏覽器實測:真 wrangler dev(kbdb+cypher-executor local D1)+ 真登入流程
(console setup → bootstrap admin → portal login)走到設定頁,面板正確顯示/隱藏;
用貼近真實自架形狀的假網址驗算,結果與 deploy.ts 的組法逐字相同;複製鈕點擊行為與
既有、未改動的 st-copy-url 按鈕在同一自動化環境下一致(clipboard-write 是瀏覽器自動化
環境限制,非本次改動的迴歸)。詳細記錄見 system-dev/docs/3-specs/portal-auth/tasks.md t218。

未部署(紅線:只 commit+push,未動任何 Cloudflare 實例)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:23:56 +08:00
uncle6me-web ebd4bf5d97 wiki: 記執行紀錄保留期 UI 補完(arcrun-rag#21)
status.md 補一段,供下個 session 接關不用重查。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:30:00 +08:00
uncle6me-web 889b70b8f9 feat(portal): 執行紀錄保留期 UI(P7 補前端,arcrun-rag#21)
後端(4ca23c2)已提供 GET/PUT /portal/admin/execution-log-retention,
但管理頁完全沒有對應介面——leo 只能自己 curl。本次補管理頁「執行紀錄保留期」
卡片:顯示目前保留天數、可改天數、可勾「不刪除」(企業稽核)。純薄殼,
零業務邏輯,呼叫既有端點(rule 07 薄殼原則)。

驗證(本機真瀏覽器 E2E,非 curl/非讀原始碼):
本機起兩個真 wrangler dev(kbdb:18787/cypher-executor:18788,local D1+KV,
真的套用 migrations 0001-0006)+本機靜態伺服 console-ui/public(18790,
config.js 指向本機 apiBase)+UI_ORIGINS 加白名單解 CORS。瀏覽器走真實
「首次設定」流程建帳號、登入、進管理頁:
- 預設值:保留天數顯示 90(無資料時退回預設,非空白)
- 改 45 天 → 儲存 → 畫面即時更新「目前設定:保留 45 天」→ reload 頁面仍是 45
- 勾「不刪除」→ 儲存 → 天數輸入框停用、清空 → 顯示「不刪除(企業稽核)」
  → reload 仍是不刪除
- 改回 90 天 → 儲存 → reload 仍是 90(雙向都驗過)
- 全程 Network 面板每筆 GET/PUT 皆 200;console 除了測試前置階段的舊
  bootstrap-conflict 雜訊外,無新增紅字

發現但沒動的問題:console-ui 本身沒有本機可跑的 dev/test 腳本
(package.json 只有 deploy/verify),E2E 用的是手工起 wrangler dev +
python http.server 兜出來的,建議之後補一支 `npm run preview:local`
方便下次驗證不用重找路。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:28:36 +08:00
uncle6me-web 21be6d19f7 把 .dev.vars 加進 .gitignore——本機密鑰檔原本完全沒被擋
實查:cypher-executor/.dev.vars(3 行)與 kbdb/.dev.vars(1 行)都是 untracked
且 git check-ignore 零命中,任何人一次 git add -A 就會把金鑰推上 Gitea。
歷史上沒有被 commit 過,所以是純預防、不需要清歷史。

對齊 D36「金鑰只有一個家」:真身不落在 repo 上,不是靠自律,是拿不到。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:24:24 +08:00
uncle6me-web 6846d6ddae fix(semantic): 故障照實說是故障——不再把壞掉說成「沒開通」(leo 2026-08-09 直令)
一、文案(portal/console/kbdb hint):語意搜尋是一安裝就提供的功能,
   降級=故障。橫幅改「語意搜尋目前故障/我們的問題/你不用做任何事」,
   拿掉「還沒開通、想開通請匯出診斷檔」這種要使用者申請開通的假框架。
   kbdb 降級回應加 degraded_reason(module_off / embed_query_failed)。

二、查詢向量化失敗不再偽裝成空結果(leo 點名的謊):
   semanticSearch 舊行為「AI 額度用完 → 回 []」會讓使用者以為
   自己的知識庫裡沒有這筆資料。改丟 EmbedQueryFailedError,
   route 誠實降級 keyword+照實告知是暫時故障。

三、源頭機制(裝好的實例為什麼會失去語意搜尋):
   - acr update:kbdb_embed 判斷 ===true → !==false。config 缺欄位時
     redeploy 會把 [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy
     整份覆蓋),一台正常實例就此壞掉。init 預設同步翻成 [Y/n]。
   -(另 repo)deploy-all.mjs ensureVectorizeIndex 失敗改致命中止。

四、順手自癒:孤兒向量/下架殘影搜尋時背景清除;空結果且 pending>0
   背景 backfill;no_index 拆「故障」vs「還沒有資料」兩態。

測試:kbdb 146/146(新增 degraded 6 案+selftest 1 案);cli 10/10;
瀏覽器端到端兩種故障畫面實測(local wrangler dev+portal 真登入)。
無 SDD 對應:leo 直令修故障(同 08-07 檢修孔前例的人閘直接授權路徑)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 02:05:12 +08:00
uncle6me-web 8eb10049b8 P8:節點輸出 KV 寫入只服務 PIPE 讀者——拆掉比 neurons 更短的隱形短板
leo 08-09「不需要換模型,要調整每個 CF 數字搭配」。盤點發現真正最短的板不是
Workers AI neurons(119 檔/日),是 EXEC_CONTEXT KV:每節點(含 FOREACH 每圈)
put 一次、rag 工作流一張卡 15 次,但唯一讀點是 PIPE 邊——rag 系全無 PIPE 邊,
15 次全是白燒 ⇒ KV 1,000/日 ÷ 15 ≈ 66 檔/日,兩條路(免金鑰/Gemini)都被卡。

修法=寫入前檢查「節點有 PIPE 出邊」。PIPE 工作流與 resume 路徑行為不變。
實測(youlin stage):修前同構卡留 6 node key(15 put)→ 修後零 key,
blocks/triplets 照常寫入。單元測試鎖住兩側行為。
另復原 08-08 重部誤拔的 [ai] binding(extract 501→200,KEEP_AI=true)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:26:16 +08:00
uncle6me-web 831cb62d2e feat(portal): 拔掉雲端「疑難排解」匯出按鈕,改導向本機小幫手(t213 收尾)
leo 08-08:「雲端那個要刪掉?不刪用戶搞不清楚要去哪裏下載」;08-09 追認「通知
完畢可以刪除」——封測者已被通知去更新到有地端匯出的版本(arcrun-app「版本與
更新」頁的疑難排解卡,見 products/arcrun-rag commit 9f1ca58 起),放行條件已滿足。

三處改動:
1. 設定頁「疑難排解」面板:移除 #st-diag-export 按鈕與 #st-diag-status,
   改成純文字指路到電腦上的 Arcrun App。
2. 對應的前端 JS 點擊處理(打 GET /portal/data/diagnostics 的那段 IIFE)整段刪除
   ——按鈕已不存在,留著是死代碼。
3. 語意搜尋降級橫幅(se-banner):原本也教用戶「到設定→疑難排解匯出診斷檔」,
   同步改成指向本機小幫手,否則使用者會照著走進一個不存在的按鈕。

GET /portal/data/diagnostics 端點本身未刪(無害、已無任何 UI 呼叫),純粹清路標
不動後端;已知例外——同步小幫手完全連不上、且封測者是老版本沒有本機匯出時,
會真的無路可走,判斷為可接受(他們已被個別通知升級,且雲端 portal 仍能用文字
指路,不是把人晾在原地不給說明)。

驗證:本機起 static server(config.js 指向 dummy apiBase)+瀏覽器 JS 強制顯示
setting/search view,實際截圖確認按鈕消失、#st-diag-export/#st-diag-status 在
DOM 裡是 null、文案渲染正確無破版;console 只有 dummy apiBase 連不上的預期錯誤,
無語法/參照錯誤。cypher-executor vitest(portal-data/admin/auth)114 通過、2 個
既有失敗(HTML 殼 404,改動前後一致,非本次影響——用 git stash 前後對照過)。

未部署(紅線:本輪只到本機驗過+commit+push gitea,prod 需 leo 開閘)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:09:10 +08:00
uncle6me-web 894d9abeb1 Revert "P8:/portal/daemon/extract 萃取模型 scout → qwen3-30b(真筆記實測:品質不降、額度天花板翻倍)"
This reverts commit aa6b899276.
2026-08-09 00:51:41 +08:00
uncle6me-web 3447efc94e docs(mcp): list_recent_executions 說明文字跟上 P7(保留期),不再寫「無固定保留期」
08-07 事故修復後,這支工具的說明文字曾誠實標「無固定保留期」——那時保留期
確實還沒做。前一個 commit(P7)補上保留期可設定(預設 90 天,可調,可設不刪
除),這支說明文字若不跟著改,會誤導以後讀到它的人以為系統仍然無限保留。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:45:19 +08:00
uncle6me-web 4ca23c256a feat(kbdb): 執行紀錄保留期可設定(P7,leo 08-08 confirm)
背景:08-07 事故修復(60688c3)已把執行紀錄從 KV 搬到 KBDB/D1(entries 表,
API-as-Wall),解掉「稽核資料放在會揮發、被額度打斷的地方」這個結構性錯誤,
也順帶把 Evan 撞到的 1,070 次寫入牆退到 D1 額度層級。但那次修復留了一個誠實
的缺口:MCP list_recent_executions 的說明文字寫著「無固定保留期」——保留期
可設定這件事還沒做。本次補上。

P7 規格(system-dev/docs/3-specs/pending-changes.md「P7」,leo 08-08 confirm):
執行紀錄是稽核資料,預設保留 90 天(3 個月)過期即清;租戶可自訂天數,也可
設「不刪除」(企業稽核,leo:「我願意花很多錢保存,不要刪除」)。

實作(kbdb/src/actions/execution-log.ts,牆內):
- getRetentionDays/setRetentionDays:沿用 execution_log_usage 的 upsert 慣例,
  單一 entries 列/租戶(entry_type='execution_log_retention_config'),零建表。
- cleanupExpiredLogs:分兩段掃——有自訂天數的租戶各自 cutoff;其餘(含無租戶)
  套預設 90 天,排除「不刪除」與已處理過的租戶。每次呼叫界限刪除量
  (CLEANUP_BATCH_LIMIT=500),長期多次呼叫可逐步清完累積量。

路由(kbdb/src/routes/execution-log.ts):GET/PUT /execution-log/retention、
POST /execution-log/cleanup,沿用既有的 Bearer token 全域守衛(fail-closed)。

清理觸發(cypher-executor/src/scheduled.ts):不新增排程基礎設施(wrangler.toml
[triggers] 是受保護檔案)——搭 cypher-executor 既有的每分鐘 cron tick 便車,
固定 UTC 02:30 那一分鐘 fire-and-forget 打一次 KBDB 的 cleanup 端點,一天一次,
不是輪詢。

Portal 薄殼(cypher-executor/src/routes/portal.ts):GET/PUT
/portal/admin/execution-log-retention(role=admin 閘),讓本實例的租戶
(portalTenant)能實際設定保留天數,不只是 KBDB 內部端點。

測試(kbdb/tests/execution-log.test.ts):新增 27 個測試(含原有測試共 27 通過
於本檔),真 SQLite 驗證 cutoff 邏輯、自訂天數隔離、「不刪除」永不清、壞資料
容錯、混合租戶情境、路由層 400/200。測試治具需要「插入指定 created_at 的過期
紀錄」這個正式寫入路徑刻意不開放的能力,做成 kbdb/src/actions/execution-log.ts
內匯出的 testInsert*/testCount* 函式(牆內執行 SQL),測試檔本身零原生 SQL。

量測(不是推論):youlin(yuga3bse)實例上,redeploy 後對 graph_neighbors
webhook 發送 1,200 次併發請求(超過 Evan 實測失敗的 1,070 次)——全部 HTTP 200;
ANALYTICS_KV 的 key 數量在請求前後維持 663 不變,證明新寫入路徑完全不碰 KV,
Evan 撞到的那道牆的成因已被物理移除,不只是延後。

讀取端驗證(真呼叫,非 curl):透過綁定 yuga3bse 的 MCP 連線實際呼叫
arcrun_list_recent_executions(回傳含本次量測寫入的 D1 紀錄)與
arcrun_get_execution_trace(正確回 404 not_found,非崩潰);portal 前端
(https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev/portal)瀏覽器實際載入,
無 console 錯誤、無異常紅色橫幅。

舊資料:KV 裡既有的 stats:* 沿用 60688c3 的既有決定——不搬移,任其依現有 90
天 TTL 自然過期(那是統計快取不是真相源);新的 D1 execution_log 保留政策只
管新資料,不回溯處理。

部署:cypher-executor + kbdb 已手動部署到 youlin(yuga3bse,AI 測試場,leo
08-08 令),未動 prod(uncle6)。本次僅程式碼行為變更、無新增/修改 D1 binding、
無新表——三個既有 D1 binding(CREDENTIALS_DB×2+kbdb DB)維持原樣,未新增第四個。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:43:55 +08:00
uncle6me-web aa6b899276 P8:/portal/daemon/extract 萃取模型 scout → qwen3-30b(真筆記實測:品質不降、額度天花板翻倍)
免金鑰路短板=Workers AI 免費 10,000 neurons/日。leo 真實筆記 8 篇 × 5 模型實測
(arcrun-rag docs/benchmarks/p8-extractor-quality/,usage.neurons 為 CF 原生計量):
scout 84 n/檔(119 檔/日)→ qwen3-30b 43 n/檔(232 檔/日);格式合規 8/8、
三元組 7.6 條全可解析(scout 5.0)。granite 最便宜但 5/8 缺段=實測否決。
聊天 recipe(workers_ai_chat)不動;只 commit 本 hunk,工作區另有 P7 進行中改動未收。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:40:52 +08:00
uncle6me-web 466e56bc2d 落帳:youlin 登入修好(瀏覽器實證)+連線中斷 vs 密碼錯誤是決定性差別
含誠實限制:驗的是程式碼修對了,不是安裝器裝出來的結果。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:15:00 +08:00
uncle6me-web 07cc7f51b5 fix(cypher): 同一台實例的 portal 一律自動放行,不再依賴 UI_ORIGINS 被注入
2026-08-08 事故根因:leo 的 youlin 實例登入整個斷掉,瀏覽器實證
  blocked by CORS policy: No 'Access-Control-Allow-Origin' header
真因=該台 UI_ORIGINS 沒被設。

同一天發生兩次同款:這些變數只有安裝器會注入,任何手動 wrangler deploy
就會漏掉,而漏掉時系統看起來完全正常(worker 上線、200、版本號對),
只有真人點下去才會發現。leo:「這麼危險的問題已經發生 2 次,不可以再有一次。」

⇒ 治法不是「記得注入」,是讓它不需要被注入:portal 與 cypher 是同一個
workers.dev 子網域下的兄弟,位址推導得出來。少一個必須注入的變數,
就少一個會被漏掉的東西。UI_ORIGINS 仍有效(自訂網域用),只是不再是
「登得進去」的前提。

對照:改動前後 tsc 錯誤數同為 7(皆為既有、不在本檔)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:47:32 +08:00
uncle6me-web 42cb1d7aa9 console-ui:把「線上跑的是不是當代的」變成機械判準,並接進部署鏈
leo 2026-08-08:「已經發生過一次這個錯誤,把舊版界面上到 prod,
你要確定不可再犯。」

實測(repo 對線上,純文字資產比對):
  repo   portal/index.html  343,969 bytes
  線上   mira.uncle6.me      82,911 bytes(Songti 12 處,舊金色 serif 品牌)
  線上   pages.dev           82,911 bytes(同上)
而 e730b3f 那版 verify-live 對這兩站**三項檢查全過**——因為它驗的是
組態(apiBase、profile 的 views/home),不是世代。
⇒ 一個網址可以組態完全正確、卻對外展示一套早就被淘汰的介面,
  而所有機械檢查都說它是綠的。這就是要消滅的狀態。

本次落地:

一、世代指紋(targets.mjs)
  逐一取線上/產物的資產(index / portal / console / favicon.svg),
  遮掉本來就該隨部署目標不同的那兩行(VIEWS/HOME),其餘按位元組比對。
  刻意不用關鍵字清單——清單要人維護,而舊世代能無聲上線正是因為沒人記得維護它。
  誠實 trade-off 寫在檔內:repo 改了沒部署就會判紅,那是正確的(那時線上確實不當代)。

二、宣告值真的寫進產物(收掉 e730b3f 標的 WIP)
  deploy.mjs 改為由 targets.mjs 產出 .staging/<目標> 再推:
  config.js 由宣告值即時產生、console 的 VIEWS/HOME 依 profile 覆寫,
  **覆寫沒命中就中止部署**;推之前回頭讀磁碟上那份驗一次(不看腳本印了什麼)。
  public/config.js 刪除——它是產物不是原始碼。

三、修好一道從 08-03 起就在誤判的閘
  t160 的世代閘比對 portal 全文含「登記新庫」即拒部,而 66f1b59(08-03)
  加了一則**說明「已經把它拿掉了」的 HTML 註解** ⇒ 該閘自那天起每次誤判,
  npm run deploy:personal 連續五天推不出去。改成剝掉註解後只看可見內容,
  並降級為輔助(主判準是指紋)。這正是「手工關鍵字閘會腐爛」的實例。

四、讓它在該跑的時候真的被跑到(不再生出沒人記得執行的腳本)
  · deploy.mjs 推完自動回頭驗線上,不過就算本次部署失敗
  · .deploy-state.json 只在線上實測通過後才寫,且不進版控
    (新 checkout 沒紀錄=狀態未知=該被提醒,而不是繼承別人的綠燈)
  · Stop hook 每回合離線比對「手上這一代 vs 最後一次驗過的部署」,
    在要說「做完了」的那一刻出聲(實測 0.096s,不連網)

五、uncle6 邊界寫進工具本身(leo 08-08:「要看範例只在 youlin 網站,不要去碰 uncle6」)
  deploy.targets.json 的 enterprise 標 frozen:deploy 拒絕部署、verify 連抓都不抓。
  目標本身保留不刪——刪掉就變成下一個 AI 眼中「從來沒有過這個站」的失憶。
  同源清掉兩處還活著的舊記錄:README 的線上 demo 連結、public/index.html 的註解。

驗收證據見 commit 後的實測輸出(舊世代樣本取自 git 歷史 ad367e4,本機起站餵判準,
未碰任何線上資源)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:36:18 +08:00
uncle6me-web e730b3f831 console-ui 部署:宣告值只解讀一次,並補上線上實況驗證(WIP)
同源於 2026-08-08 的 apiBase 事故:部署腳本把 apiBase/profile 印在
終端機上,卻沒有寫進推上去的產物 ⇒ 印對的、推錯的。

根因比想像深:deploy.targets.json 原本靠 build.mjs 在 build 期把
profile/apiBase 烤進產物,但 t160(e744ad1)清世代債時把 build.mjs
整支刪掉改成直接託管 public/,沒有人接手「把宣告值寫進產物」這件事
⇒ 前兩次事故的解等於被還原,只剩下「印出來給人看」。

本次落地:
- targets.mjs:宣告值的唯一讀取點,把「一個目標展開成期望的產物長相」
  定死成函式,供部署/驗證共用同一個答案,杜絕三邊各自解讀而漂移。
  缺 apiBase/accountId/verifyUrls/未定義的 profile 一律拒絕部署。
- verify-live.mjs:獨立可跑,驗「線上網址真的在用的組態」=「宣告值」。
- deploy.targets.json:補 _profiles(profile → views/home)與 verifyUrls。

WIP:deploy.mjs 尚未改接 targets.mjs,public/config.js 尚未退役。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:14:20 +08:00
uncle6me-web 84471659af docs(portal): 疑難排解按鈕文案改導流(t213,InkStoneCo 總管交辦)
這顆按鈕在封測者瀏覽器裡執行,構不到他電腦上 daemon 的本機資料(檔案總量/
失敗分類/daemon 版本)——那半已改在 arcrun-app 本機端匯出(見
products/arcrun-rag commit 9f1ca58)。按鈕本身保留當退路(daemon 完全掛掉時
唯一還按得到的東西),文案改成誠實講清楚自己只有一半、導去完整版,照總管
給的原話:「這裡只看得到雲端這半,完整診斷請到你電腦上的 Arcrun 匯出。」

純文案改動,未動任何邏輯/端點;跑過 portal-data/portal-admin/portal-auth
三份測試,114 通過、2 個既有失敗(HTML 殼 404,測試環境問題,與本次無關,
改動前後一致)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:13:06 +08:00
uncle6me-web 93b1140bf1 feat(portal): 新增 GET /portal/daemon/diagnostics(t213 matrix/arcrun 半部)
InkStoneCo 總管交辦(arcrun-rag repo t213):leo 拿真診斷檔實測四個真實問題,只答得出一題
——其餘三題需要地端資料(daemon 本機 manifest 總量/失敗分類/自我更新狀態),而現有
GET /portal/data/diagnostics 只有 portal session 認證,daemon 背景行程沒有 session
(密碼只在連線精靈當下用過就丟,不落地),構不到這支端點。

本次只做 matrix/arcrun 半部(雲端這半):
- 把 /portal/data/diagnostics 的核心查詢邏輯抽成 buildDiagnostics(env, tenant)
  (portal.ts),薄殼原則:能力只實作一次
- 新增 GET /portal/daemon/diagnostics,認證比照既有 /portal/daemon/extract
  (X-Arcrun-API-Key,非 session);apiKey 當 owner_id 用,不與 portalTenant(env)
  比對(t189 教訓:daemon 的 api_key 不保證等於 worker 的 CONSOLE_TENANT)
- /portal/data/diagnostics 改呼叫共用函式,回應形狀完全不變
- 按 leo 指示刪掉「需在失敗當下由封測者截圖」那句 notes(本機那半資料到位後這句話
  失去意義)

地端那半(arcrun-app 讀 manifest 合併 + 匯出按鈕)在 products/arcrun-rag repo 進行,
待另一隻處理 t210 的 agent 落地 app.go/main.js 改動後再接線,本次不動 arcrun-app。

驗證:
- npx tsc --noEmit:與 stash 前錯誤數相同(4 個),零新增(pre-existing,與本次改動無關)
- npx vitest run:314→315 通過(+3 新測試涵蓋 401/apiKey 當 owner_id 不比對 tenant/
  回應形狀與 session 版一致),14 個既有失敗與改動前完全相同(HTML 殼 404,測試環境
  static asset 問題,與 portal.ts/portal-data.ts 邏輯無關)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:32:10 +08:00
uncle6me-web 962d863ef7 fix(kbdb): 藏書地圖 M3 收尾——讀端自動核對重算,不再依賴 ingest 接鏈
真因(總管實測,system-dev/wiki/mistakes.md 08-08 段):design 原訂「ingest 尾端呼
POST /map/recompute」,但 repo 內查無任何呼叫點,三週沒接上,沒手動 backfill 過的租戶
(絕大多數)GET /map 恆回空;MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算(M3)」——假話。

leo 否決「降級成即時聚合、不維護快取」的提案(會丟失 narrative 這類摘要本體,只算得出
count)。改法:GET /map/GET /map/:library 讀端自己核對即時三元組數,落差就地呼叫既有的
recomputeLibraryMap 補算(kbdb/src/actions/library-map.ts ensureFreshLibraryMaps)。聚合
SQL 沒有第二套、narrative/relation_profile/bridges 摘要欄位原封不動,只是觸發時機從「等
外部呼叫」改成「讀的當下順手核對」。同時解掉:全租戶自動 backfill/跟得上新資料/不依賴
跨 repo 的 ingest 接鏈。

附帶修 recomputeLibraryMap 的 narrative 欄位:沒帶值時原本會清空,改成沿用上一版(避免
自動重算把 ingest 端/人工填過的 narrative 靜默洗掉)。

修正三處說謊的說明文字(mcp/src/tools/kbdb_map.ts、console-ui console/index.html):
「地圖由 ingest 尾端自動重算(M3)」不存在,改為誠實描述讀端即時核對機制;404 語意從
「從未 recompute」改為「查無此庫」(已知但空的庫現在會自動補成 triplet_count:0 的 200,
不會落到 404)。

測試:kbdb 新增 6 案(18/18 全綠,覆蓋自動 backfill/跟得上資料/narrative 保留/
404 vs 空庫誠實分辨/owner 隔離/無 triplet template 不報錯);mcp 新增 1 案釘住舊謊言
不再出現。kbdb 125/125、mcp 69/77(同基線 8 個 oauth 既有失敗,非本次引入)全綠;
tsc 兩包乾淨(kbdb 1 個既有 auth.test.ts 錯誤與 stash 前一致,非本次引入)。

SDD:system-dev/docs/3-specs/library-map/tasks.md M3 從「07-19 誤標 」更正為實況;
design.md §3 加 2026-08-08 更正說明。未動 frontmatter status(仍 draft,D35 生命週期
鐵律留給總管/leo 裁)。

殘項:本次修改只在本機驗證(真 SQLite + 假 binding 單元測試),未部署 prod;未在真實
KBDB(如 yuga3bse 租戶)重新實測 kbdb_get_map 非空——需部署後才能貼實測輸出。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:44:55 +08:00
uncle6me-web 7dbd4f59e7 fix(portal): 檢修孔 library_count/triplet_count 恆 0 的根因+加統計自我檢查
真因:/portal/data/diagnostics 讀 GET /map,那是 library_map 快取 block,只能
靠 POST /map/recompute 產生;核實過整個 repo 沒有任何呼叫點會打 /map/recompute
(library-map SDD 的 ingest 自動重算 M3 從沒接上,status: draft)。⇒ /map 對任何
租戶恆回空 libraries,與實際資料量無關——2026-08-07 leo 實測抓到:3 庫、大量
triplets,診斷檔卻回 0/0。

修法:改走 GET /portal/admin/libraries 已在用、驗證過的即時查詢組合(不依賴任何
快取):listRecordsByTemplate(portal_library) + /entries/libraries(t52 蓋章即
現身)+ /records/triplet-stats(t142 即時聚合 SQL)。

附帶:加 library_scope_check 統計自我檢查(呼應 embedding.self_test 的精神)。
兩個計數都是 0 時,用完全不同的查詢路徑(不分庫/模板,只問這個租戶底下有沒有
任何 entries)交叉驗證,區分「真的是空」與「查詢方式或 owner_id 對不上」
(2026-08-01 t161 前科同型病:手動補的 record owner_id 存成 None,全量查得到、
按 owner_id 過濾的畫面永遠空)。

三個 diagnostics 測試全綠:即時查對出正確 library_count/triplet_count(且不再
外流庫名/內容,只回數字)、真空情境自我探測誠實回空、t161 同型病情境自我探測抓到
「查得到但統計回 0」的矛盾。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:12:54 +08:00
uncle6me-web d779a11958 gitignore 掉 deploy-all.mjs 產的 package.json/lock
名字是 arcrun-deploy-shared,是本機跑 installer/scripts/deploy-all.mjs 時
npm 為了 wrangler 等依賴生出來的,不是 repo 內容。
每次本機部署都會冒出來吵未推警察 => 加 gitignore 而不是刪
(刪了下次部署又會產生)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:10:59 +08:00
uncle6me-web c7a0b317cb fix(kbdb): 補 kbdb/src/routes/entries.ts 缺的自癒搬遷 hook(上一commit漏了這支檔)
上一個 commit(046ceba)訊息宣稱改了 entries.ts 但實際只有測試檔進了 git——
entries.ts 的 migrateLegacyCredentialsForOwner 呼叫留在工作區沒進 index
(同一份工作目錄有另一個 session 併行在改這支檔案的語意搜尋空結果診斷功能,
兩邊的 import 改到同一行,第一次 commit 時漏收)。這次補上:

- import migrateLegacyCredentialsForOwner,GET /entries 對 entry_type=
  credential 觸發自癒搬遷(見 046ceba 說明,本檔案是實際掛載點)
- 同時收進另一個 session 已完成且測試通過的變更(search-semantic-empty-reason
  :語意搜尋回空時分辨 no_index/no_match/stale_index 三態,給人話 capability_
  hint + 技術向 admin_hint;非本次任務範圍,因同檔同 import 行交織、且已驗證
  119/119 全過,一併收下不拆散)

kbdb 全測試 119/119 通過(含新增的 credential-legacy-migration.test.ts 5 項
與 search-semantic-empty-reason.test.ts 4 項)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:53:51 +08:00
uncle6me-web 046ceba29c fix(kbdb): credential 目錄自癒搬遷(D38 收尾)——修 youlin 20/20 全敗事故
根因(2026-08-07 youlin 測試實例):7ba7855 把 credential 讀寫端從舊表
credentials(0002)改走 KBDB entries(entry_type='credential'),但舊表
資料的搬遷 migration(0006)要人手動觸發部署才會跑。實查 youlin 的 D1:
credentials 表有 1 筆(yuga3bse/kbdb_internal_token),entries 對應筆數
為 0——新讀取端上線、舊資料還沒搬,20 次 workflow 全部找不到 credential。

leo 追加硬要求:credential 資料住在用戶自己的 CF 帳號,換讀取路徑=每個
既有實例都要遷移,但用戶不准做任何手動步驟——搬遷必須內建在既有更新流程
裡、天然無感。

解法(kbdb/src/actions/credential-legacy-migration.ts):把「搬」變成
「讀」的副作用而非獨立步驟。KBDB worker(D38 唯一允許碰 SQL 的牆內)在
每次查詢某租戶的 credential 目錄前,先確認舊表資料是否已搬進 entries
——沒有就搬(per-owner scoped、NOT EXISTS 冪等),有就是零成本的
sqlite_master 短路檢查。呼叫時機掛在 GET /entries?entry_type=credential
(cypher-executor 熱路徑本來就會打的端點),故只要更新 KBDB worker,
下一次任何人跑 workflow 該租戶就自動搬好,不需要用戶或安裝器多做任何事。
刻意不執行退場(DROP TABLE)——多個實例搬遷時間點不同,舊表留著才能讓
「已搬」與「還沒搬」的實例同時安全運作;退場留給之後獨立的清理步驟。

kbdb/tests/credential-legacy-migration.test.ts:反向驗證重建 2026-08-07
事故的確切前置狀態(真 SQLite + 0001/0002/0005 migration 原檔),證明補丁
加入前 entries.length 回 0(事故重現),加入後回 1(修好);另驗冪等
(連呼叫三次不重複搬)、多租戶互不干擾、舊表已清理時的終態安全。

cypher-executor/tests/credentials.test.ts:補齊 7ba7855 留下的刻意紅燈
(原 placeholder 五項清單),涵蓋租戶隔離的讀寫、真刪除(非 deprecated
標記)、零原生 SQL 原始碼掃描、密文本體不落 KBDB。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:51:31 +08:00
uncle6me-web ac4fb56c91 portal 前端文案:語意搜尋降級提示改講人話+entry_type/mode 不再吐原始英文值
封測者 Oscar 回報「語義搜尋搜不到」,追查發現 portal 使用者實際會看到的降級提示
其實是後端 kbdb capability_hint 直接透傳(寫給工程師看的:「叫 CC」「vectorize」
「kbdb_embed:true」「redeploy」),比 UI 裡原本的 fallback 文案還難懂。

- se-banner:不再透傳後端 capability_hint,前端固定顯示「發生了什麼+現在怎麼辦」
  (語意搜尋還沒開通→以下是關鍵字結果→到設定頁疑難排解匯出診斷檔給我們)
- 新增 entryTypeLabel():entry_type 原始值(wiki_card/block/execution_log…)
  不再直接印給用戶看——尤其 wiki_card 原樣顯示會打臉上傳頁自己講的「AI 整理後
  會以 wiki 卡形式出現」
- 新增 searchModeLabel():搜尋結果「模式 keyword/semantic」的英文值改顯示
  中文(關鍵字/語意/圖譜),AI 問答來源標籤同步套用

不動 console/index.html(僅供部署擁有者/維運者使用,非客戶介面,根目錄
index.html 明文寫「不該對客戶露出」,同類字眼在此屬合理專業詞彙,故不改)。
不動 cypher-executor/src、kbdb/src(另一 agent 正在改 credential 路徑;
真正的降級文案根因在 kbdb/src/routes/entries.ts 的 capability_hint,
已另行回報總管,非本次改動範圍)。

已用 installer/scripts/build-ui-bundle.mjs 打包驗證:新文案進 bundle、
舊文案消失,entryTypeLabel/searchModeLabel 均實際生效(非死代碼)。
2026-08-07 22:38:07 +08:00
uncle6me-web 1e2ef6806a wiki: 落帳檢修孔第一版(embed selftest + diagnostics 端點 + 匯出按鈕)
記錄 2026-08-07 三個 commit(9344562/83aa1f6/5388f40)的狀態、端到端實測方式、
測試結果,以及未完成項(未 push GitHub 待 D20/Oscar 需自行按「立即更新」)。
一併誠實記錄收工時發現本機既有背景機制會把 commit 鏡到 Gitea(非本次操作觸發)。
2026-08-07 18:52:30 +08:00
uncle6me-web 5388f40c03 feat(portal-ui): 設定頁「匯出診斷檔給我們看」按鈕(檢修孔前端,2026-08-07)
leo 直接指令的簡化版規格:一顆按鈕、按下去下載一個檔案、用戶自己把檔案傳出去——
同意天然內建在「他自己按、自己傳」這個動作裡,不需要額外授權流程或內部概念外露。

按鈕打 GET /portal/data/diagnostics,把回應存成單一 JSON 檔(不是要解壓的一包)
直接觸發瀏覽器下載,檔名帶時間戳。沿用既有 authHeaders/safeJson/guard401/friendlyErr
helper(與同頁其餘按鈕同一套錯誤處理慣例)。

既有前端輕量測試(safejson.test.mjs/os-split.test.mjs)跑過,18/18 全綠,未受影響。
2026-08-07 18:39:30 +08:00
uncle6me-web 83aa1f6bb2 feat(portal): GET /portal/data/diagnostics —— 檢修孔聚合端點
leo 2026-08-07 直接指令:「一顆按鈕在設定裡,按鈕下載一個檔案,把檔案發給我,你看那個
檔」。本端點是那個檔的資料來源:聚合 embed 模組健康狀態(module_enabled/cards_embedded/
cards_pending/self_test)、知識庫規模(library_count/triplet_count)、bundle_version、
instance_url。

紅線落實:
- 只轉發數字/布林/字串狀態,KBDB /map 回應裡的 narrative/top_entities(卡片內容)讀出
  triplet_count 後即丟棄,測試 portal-data.test.ts 新增案專門斷言回應不含內容字樣。
- 認證沿用既有 requirePortalUser session 閘,不對外公開。

3 個新測試全綠(未登入 401/完整聚合含隱私斷言/embed 未開時誠實回 false 不假裝)。
既有 1 個失敗案(/portal HTML 殼 404)為 stash 驗證過的既有失敗,與本次改動無關。
2026-08-07 18:38:03 +08:00
uncle6me-web 9344562258 feat(kbdb): embed 自我檢查端點(檢修孔第一塊,2026-08-07 leo 直接指令)
GET /embed/selftest?owner_id= —— 挑一筆已標記「已嵌入」的卡片,拿它自己的內容做一次
真實語義查詢,檢查「自己是否搜得到自己」。backfillStatus 的 pending/embedded 計數
看不出 Arcrun#11 那種「嵌了但查不到」的故障模式(metadata index 事後才建、既有向量
沒被收錄),本端點是唯一能端到端驗證 index 真的可用的方法。

隱私邊界:只回 {enabled, tested, passed, note} 四個布林/字串欄位,不回卡片內容、
不回 entry id(測試 embed-selftest.test.ts 最後一案專門斷言不洩漏)。

12/12 kbdb vitest 全綠(含既有 embed-backfill 6 案未壞)。
2026-08-07 18:35:06 +08:00
uncle6me-web 7ba78552a4 D38:credential 目錄改走 KBDB API(零原生 SQL)+舊表退場;測試刻意留紅燈
存取層:credentials.ts / auth-dispatcher.ts / portal.ts 全改走 kbdbBase()+fetch
到 /entries(照 execution-logger.ts 既有慣例),.prepare/.exec/.batch 命中 0。
資料層:0005 seed credential template;0006 把舊表資料搬進 entries 後拆表;
0002 標退役、deploy.ts 不再套用(加 kbdb-sql-ok 留痕,純歷史對照)。

總管親驗四項(不聽 agent 自評):
1 三個檔 .prepare/.exec/.batch 命中 0;六個檔全部通過 kbdb-api-wall-guard
2 0006 的 INSERT 欄位(id/entry_type/owner_id/page_name/metadata_json/
  created_at/updated_at)與 0001_base 的 entries 表逐一對得上
3 不可逆風險查官方:D1 batch 是 transaction、任一句失敗整批 rollback;
  exec 出錯則「執行停止、後續不執行」=> 兩種語意下 INSERT 失敗都不會跑到
  DROP TABLE,用戶 credential 目錄不會遺失
4 0006 搬在拆之前、冪等(NOT EXISTS 防重複)、豁免標記有留痕且理由正當
  (拆表是牆內施工,API 不提供也不該提供拆表)

🔴 抓到一個假綠並修正:agent 中途被中斷,把 111 行的 credentials.test.ts
砍成一行「// placeholder — see edit below」,那個 edit 從來沒發生,
且 setup.ts 被刪。vitest 對這種檔案回報「Tests: no tests」,
很容易被讀成「沒失敗=通過」——正是 CP 記過的
「這條 route 曾整條消失過沒人發現」同型。
處置:還原 setup.ts/vitest.config.ts,credentials.test.ts 改成
**刻意會失敗的紅燈**並在檔頭列出要補的五項。空檔會被誤認為綠,紅燈不會。

未部署。本批要先上 stage 驗過才進 prod(leo 08-07 定)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:27:18 +08:00
uncle6me-web 60688c3108 fix(kv-quota): workflow 執行紀錄搬離 KV,改走 KBDB template 機制(A1/A2/A7)
事故:cypher-executor/src/actions/execution-logger.ts 舊版每跑完一次 workflow 就
ANALYTICS_KV.put() 一筆新 key(註解寫「避免覆蓋」)= 只增不減,封測者 Evan 處理約 690 個
檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write),整個實例 429。

A1 少記:workflow 執行紀錄改走 KBDB template 機制(entries 表 entry_type='execution_log',
kbdb/migrations/0004_execution_log_template.sql 只 seed 一列 template 定義,零建表/改表)。
儲存精神比照既有 recipe_stat(kbdb/src/actions/recipe-stat.ts):template 只負責文件化,
實際一筆執行是 entries 表一列(1 次執行=1 次 D1 寫入,不走 entry_values 全展開)。欄位收斂:
時間/workflow/verdict/duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記(訊息截斷長度
不對稱:200 vs 2000 字)。target 只認 trigger context 的 page_name/path,不整包存 input。

A2 自我降級:D1 額度仍與知識卡共用同一顆 100,000 rows/日,本模組自設 20% 軟上限(可用
EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫),超過 80% 降成只記失敗、超過 100% 完全停止記錄,
但 workflow 執行永遠照跑(cypher-executor 端 fire-and-forget 永不 throw)。

A7 讀取端:/workflows/:name/executions、/portal/data/workflows 的 last_execution、MCP
list_recent_executions 全部改打 KBDB HTTP API(GET /execution-log、/execution-log/latest),
取代原本的 ANALYTICS_KV list/get(免費層 list 也是 1,000/日)。

架構鐵律修正(本次施工中兩度被抓到走偏,過程留痕於 commit 訊息供後續參考):
- KBDB 三張表打天下(entries/templates/entry_values),永遠不加新 table——新資料類型
  一律用 template + entries,不建表、不 ALTER TABLE。
- KBDB = API-as-Wall,零 SQL:cypher-executor 端一律走 KBDB 的 HTTP API(連法比照既有
  recordRecipeStats/kbdbFetch 慣例),不直連任何 D1、不對 arcrun-kbdb 下任何原生 SQL。

順帶修復:kbdb/src/actions/entry-crud.ts listEntries 的 ORDER BY 補 `, rowid DESC` 二級
排序——entries.created_at 是 unixepoch() 秒級解析度,高頻寫入(execution_log 一秒內多筆)
常同秒,單靠 created_at DESC 不保證「最新一筆」正確,此為本次測試(latestExecutionLog)
發現的既有潛在缺陷,順手補上決定性排序,不改變任何既有查詢在 created_at 不同時的行為。

隔離:portal-data.ts INTERNAL_ENTRY_TYPES 加入 execution_log/execution_log_usage(與既有
value/workflow 同層級排除),避免用戶知識搜尋混進執行 log;本模組從不設 metadata_json.embed,
故永不進 Vectorize 語意搜尋索引。

不動:registry/src/actions/recordAnalytics.ts(零件市場統計,獨立 Worker、獨立 KV 命名空間、
不同資料模型,非本次事故根因所指範圍);cypher-executor/{wrangler.toml,kbdb/wrangler.toml}
未變動(repo 層級 deny 規則保護這兩個生產設定檔不被 AI 編輯)——ANALYTICS_KV binding
因此仍留在 wrangler.toml 宣告中但程式碼零讀寫點(見 PR 說明的完整 grep 佐證)。

KV 裡既有的 stats:* 舊資料不搬移(是統計不是真相源,維持原樣任其依 90 天 TTL 自然過期)。

測試:kbdb/tests/execution-log.test.ts(13 個,含零建表證明/少記/A2 降級/route)、
cypher-executor/tests/execution-logger.test.ts(payload 正確性/永不 throw)、
cypher-executor/tests/executions-route.test.ts(讀取端轉發)、portal-data.test.ts 對應區塊
改寫。kbdb 全測試 104/104 通過;cypher-executor 320 個測試中 9 個失敗為 main 既有(與本次
改動無關,改動前後 stash 對照確認)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:13:00 +08:00
uncle6me-web 36a5630c63 portal 顯示同步器版本+下載網址改讀真相源(leo 08-06)
leo:「Portal 和 rag.arcrun.dev 應該顯示同步器的版本⋯⋯因為你說最新 0.18.5
連我都沒辦法確認,所以用戶到底是否最新版他自己也不知道」。

## 病
release(雲端知識庫)與 daemon(桌面同步器)是兩條版本線。
版本卡只顯示前者;下載按鈕的檔名是寫死的固定別名
(DAEMON_MAC/DAEMON_WIN)⇒ 頁面上完全看不出「同步器是哪一版」。

## 改
- 下載網址改向 /api/latest 取 daemon.downloads(真相源=bundles manifest),
  取不到才退回原本的固定別名 ⇒ 按鈕永遠可用。
  ⚠️ 沒有違背 fe0ee82 立的「不要每出一版就改 code」——網址是取來的,
     一樣不必改 code;還順便解掉固定別名的兩個老問題:
     ① 別名指向 @main 會吃 CDN/ref 快取拿到舊檔(08-04 撞過)
     ② 檔名不帶版號 ⇒ 無從顯示「這是哪一版」=leo 這次抱怨的正題
- 版本卡下方加一行「最新的同步器版本是 X(你手上那支在同步器視窗左下角)」
  +連到版本說明頁。取不到就**不顯示**,不編數字。
- 渲染改成「先用退路畫、取到真相源再重畫」,renderDaemonDownload 做成可重複呼叫
  (清掉上一輪插入的節點,否則會疊出兩份「不是這個系統?」)。

## 驗
· node --test os-split.test.mjs safejson.test.mjs → 8 passed, 0 failed
· index.html 三個 script 區塊逐塊 node --check → 全部語法 OK
· 未驗:線上畫面(要等 bundle 出貨後才有 daemon 欄位可讀)⇒ ◐
2026-08-06 13:12:15 +08:00
uncle6me-web 05b215c9f7 語意門檻改相對式+下架連帶刪向量(leo 兩個實測回饋)
## ① 「關懷型 AI 命中 20 筆、只有前 3 筆相關」⇒ 閾值太寬(leo 判斷正確)
固定門檻兩頭都不對,因為每個查詢的分數尺度不同(實測 youlin 實例):
  關懷型 AI          正解 0.645-0.770,雜訊起於 0.547  ← 固定 0.5 放進 6 筆雜訊
  閉環機             正解 0.552-0.638,雜訊起於 0.446  ← 固定 0.6 砍到剩 2/4(=早上的 0 命中)
  人力媒合系統規劃書   正解 0.842,雜訊起於 0.550
⇒ 改**相對門檻** max(0.45, top×0.8)。五組實測:固定 0.5 混入 9 筆雜訊/
  固定 0.6 有兩組正解被砍/相對式四組雜訊 0 且正解全留。

## 🔴 寫測試才發現的真問題:門檻不能在 Vectorize 那層算
Vectorize 的 indexed metadata 沒有 status ⇒ 那層不知道誰已下架。
若最高分是下架殘影(t24 復現案 0.971),拿它算門檻=0.777,
會把 0.6 的正解一起砍光 ⇒ **又變成 0 命中**。
⇒ 相對門檻移到 routes/entries.ts,接在「hydrate+濾下架」之後;
  embed.ts 只留絕對下限。新增測試鎖住這個順序。

## ② leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」——對,補上
單筆真刪已接 deleteByIds(b7af622),但「移除整個庫」走軟刪、向量原地不動。
⇒ deprecate-by-library 同時 deleteByIds + is_embedded 歸零(D1 與 Vectorize 不說兩套話);
  backfill 兩條路徑(含 reindex)都排除 deprecated,否則下次補嵌會把殘影養回來。
不違背 t135「資料保留可還原」:D1 那列原封不動,還原後跑 backfill 重嵌即可。
回應新增 vectors_deleted,刪失敗誠實回 0 不假裝清乾淨。

驗:kbdb 91/91 綠(新增 4 項相對門檻測試,含「下架殘影不得決定門檻」)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:47:10 +08:00
uncle6me-web 0ff369f818 修語意搜尋全 0 命中:分數門檻沒跟著 bge-m3 換代(換模型漏掉的第五處)
leo 2026-08-05 實撞:「新上傳的『loop-engine-north-star.md』語義 0 命中,
但舊的『人力媒合系統規劃書』語義 2 命中」。

## 根因不在向量——向量是好的
直打 Vectorize 實測(youlin 實例、bge-m3、1024 維 index):
  搜「閉環機」→ loop-engine-north-star.md 穩坐第 1-4 名(0.638/0.603/0.588/0.552)
D1 與 Vectorize 也對得上:659 筆 embeddable 全 is_embedded=1、index vectorCount 659。

真兇是 **min_score 門檻綁在舊模型的分數尺度上**:
  舊 bge-base-en-v1.5:中文分數全擠 0.65-0.90(沒區辨力)⇒ t183 取 0.75 砍雜訊,對
  新 bge-m3          :尺度整體下移(相關 0.5-0.85、雜訊 0.4 上下)⇒ 0.75 砍掉的是正解
leo 看到的「舊檔中、新檔不中」由此而來——「人力媒合系統規劃書」拿 0.842 僥倖存活,
其餘全被門檻掃掉。08-05 換 bge-m3 的「四處同步」清單(embed.ts/deploy.ts/
deploy-all.mjs/worker.js)**漏了這第五處**,因為它不在 kbdb 而在 portal 呼叫端。

## 改動
· kbdb/src/embed.ts:新增 DEFAULT_MIN_SCORE=0.5,**緊鄰 DEFAULT_EMBED_MODEL**
    ——門檻是模型的性質,放模型旁邊,下次換模型的人一定會看到
· cypher-executor/src/routes/portal-data.ts:拿掉硬寫的 0.75,
    只在使用者顯式指定時才傳 min_score(不再各自持有一份數字=不再漂移)
· console-ui/public/portal/os-split.test.mjs:修好被今天 fe0ee82 弄壞的自測
    (結尾標記寫死文案 ⇒ 改文案就炸「抽不到函式區塊」;改成錨定結構)
    +斷言同步改成 Mac 給 DMG

## 0.5 怎麼來的(實測分布,不是猜的)
「閉環機」            0.638/0.603/0.588/0.552 全是目標檔 ── 斷崖 ── 0.446 才是雜訊
「火星座標 奧林帕斯山」 0.750…0.500 全對,0.475 以下才是雜訊
「人力媒合系統規劃書」   0.842 對,0.550 起是雜訊
誠實 trade-off:0.5 非每個查詢都乾淨(「AI 上課名冊」0.658 的 ax-academy 會擠進來),
但「偶有雜訊」遠優於現況「什麼都搜不到」。

## 驗
· kbdb 87/87 綠(三筆斷言隨新契約更新:預設不再是「不過濾」)
· cypher-executor 9 failed/301 passed=**與改動前逐數相同**(git stash 前後各跑一次)⇒ 既有債
· portal os-split 自測 10/10 綠

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:00:43 +08:00
uncle6me-web fe0ee8296a portal 下載 Mac 版改給 DMG(拖進 Applications),不再給 zip
leo 2026-08-05:「Mac 強調要包裝成 application + dmg 的格式,拖進去就會放到
application,**解決之前直接在下載資料夾啟動造成更新問題**」

## 病(實查證實)
manifest 已有 mac_dmg 欄,DMG 也上傳了,**但 portal 下載按鈕仍指 zip**:
  DAEMON_MAC = 'ArcrunRAG-mac-unsigned.zip'
用戶端實抓該 zip → 解開就是 Arcrun.app ⇒ 很可能直接在「下載」資料夾雙擊啟動,
正是 t184(Oscar)那個「更新完又跳回舊版」的病灶。

## 修
· DAEMON_MAC → 'ArcrunRAG-mac.dmg'(**固定檔名**,否則每出一版都要改 code)
· Mac 安裝提示改成 DMG 的步驟:「開啟後把 Arcrun 拖進『應用程式』,再從啟動台開啟」
  (zip 版的話術是「右鍵→打開」,步驟不同不能沿用)

## 說明:App 端的更新邏輯本來就修好了
selfupdate.go 已改成更新「正在跑的那個 .app」(runningAppBundlePath),不寫死
/Applications ⇒ 放哪都更新得了。本次補的是**入口**:讓使用者一開始就放對位置。

## ⚠️ 殘項(未送達)
bundles repo 目前**只有帶版號的 DMG**(ArcrunRAG-v0.18.4.dmg),
**沒有固定檔名 ArcrunRAG-mac.dmg**(實測 404)⇒ 這行改動要等出貨時
一併產生固定檔名副本才會生效。出貨需 D20 開閘。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:35:02 +08:00
uncle6me-web b9b5d98d75 併 bge-m3 換代(#59)+補上分支漏做的另一半:Vectorize index 也換
leo 2026-08-05:「換 embed model 當然要合併,當然要換 vectorize,**原本的根本不能用**」
——我把已拍板的事當成待裁決,錯了。

## 為什麼非換不可(08-03 leo 拍板,5 組中文測資實證)
  @cf/baai/bge-base-en-v1.5   768   排序正確 2/5   margin -0.0413   1660ms  ← 舊
  @cf/baai/bge-m3            1024        5/5        +0.1410    959ms  ← 新
舊英文模型嵌中文:分數全擠在 0.65-0.81,區辨力接近沒有=**根本不能用**。

## 🔴 分支只做了一半,另一半我補上
feat/embed-model-m3-t59 只改 kbdb(embed.ts / types.ts / 測試),**完全沒動 deploy.ts**
⇒ 就算合併,安裝器仍會建 **768 維的舊 index**,新向量根本收不進去。
(git show f3af5d3 --stat 實查:只有 3 個檔,全在 kbdb/)

本次補上安裝器那半:
· KBDB_VECTORIZE_INDEX: arcrun-kbdb-embed → **arcrun-kbdb-embed-m3**(換名字,非只換維度)
· ensureVectorizeIndex: dimensions 768 → **1024**
· kbdb/wrangler.toml 說明與 metadata-index 指令同步改新名(照抄不會建錯 index)
· embed.ts docstring「768 維向量」→ 1024(分支漏改,會誤導)

## 為什麼要換「名字」不是只改維度
① 維度 768→1024,舊 index 收不進新向量
② 就算維度相同也不能沿用——不同模型的向量混在同一 index 比對出來是垃圾,
   而 #58(Vectorize vector delete 未接)代表舊向量刪不掉
   ⇒ **開新名字反而順手繞開 #58**,且新舊並存可回滾。

## 查證(歷史警察 Q0-Q3,非假設)
· git log --all -S"arcrun-kbdb-embed-m3" / -S"dimensions: 1024" 皆空 ⇒ 無分支改過,非重造輪子
· **metadata index 用同一個常數**(deploy.ts:426 ensureVectorizeMetadataIndexes)
  ⇒ t36 的四個 metadata index(owner_id/entry_type/source/library,Arcrun#11 根因修復)
  會自動建在新 index 上,**不會因改名而遺失**——這點特地查過,不是假設。

## 驗
· kbdb 測試 87/87 綠(含新增 4 項 embed-model-config)
· cli tsc --noEmit **零錯誤**
· 三處一致性機械確認:embed.ts=bge-m3/deploy.ts=dimensions 1024/index=arcrun-kbdb-embed-m3
· 踩到並記錄:註解寫 `**dimensions=1024**​/metric` 會因 `*/` 提早關掉 block comment(TS1127)

## 既有實例遷移(尚未執行,需對實例操作)
建新 index → 重部署 kbdb(binding 指新 index)
→ POST /embed/backfill {"reindex":true} 到 remaining=0 → 舊 index 可刪。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:46:33 +08:00
uncle6me-web 3d21bf0725 移植 t21 溯源 scheme 相容:kb:// 不造死鏈(救自已刪分支)
## 這條與 t195 直接相關
新 ingest 鏈(rag_ingest_card/rag_ingest_direct,就是我今天在修的收卡鏈)
寫的 metadata.source 是 **kb://<path>**,但 main 的 portal 溯源只認 gitea://
⇒ 卡片上傳成功後,**溯源連結一律失效**。

原修補在 fix/portal-source-scheme-t21(07-24),一個月沒併回 main。
該分支的檔案 console-ui/src/portal-ui.ts 已被重構搬走(UI 搬 CF Pages),
⇒ 不能直接 merge,**移植邏輯到現行的 console-ui/public/portal/index.html**。

## 三種 scheme 的正解(照原分支設計)
· gitea://<path>          舊 rag_ingest v2 → {base}/<path>,行為一字不變(不回歸)
· gitea:<org/repo>@<path> km-wiki-ingest 實形 → {base}/<org/repo>/src/branch/main/<path>
· kb://<path>             新 ingest 鏈實形 → **回空字串=維持純文字,不造死鏈**
  (雲端沒有對應網頁;另給 srcLocalPath() 取本地相對路徑顯示)

## 實測四情境(抽出函式跑 node)
gitea://docs/a b.md#3        → https://…/base/docs/a%20b.md           錨點捨棄+編碼
gitea:Leo/kb@notes/x.md#seg01 → https://…/base/Leo/kb/src/branch/main/notes/x.md 
kb://wiki/cards/policy.md    →                                      不造死鏈
srcLocalPath(kb://…#seg02)   → wiki/cards/policy.md                  

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:38:19 +08:00
uncle6me-web 5783420fcf 併 feat/harness-content-refresh:install-harness 升現世代+世代閘
leo 2026-08-05:「Arcrun 是它的父層,這裏的問題也影響 Arcrun RAG」。

main 的 cli/harness/ 停在 06-15,分支是 07-31 的升級(676 行實質改動):
· cli/scripts/check-harness-generation.mjs(130 行)=**世代閘**——
  與今天救回的 build-bundles 落後閘同族,都是防「發舊版給用戶」
· cli/scripts/build-harness-skill.mjs(64 行)
· CLAUDE.block.md/commands/arcrun.md/hooks/arcrun-guard.sh 交付內容升級

自動合併零衝突。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:35:56 +08:00
Leo 1eefce952b docs: 零件/binding PR 審核規範——結構性防架構錯誤(不靠記規則):A反過度工程(D27)/B binding層級(D28)/C contract/D驗證誠實/E部署資料鐵律/F走PR+機械化CI補強 2026-08-05 13:33:56 +08:00
uncle6me-web 6c5706ba7d 補上合併漏收的修改檔(t189/t181/CIS portal 本體)
上一筆 5d78806 我只 add 了新增檔(favicon 三件套),四個修改檔沒收進去
⇒ 合併內容只推了一半,t189 的 tenant 比對修復其實還躺在工作區。

本次補上(實測與已刪分支 HEAD 23f2fd4 逐檔相同):
· cypher-executor/src/routes/portal.ts      +81(t181 /portal/daemon/extract、t189 tenant)
· cypher-executor/src/routes/portal-data.ts +21
· console-ui/public/portal/index.html      +169(CIS 視覺、版本卡)
· cypher-executor/tests/portal-admin.test.ts +45

教訓:merge --no-commit 後要 git add -A,不能只 add status 裡看到的第一類。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:31:57 +08:00
uncle6me-web 5d78806806 併 fix/cis-round3-portal 進 main:CIS 視覺+t181/t183/t189 收口
## 為什麼現在併
leo 2026-08-05:「完工、下班,都要把所有東西整理乾淨,沒推的要推、沒合併要合併」
「至少在 gitea,我不要看到還有沒處理的 PR 或分支」。

這條是 arcrun 的**現役工作分支**(領先 main 15 筆、只落後 3 筆),
含的都是已驗過的東西:
· CIS 第三輪:portal 換色票/真向量 wordmark/favicon 三件套/裁淨版 lockup
· 底色與字形對齊 landing(Paper #FDFCFB/Canvas #F2F1ED,9 處明體→無襯線)
· portal 設定頁版本卡(落後紅點+一鍵更新,t154 免辨識碼)
· t176 套到 CIS 版 portal(刪 AI 設定區塊)
· t181:POST /portal/daemon/extract——daemon 萃取走 Workers AI(免金鑰)
· t183:語意搜尋補 min_score=0.75
· t189:/portal/daemon/extract 拿掉 tenant 等值比對(geek6688 萃取永遠 401)
· 移除誤入版控的 cypher-executor/node_modules 自指 symlink

落後的 3 筆=剛併進 main 的 PR#22(純文件)⇒ 自動合併零衝突。

## 測試對帳(合併前後比對,非只看合併後)
合併後:Test Files 4 failed | 22 passed;Tests 9 failed | 301 passed
合併前(純 main 同樣跑一次):Test Files 4 failed | 22 passed
⇒ **9 fail 是既有債,非本次合併造成**(與 D36 那次 commit 記載的既有債一致)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:29:15 +08:00
uncle6me-web 1b6b775c0e Merge commit '5491409003ff' 2026-08-05 13:21:09 +08:00
uncle6me-web 66f1b59f7f t176:雲端不再管地端 LLM 設定(leo 08-03 緊急)
leo 回報四件事:①雲端 Gemini 無法用 ②搞不清楚設定 AI 是設雲端還是地端
③地端不會萃、每個人都死掉因為都去抓 Claude ④雲端還要設置明明就有 Workers AI。
目標:「雲端裝好就能用,地端輸一個 Gemini API Key,然後就開始用。」

根因:extractor_config 的 KV key 由 portalTenant() 組出,而 portalTenant 是
**worker 層級**環境變數(portal.ts:43)⇒ **全租戶共用一把**。任一處設了 claude,
所有人的 daemon 都收到 claude;沒裝 Claude Code 的機器萃取全滅,而 portal 的
Claude 勾選框又恆 disabled(daemon 從未實作 report-capabilities ⇒ daemon_caps 永遠空)
⇒ 用戶自己解不開(awindhon 08-03 實證:雲端同步成功、金鑰有效、零張卡)。

本次(雲端側):
- POST /portal/daemon/config **不再下發** extractor/gemini_api_key/llm_model,
  只回連線欄位。⚠️ route 本身與 daemon/libraries 資料夾管理完全不動(leo 明確劃界:
  「雲端拉地端檔案夾部分不要刪,刪掉從雲端設置地端 LLM 選項部分」)。
- 移除 POST|GET /portal/admin/extractor(t122)——這是「雲端指定地端引擎」的入口,
  且無任何伺服器端驗證,打一下就能把全租戶設成 claude。
- 移除 POST /portal/daemon/report-capabilities(t131)——daemon 端從未實作該呼叫。
- 移除 t131 那組重複註冊的 /portal/admin/ai。**它是重複 route**:後段(arcrun-rag#10)
  另有同路徑一組,Hono 先到先比 ⇒ 舊的一直贏,後段修好的「金鑰真的寫進 credentials」
  形同死碼。保留後段那組(只管 Gemini 金鑰、走 storeCredential),並拿掉 Claude 偏好欄。
- portal 設定頁「AI 設定」整塊移除,改成一句話說明:雲端不需設定(Workers AI 免金鑰),
  地端請在同步小幫手的「AI 設定…」填 Gemini Key。順手清掉 main 上既有的 conflict 標記。
- 清掉隨之孤兒化的 helper(ExtractorConfig/AiConfig/DaemonCapabilities/
  syncExtractorFromAiConfig/readAiPref 等)。

測試:**相對 merge 前 main 基準線,新增失敗 = 0**(基準線本就 9 紅:console 藏書地圖 6/
portal HTML 殼 2/POST execute 1,皆與本次無關)。t122/t131 兩組測試改寫成
**回歸守衛**(斷言那些端點/欄位確實 404、確實不存在),不是刪掉充綠。
順手修 health bundle_version 測試與實作對齊(實作刻意省略該欄,測試卻期待空字串)。

未送達:本 commit 只到 code,尚未部署上線。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 22:11:45 +08:00
uncle6me-web eee3f93815 merge feat/workers-ai-chat-t152:雲端聊天改 Workers AI(免金鑰)
leo 08-03 緊急事件之一:「雲端還要設置明明就有 workers AI」。
本分支帶進 workers_ai_chat 種子(auth: binding,不需要任何金鑰),
讓「雲端裝好就能用」成立——用戶不必再去申請/貼 Gemini key 才能聊天。

測試差異(相對 merge 前的 main 基準線 9 紅):
- 新增 3 紅=/portal/admin/ai 的 Claude 偏好案,正是接下來要刪的功能(t176),隨刪一併處理
- 新增 1 紅=health bundle_version 回 undefined 而非 '',本分支既有小瑕疵,
  與本次緊急事件無關,另記待修
- 原 9 紅為 merge 前既有(console 藏書地圖 6/portal HTML 殼 2/POST execute 1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:20:33 +08:00
Leo f3af5d3631 feat(#59): embed 模型可配置,預設換成 bge-m3——英文模型嵌中文會排錯
leo 2026-08-03 拍板「換 m3」。#59 本體(模型應可配置+index 版本化)。

## 為什麼換:實測,不是憑感覺
5 組中文問答測資(每組 1 問 + 2 段相關 + 3 段無關,無關的刻意放同一知識庫裡的其他主題),
margin = min(相關分數) − max(無關分數),margin ≤ 0 代表排序是錯的:

  @cf/baai/bge-base-en-v1.5      768   排序正確 2/5   平均 margin -0.0413  1660ms ← 舊
  @cf/google/embeddinggemma-300m 768   4/5   +0.1275   1174ms
  @cf/baai/bge-m3               1024   5/5   +0.1410    959ms ← 新(最好且最快)
  @cf/qwen/qwen3-embedding-0.6b 1024   4/5   +0.1381   3238ms

最刺眼的一組:問「知識庫問答為什麼要標出處?」→「會議室預約規則」0.7789
竟然高於真正相關的 0.7306。這正是 leo 2026-07-18 回報的「問 RAG 卻引用會議室規範」,
過去只被描述成「semantic 排名近乎雜訊」,其實是**排序錯誤**,而且五組錯三組。
英文 bge 系列的分數全擠在 0.65-0.81(區辨力接近沒有)——中文對它就是看不懂的 token。

## 改動
- `EMBED_MODEL` 常數 → `DEFAULT_EMBED_MODEL='@cf/baai/bge-m3'` + `embedModel(env)`,
  可由 `env.EMBED_MODEL` 覆寫(#59 要的「可配置」),空字串/空白視為沒設。
- 寫入端(embedOnWrite / backfill)與查詢端(semanticSearch)共用同一個 getter
  ——兩邊不同步是最惡毒的 bug:不報錯、分數全垃圾、外面完全看不出來。已加測試守。

## 換代必須換 index(安裝器那半在 arcrun-rag 同名分支)
① 維度 768→1024,舊 index 收不進新向量
② 就算維度一樣也不能沿用——不同模型的向量混在同一個 index 比對出來是垃圾,
   而 #58(Vectorize vector delete 未接)代表舊向量刪不掉
⇒ 開新 index 反而順手繞開 #58。

既有實例遷移:建新 index → 重部署 kbdb(binding 指新 index)
→ POST /embed/backfill {reindex:true} 重嵌到 remaining=0 → 舊 index 可刪。

驗證:kbdb 全套 87/87 綠(含新增 4 項);tsc 與基線相同(只剩既有的 auth.test.ts 那筆);
打包實跑產物 grep:kbdb bundle 只有 @cf/baai/bge-m3、舊英文模型 0 殘留。
2026-08-03 03:48:28 +00:00
Leo 47c6aaea03 feat(t152): workers_ai_chat 種子(auth: binding,免金鑰)+ 修 /init/seed 吃掉 3.12 欄位+ 修 D1 LIKE 長查詢 500
SDD: workflow-discovery 3.12/3.13(不是新規格;3.12 已 confirmed 並實作完成)

## 1) workers_ai_chat 種子(新)
Cloudflare Workers AI 走 env.AI binding ⇒ 用戶不必填任何 API 金鑰就能問答。
放種子表而非產品安裝器:「裝好後預設有哪些 recipe」是平台能力(rule 07 薄殼原則)。
換模型/換供應商=改這一筆 recipe,workflow 不動。

選型實測(1.4.4 實例,真實長度 RAG prompt,每個模型連跑 2 次):
  llama-4-scout-17b        2373/2173 ms   答案最完整、引用正確 ← 選它
  llama-3.3-70b-fp8-fast   3261/2147 ms   可用但波動較大
  mistral-small-3.1-24b    3560/3631 ms
  qwen2.5-coder-32b        3572/3353 ms
  gpt-oss-120b             1971/2295 ms   回應形狀不同,response 取不到文字
  gemma-3-12b-it            5018 帳號無權限
對照舊路徑 Gemini gemma-4-31b-it:同型提問 16.87 s,且吐整段英文思考草稿。

## 2) 修 /init/seed 靜默吃掉 3.12 欄位
3.12 給 RecipeDefinition 加了 body_template/response_map/auth/binding_name,
但 /init/seed 是**列舉欄位重建** recipe record ⇒ 不在名單上的欄位被丟掉。
最惡劣的地方是「哪裡都不會紅」:recipe 查得到、endpoint 對,只有跑起來像沒設定過。
與 08-02 syncManifest 吃掉 manifest.daemon 欄同型(教訓:東西還在不在也要進機械閘)。
加 tests/init-seed-recipe-fields.test.ts:拿掉修復會紅、補回會綠(已實測會擋)。

## 3) 修 D1 LIKE pattern 50 bytes 上限造成的 500
/entries/search?q=… 只要 q 超過 48 bytes 就回 HTTP 500,沒有錯誤訊息。
逐 byte 二分:48→200/49→500;中文 16 字→200/17 字→500。
判別實驗:q 固定 48 bytes、其他 filter 全塞滿讓 SQL 變很長 → 仍 200
⇒ 爆的是 LIKE 的 pattern('%'+q+'%' = 50),不是 statement 長度。
中文問句超過 16 字是常態,而 rag_chat 用整句問題當 q ⇒ 聊天對正常問句等於不能用。
(=InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
修法:q ≤ 48 bytes 走原路(行為逐字不變),超過才拆詞/切 UTF-8 邊界片段。
kbdb 全套 83 測全綠(含新增 8 項)。

## 4) 順手
- 移除被 commit 進 repo 的 node_modules 壞 symlink(指向 leo Mac 的絕對路徑,
  害任何 fresh clone 裝不起來、切分支還會把裝好的蓋掉——本次撞了兩次)。
- pending-changes.md 加 P2 提案(fan-out 並行執行)+等裁決,未動引擎。

驗證:cypher-executor 新增測試 17/17 綠;tsc 與基線逐字相同;
全套測試失敗集合與基線**逐字相同**(基線 14 個失敗,本分支 t173 既有,非本次引入)。
2026-08-03 02:56:53 +00:00
Leo 5b983c47b8 Merge branch 'main' into fix/merge-main-into-batch-t173
# Conflicts:
#	console-ui/public/portal/index.html
#	cypher-executor/src/routes/health.ts
#	cypher-executor/src/routes/portal.ts
#	registry/components/kbdb_upsert_block/component.contract.yaml
#	registry/examples/km-wiki-ingest/workflow.yaml
2026-08-02 23:43:16 +08:00
uncle6me-web 46afea83c2 步驟1 最後一筆:install-harness 交付內容升級到現世代+世代閘
管道本來就是好的(install-harness 功能完整、冪等),**過時的是內容**:
harness skill(4066B)grep「意圖」「>>」= 0 命中,只講世界觀/別寫 Python
⇒ 新裝的封測者拿不到步驟 1 的核心教材(`>>` 意圖語法)。

■ 單一真相源:harness skill 改為建置期由 registry 複製
  build-harness-skill.mjs=head + registry/skills/write_intent_workflow.md 正文 + tail。
  選「建置期複製」的理由:npm files 只收 harness/,registry 不進套件;
  symlink 在 npm pack 與 Windows 不可靠。產物 commit 進 repo(npm 裝的是產物、不跑 build)。
  head/tail 是 harness 專屬(CLI 語境入口/acr 指令表/暴露同意/誠實鐵律),
  install-harness 的 copyTree 跳過 .head/.tail,不鋪進使用者專案。

■ 其餘三件逐份對照現世代事實後更新(過時的直接刪,不留死代碼)
  - CLAUDE.block.md:補 >> 意圖語法、not_found 兩條路、零件 vs recipe 分型、
    腹語術紅線、金鑰只拿名字
  - commands/arcrun.md:步驟改成「先寫意圖 → 丟去查 → 再寫 YAML」,補 acr search/validate
  - hooks/arcrun-guard.sh:**正路提示改為指向 arcrun-mindset Skill +意圖語法**
    (呼應「hook 沒提 skill 反而把 AI 導向 repo 文件」的教訓);
    新增 code 節點腹語術提醒,settings.fragment 補 Write|Edit|MultiEdit matcher

■ 世代閘(防再度脫節)
  check-harness-generation.mjs 檢查四件交付物的現世代指紋,缺指紋 exit 1,
  掛進 npm run build(prepublishOnly 因此也擋)。
  反向驗證:把 skill/CLAUDE.block 換回上一代 → 兩者都被擋下並逐條點名缺哪個指紋。

■ 驗收(考生 haiku/受測物=環境)
  乾淨臨時目錄 acr install-harness → 四件鋪好;重跑冪等(全檔 md5 不變、
  CLAUDE.md 66 行不變、hooks 條目 2 不變、arcrun 區塊仍 1 個)。
  haiku 只讀該目錄的 CLAUDE.md+SKILL.md(明令禁讀 ~/.claude、禁上網;
  兩份教材 md5 與大小均不同,可證非考本機那支)答十題
  → grade-step1.sh **10 / 10 通過**(判分器同時反向驗證仍會抓
  ON_TRUE/ON_FAILURE/第一節點非 input)。
  npm test 18/18、tsc 綠。

SDD:workflow-discovery/tasks.md 3.11

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 15:29:17 +08:00
Leo e28e19069f t142: 雲端子庫顯示同步幾張卡+幾個三元組(政府專案驗收需求)
leo 07-29:「要在雲端子庫顯示同步了幾個 wiki,既然這樣也同步顯示有幾個三元組,
這是為了政府專案驗收。」

kbdb 兩支統計端點:
- entries.ts: COUNT(DISTINCT page_name) AS card_count
  ⚠️ 必須 distinct——算 block 數會膨脹 3-5 倍,政府驗收看到假數字比沒數字更糟
- records.ts: COUNT(*) AS triplet_count(三元組本來就算全部)
portal.ts 聚合兩者掛進庫目錄;前端 index.html 顯示。

驗:卡數確為 COUNT(DISTINCT page_name)/前端內嵌 JS node --check 全通過
(07-29 白畫面事故教訓)/portal-admin 測試 34 passed(改動前 31 passed,
唯一的 1 failed 是既有債:GET /portal 回 404,改動前後相同,非本次造成)。

註:此為子 CC 完成後未 commit 的懸置工作,總管收工檢查時發現並補收。
2026-07-29 19:55:03 +08:00
Leo cdca296044 D36:修正四個零件契約的假資訊敘述(現行沒有發 API key 的機制)
leo 07-29 指正:「現在沒有 partner key,改用 namespace,現在沒有發 API key 的機制」。
這些契約寫著 'KBDB partner key(ak_xxx)'/'租戶識別(ak_ 前綴)'=假資訊源——
總管就是被它騙的(先把範例改成不存在的 {{credential.kbdb_partner_key}}),
不修的話下一個 AI 會再挖同一個坑。

改:kbdb_upsert_block/auth_oauth2/auth_service_account/auth_static_key 的
api_key description,改述為「租戶識別=Arcrun namespace」+註明 examples 裡的
ak_test/ak_nonexistent 是測試假值不代表真實格式。

只動 description,不動 schema/欄位名/gherkin_tests
(api_key 欄位名保留——改名會破壞現有 workflow)。
驗:四檔 YAML 可解析、required 不變、欄位清單不變、gherkin_tests 全保留、
ak_test 等測試值原封不動。

人閘:leo 07-29 跑 scripts/component-arm.sh 解保險授權。
2026-07-29 19:38:14 +08:00
Leo e8bd518efa D36 第0步:範例 workflow 金鑰統一走 credential(止血——範例是用戶照抄的樣板)
改前四種寫法並存、沒一個是 credential,而執行端只認 {{credential.X}}
(graph-executor.ts:247→resolveCredentialRefs)⇒ 用戶照抄必踩坑:
  {{api_key}} 13/{{gitea_token}} 2/{{secret.GITHUB_BOT_TOKEN}} 2/{{kbdb_api_key}} 1

改後(19 處統一):
  {{credential.arcrun_namespace}} 15/{{credential.gitea_token}} 2/{{credential.github_bot_token}} 2

⚠️ 中途修正一次錯誤命名:我先改成 kbdb_partner_key(照零件契約舊敘述 ak_xxx),
leo 當場指正「現在沒有 partner key,改用 namespace,沒有發 API key 的機制」——
實際機制確為 X-Arcrun-API-Key: <namespace>(實例上活的 workflow 為證)。

不動:{{secret.LEO_TELEGRAM_CHAT_ID}} 3 處——chat_id 是聊天室 ID 不是金鑰,
無腦套規則會引進「找不到 credential」的錯誤。

驗:違規寫法歸零/12 個範例 YAML 全可解析。
殘:kbdb_upsert_block 契約仍寫『KBDB partner key(ak_xxx)』=假資訊源(我就是被它騙的),
修它被 component-guard 擋(正確),需 leo 跑 scripts/component-arm.sh。
2026-07-29 19:33:46 +08:00
uncle6me-web 159f0b07dc 🔴🔴 fix: portal 白畫面——t131 誤刪上一個 IIFE 的收尾 })();
leo:「問題是 portal 是白的」。真因:t131 合併 AI 設定時刪掉舊的 chat-key/extractor 兩區,
**連同上一個 IIFE 的收尾 })(); 一起刪掉** ⇒ 整段 JS 語法錯(Unexpected end of file)
⇒ 瀏覽器整支 script 不執行=白畫面。
**總管失職**:t131 驗收只跑了 vitest(測後端)+grep 字串,**從未驗過前端 JS 語法**——
portal/index.html 是純前端檔,vitest 根本測不到它。
修:補回 })();;node --check 通過;os-split/safejson 測試綠。
2026-07-29 17:45:30 +08:00
uncle6me-web 9d38d580f2 feat(t131): AI 設定合併成一把金鑰——聊天與萃取共用,Claude 為選填加強
leo:「拿到一把就很難了,還要拿兩把。一律規定先輸入 gemini api key,
如果想要強化本地萃取,可以選擇 claude……前面那個,聊天和萃都一次設好,後面那把,不填就是 gemini」
+「重點是更好的模型萃取知識更能抓重點,不然他也不知道加強什麼」(文案講感覺得到的差別)
+「本地如果有裝 claude,掃到,只要一個 checkbox 就好」(daemon 偵測回報,沒偵測到就停用選項)。
- POST/GET /portal/admin/ai(一次寫 chat-key 與 extractor)+/portal/daemon/report-capabilities
- 舊 chat-key/extractor 端點保留相容;UI 兩區合併成「AI 設定」
t131 新測 7 條全綠(總體 229 passed/9 紅皆 pre-existing:HTML shell 搬遷×2、library-map 未實作×6、
executor 零件×1)。(實作=子 CC;驗證+commit=總管)
2026-07-29 15:28:10 +08:00
uncle6me-web 0860e84d22 feat(t135): 庫目錄可自主移除+標示還在不在同步
leo:「需要加移除按鈕。因為別人裝錯我沒辦法幫他弄,需要可以自主」
+「你應該要顯示這個庫沒有本地對應的 folder,那就不容易刪錯」
(兩態非三態——leo 二修:「分兩種沒意義」,那是內部狀態不是用戶分類)。
- DELETE /portal/admin/libraries/:id(登記簿)與 by-name/:name(auto 庫需輸入庫名確認)
- kbdb 加 deprecate-by-library(auto 庫移除=標 deprecated,資料保留可還原)
- daemon/libraries 存 active 清單 → 卡片標 🟢同步中/灰目前沒有在同步
- 不自動刪(daemon 可能沒開機);daemon 從未回報時整列不標
vitest 24 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管。含 t116/t117 先前未 commit 的 graph-executor/wasi-shim 修正)
2026-07-29 14:45:44 +08:00
uncle6me-web 6d4980d3d7 fix(t130 🔴🔴): PORTAL_TEMPLATE_SEEDS 補 triplet——新實例總圖不再永遠空
真兇(總管探針定罪):seeds 只有 portal_user/portal_library,寫三元組回 400
'template not found: triplet' ⇒ 每個新用戶(含封測者)總圖必空。
geek6688 有是舊實例早期流程建過=拿它驗會假綠(已記 mistakes)。
呼叫路徑已驗:daemon/libraries、admin/libraries、init/seed 皆會 ensurePortalTemplates(冪等)。
vitest 24/24 綠(總管親跑)。(實作=子 CC;驗證+commit=總管)
2026-07-29 14:35:36 +08:00
uncle6me-web ccb86481ae fix(t128+t129): 圖搜尋補 template:triplet+AI 問答出處按頁去重
t128 真因(總管實測定罪):t116 只補 kbdb_base,同一行 URL 還吃 {{input.template}}
⇒ /records/by-template/?owner_id=... 查不到;手動補 template 即 count=1
(企業版功能解鎖→控制→授權系統)。**同種病三犯,已記 mistakes。**
t129:一卡切 3-5 block 每段都算一筆命中 ⇒ dedupeSourcesByPage 後端去重+hit_count。
vitest 52 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管)
2026-07-29 14:11:52 +08:00
uncle6me-web eb9f2db513 feat(t122 🔴🔴): 萃取引擎雲端設定+隨連線下發——封測者終於萃得出東西
真兇(總管查證):daemon/config 寫死 extractor='claude' 且不下發金鑰 ⇒ 封測者 100% 萃取失敗
(leo:「地端沒有 AI 根本不能萃,那它就不能玩」)。
- POST/GET /portal/admin/extractor(admin 閘;GET 只回 has_key 不回明文)
- daemon/config 改讀設定:未設定→gemma 無金鑰;設定後→含 gemini_api_key
- portal 設定頁加「萃取引擎」區(Gemini 推薦+aistudio 連結,存後提示「小幫手點連上知識庫重連即可」)
測試 3 條新綠(vitest 17 passed;1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管)
2026-07-29 13:21:26 +08:00
uncle6me-web 429b2d965f fix(t97+t114): 庫目錄只顯示用戶同步進來的庫、拿掉兩段式登記
leo 07-28 原話:「用戶沒加上的庫,不要自作主張給它加上」/「掃進來的就是要進目錄…
加入目錄這件小事還要分兩段做?…這是在攻打用戶嗎?根本是 attack」。
- portal.ts:859 auto 段濾掉 general(系統未標庫桶,非用戶的庫)
- index.html:bootstrap 不再預埋 kb 庫;移除「登記到目錄」按鈕與 auto/registered 視覺分岔
驗證(總管 grep 真相源):登記到目錄=0、kb 種子=0、general 濾在;
os-split 10/10+safejson 8/8 綠;portal-admin 1 紅=console HTML 搬遷陳舊測試
(stash 基線同紅,與本案無關)。(實作=子 CC;驗證+commit=總管)
2026-07-28 23:54:57 +08:00
uncle6me-web f6728974ea fix(t115 🔴🔴🔴 三修): kbdb 認證完全 fail-closed——沒金鑰一律 401(含讀取)
leo 實證的洞=「知道網址即可讀走全部知識」;一修 fail-open(沒設 secret 就不擋)、
二修仍放行讀取=洞沒補。三修(總管手改):無 token→全部 401(health 豁免),
老實例升級路徑=重跑安裝器(同時注入金鑰與新 workflow),不以繼續外洩換相容。
+結構閘測試:斷言 src/index.ts 的無 token 分支不得有 return next()——
擋「測試複本與真實作漂移」那類假綠(本輪正是它抓到二修的複本沒同步)。
kbdb vitest 60/60 全綠(總管親跑)。
2026-07-28 23:52:43 +08:00
uncle6me-web 2ff36962be feat(t103): /health 回 bundle_version(讀 ARCRUN_BUNDLE_VERSION,無 var 回空=老實例)
daemon 比對用(leo:daemon 和雲端是連動的)。vitest 2/2 綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
2026-07-28 16:06:10 +08:00
uncle6me-web e36cd2d990 fix(t95+t96): 查詢 CJK 邊界自動補空白+圖譜節點模糊命中
leo 07-28 實測:「AI協作」(無空白)搜不到;圖譜搜「AI 協作」0 鄰居但總圖有
「AI 協作規範書」節點(「這個搜尋詞來自 Graph View 的一部分,居然搜不到?」)。
- normalizeCjkQuery:CJK↔ASCII 邊界插空白,search q 與 graph 節點名都過
- fuzzyFindNode:精確 0 鄰居時 fallback contains 比對(取最短命中)重查
測試 +18 全綠(vitest 197 passed;9 個既有紅=console HTML 搬遷陳舊測試,與本案無關,
stash 基線對照確認)。B5 分支衝突面已查:僅 line 274 一行。
(實作=子 CC;驗證+commit=總管)
2026-07-28 15:06:07 +08:00
uncle6me-web ba92d10a3f fix(t88): 拿掉庫管理頁「圖譜來源」概念——任何庫都能進總圖
leo 裁定(2026-07-28):「任何的庫都要進到圖譜模式」「給他選擇就是客服問題」
「不要給他選,掃到就能進總圖,也不用停用按鈕」。
移除:標為/取消圖譜來源鈕、庫停用/啟用鈕與 dialog、graph_source tag 與過濾、
說明文字「標了圖譜來源的庫決定誰能用圖譜模式」;後端 API 不動;
用戶管理的「停用」(set-status)未動。diff +6/-33。
按鈕來歷(leo 問):5a16484 第一刀拆分引入(當時 B5 不存在,粗閘是唯一保護);
t52(139d4c5) 補 auto 庫說明。拆的是過期鷹架非錯誤設計。
(實作=子 CC;驗證+commit=總管)
2026-07-28 12:41:00 +08:00
uncle6me-web a909072dc1 feat(t87): portal 兩處顯示「去後綴的乾淨網址」+一鍵複製——連線不再要人抄網址列
leo 07-28 拍板:「一律用網址,把後綴拉掉,以免他貼了說我的網址錯了」。
設定頁「同步小幫手」卡+管理頁「庫目錄管理」各一行:location.origin(無 /portal/#/ 後綴)
+複製鈕(成功短暫顯示「已複製」,失敗 fallback 提示手動選取)。共用同一 helper。
os-split 10/10、safejson 8/8 仍綠(總管親跑)。(實作=子 CC;審查+commit=總管)
2026-07-28 12:04:04 +08:00
uncle6me-web 11e772496f fix(t75 ①): portal 不再把 JSON 解析錯誤噴給使用者+404 說人話
leo 同事實測:存 Gemini 金鑰時畫面出現
「Unexpected non-whitespace character after JSON at position 4」。

根因兩層:
① 前端 15 處無條件 r.json(),但伺服器不一定回 JSON——404 頁/CF 錯誤頁都是 HTML。
   JSON.parse 一爆,錯誤沿 .catch 走到 friendlyErr,而 friendlyErr 最後一行是
    =把任何例外訊息原樣顯示 ⇒ 技術英文直接噴到畫面。
② 真正的原因是 /portal/admin/chat-key 回 404(實例的 cypher 是舊版沒這端點),
   但使用者完全看不出來,只看到一句看不懂的英文。

修:
- 新增 safeJson(r):用 r.text() 再 try/catch parse,解析不了回 {} 不拋錯;15 處改用它
- friendlyErr 收斂:JSON 類錯誤→「伺服器回應異常,請稍後再試」;
  純英文技術訊息→「操作失敗」;我們自己寫的中文訊息才原樣顯示
- 兩處金鑰儲存加 404 專屬提示:「你的知識庫版本還沒有這個功能,請先更新知識庫」
  ——講清楚為什麼與怎麼辦,否則他只看到「儲存失敗」會反覆重試同一件事
- 順手:安裝卡片那處原本 x.d.error 在 x.d 為 undefined 時會再爆一次,補 x.d && 防護

測試 safejson.test.mjs 8/8:404 HTML 不拋錯/空回應/正常 JSON 仍解析得出/
JSON 錯誤不外洩原文且說人話/網路錯誤訊息保留/中文訊息原樣/英文技術訊息收斂。

⚠️ 這只解「不噴技術訊息」;金鑰要真的存得進去仍需實例更新 cypher(②層待辦)。
2026-07-28 01:02:12 +08:00
uncle6me-web 53c6334fd9 fix(portal/t72): 下載頁 OS 分流——Windows 客戶不再拿到 Mac 的 .app
leo 07-27:「客戶是用 windows 的」。此前四處寫死「下載 Mac 版」,
Windows 用戶按下去拿到 .app=按了連到錯的東西(leo 判準:那不算友善)。

改法(施工圖 rag-wave1/windows-build-and-os-split.md §3):
- daemonPick() 依 UA 判 OS,四處共用;判不出來=兩個都給,不替用戶猜
- 判對了也附「不是這個系統?」另一版連結(UA 會判錯,用戶要有路走)
- 擋關話術跟著 OS 走:Mac=右鍵打開/Windows=更多資訊→仍要執行
- daemonBase 新 key,保留 daemonDownload 舊 key 相容(由檔名推目錄)
- 一律走 raw:Mac zip 21MB > jsDelivr 20MB 上限(實測回 File size exceeded)

測試 os-split.test.mjs 10/10:
⚠️ 測試抓到真 bug——iPhone 的 UA 含 'Mac OS X' 會被判成 Mac,
讓手機用戶下載裝不起來的桌面 app。已加 isMobile 排除,手機落到「兩個都給」。

未驗:真 Windows 機器的實際下載與安裝行為(需真機)。
2026-07-27 19:28:43 +08:00
Leo d7fab7c6aa fix(portal): 拿掉登記新庫/設定頁補常駐入口(下載小幫手+AI 金鑰)
leo 07-27 走完安裝後三點回饋:

1) 「登記新庫」是設計錯誤 —— leo:「應該是同步小幫手抓到的庫就顯示在上面,
   沒有人工登記的選項。」人工登記只會製造對不上的空庫。
   移除表單+對應 JS;說明改為「裝好同步小幫手並選好資料夾後,每個資料夾會自動
   成為一個庫出現在下面,不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。」

2) AI 金鑰與下載連結只存在一次性的「還差 N 步」卡片,按了「稍後再說」或裝完就
   再也找不到 —— 換金鑰/換電腦重新下載都沒入口。
   設定頁新增兩個常駐面板:「同步小幫手」(下載)與「AI 問答金鑰」(可隨時更換),
   共用既有 API POST /portal/admin/chat-key。

3) 修 window.ARCRUN_CFG → ARCRUN_CONFIG(與 721 行既有用法一致,原為筆誤)

驗證:ad-nl-create 殘留 0 / st-daemon-dl 2 / st-key-save 2 / ARCRUN_CFG 0
抽出頁面 3 個 script 區塊 node --check 語法通過
2026-07-27 16:26:52 +08:00
uncle6me-web 139d4c5ed1 feat(t52): 資料夾=庫端到端——kbdb 加 /entries/libraries(資料面 distinct 庫);portal 庫目錄合併「登記簿+蓋章自動出現」(auto);auto 庫改安全渲染(無 record_id 不放死按鈕,改給『登記到目錄』)+daemon/libraries 自動登記端點。leo 07-26:地端 2 個資料夾雲端就要 2 個庫 2026-07-26 01:49:43 +08:00
uncle6me-web e7fe83a872 feat(t54): 小幫手憑帳密自取設定——新端點 POST /portal/daemon/config(帳密驗證同 login,回連線設定不含知識內容);portal 清單移除 config.json 下載(3 步→2 步)。leo 07-25:「最好的就是把它的帳密直接輸入」 2026-07-26 00:16:21 +08:00
uncle6me-web 8d19d0b2d7 fix(portal/t53): daemon 下載連結補實——zip 上鏡像(raw 路徑;jsDelivr 20MB 上限擋 21MB 檔)+無下載點時誠實降級不放死連結(交付警察抓到 404 死連結) 2026-07-25 23:44:44 +08:00
uncle6me-web a8d246ebe9 feat(portal/t53): 進站完成安裝清單(leo 07-25:金鑰與 daemon 要在站內做,不然安裝沒完成)——常駐三項清單(下載小幫手/下載 config.json 站內生成/貼 Gemini 金鑰即時啟用)+新端點 POST /portal/admin/chat-key(改寫 tenant rag_chat 的 x-goog-api-key,admin 閘、金鑰不落 log)+session 回 email 供 config 生成 2026-07-25 23:26:44 +08:00
uncle6me-web 11ffd42899 fix(portal/t49): 首登頁補逃生口——『已經有帳號?改用登入』+setup 409 自動切登入帶提示(leo 實撞死路) 2026-07-25 21:12:31 +08:00
uncle6me-web 24f989d818 merge t49: portal 首登建帳+歡迎引導(leo 07-25 令當晚交付;已先行部署 leo 實例驗證) 2026-07-25 20:35:33 +08:00
uncle6me-web 9bbd25fdfc feat(portal/t49): 首登建帳一顆按鈕——auth-status 偵測未初始化→v-firstsetup(console/setup→portal/admin/bootstrap→自動登記預設庫 kb→portal/login 四發連鎖)+進站一次性歡迎卡(下載小幫手/金鑰 → installer /setup)。leo 07-25:『加入的體驗要搞定』——用戶打開專屬網址第一眼就是建帳號,不再回安裝頁等 2026-07-25 20:35:33 +08:00
uncle6me-web 76b89be6d7 merge t36: console 語意搜尋改狀態列(假開關+CLI 指示移除,click handler 一併拆——leo 07-25 口頭核准 merge) 2026-07-25 20:29:40 +08:00
Claude beb0653e15 fix(console/t36): 語意搜尋改狀態列——移除按不動的假開關與 CLI 指示
leo 2026-07-25 定案:語意搜尋預設開啟、不做開關(費用實算後屬雜訊——1 萬張卡規模
下 Vectorize 約 $0.18/月,佔 CF 帳單 3~4%;關掉省幾毛錢卻換來很爛的搜尋體驗)。

設定頁那顆「語意搜尋(vectorize)」開關其實不能按(title 自承「不能遠端改,只如實
顯示狀態」),旁邊還教用戶去部署端改 config.yaml 跑 acr update——對一鍵安裝進來的
用戶那是天書(與 t35 同一種白癡化違規),而且安裝器現在裝機時就把語意索引開好了,
那段指示本身已不成立。

改成單純狀態列:啟用時只說「已啟用,搜尋頁切語意就能用」不給任何操作指示;未啟用
才給一句人話與下一步(重跑安裝流程會補上,已建資料不重來)。

⚠️ 連帶必修:一併移除綁在該開關上的 click handler——留著會讓 $('st-vec-switch')
回 null、addEventListener 當場拋錯,把後面所有綁定(含登出)一起打斷。
驗:三個 script 區塊語法檢查通過、全檔已無 st-vec-switch/st-vec-state 殘留參照。
2026-07-25 08:51:54 +00:00
Leo 5491409003 docs(mcp): README 加「更新 / acr update」段
- acr update:self-hosted 重跑部署、只部署變動 Worker、未變動略過(--force 全部重部)。
- 說明「新裝 vs 更新」差不多同一套流程,拿新零件(如 code)/新版就跑更新。
- 標待核佔位:下載源正從 GitHub codeload 改指 Gitea(Arcrun#4),確切行為待該 PR 定案。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 08:06:28 +00:00
Leo 0617dab70a docs(mcp): README 改為安裝+使用導向,零件投稿拆成 CONTRIBUTING-components.md
- README 補三種前端安裝(claude.ai connector / Claude Code / Claude Desktop),
  MCP URL 以源碼為準 = <origin>/mcp(DEFAULT_MCP_URL、resourceUri)。
- 認證段講 OAuth owner secret 閘 + MCP_STATIC_TOKEN 真祕密路徑,明講明碼-namespace 已移除。
- 工具總覽全用 arcrun_* 現役名(#20 rename)+ kbdb_* 資料層。
- 修正舊 README 兩處連結:.mcp.json url 補 /mcp、inspector 改 /mcp/inspector。
- 零件開發/投稿流程搬到 mcp/CONTRIBUTING-components.md,README 末留連結。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 06:39:04 +00:00
120 changed files with 10059 additions and 1123 deletions
+27
View File
@@ -32,6 +32,33 @@ SDD 協議要求:code 和 SDD 必須同步更新。
EOF
fi
# ── console-ui:對外網址上是不是還跑著舊世代?(2026-08-08)────────────────
#
# 病(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,你要確定不可再犯」):
# 前端改完、commit 了、甚至 wiki 都寫了,但**沒有人把它推上去**——
# 而線上不會報錯,只是繼續展示半個月前的介面。08-08 實測:三個對外網址的
# apiBase/profile 全綠,跑的卻是 07-22 那一代。**組態對 ≠ 世代對。**
#
# 為什麼掛在 Stop:這裡正是 CC 要說「做完了」的那一刻。
# 不連網(每回合都跑),只比對「手上這一代」與「最後一次**通過線上實測**的部署紀錄」
# .deploy-state.json 只在 deploy.mjs 驗過線上後才寫,不是跑過指令就寫)。
# 要問線上真實現況:cd console-ui && npm run verify(那支才連網)。
if [ -d console-ui/scripts ] && command -v node >/dev/null 2>&1; then
LAG="$(cd console-ui && node scripts/verify-live.mjs --offline-lag 2>/dev/null)"
if [ -n "$LAG" ]; then
cat >&2 <<EOF
🕰️ console-ui:手上這一代**還沒送出去過**
$(echo "$LAG" | sed 's/^/ · /')
對外網址不會因此報錯——它只會繼續展示舊介面,而所有只驗組態的檢查都會說它是綠的。
要看線上現在真的在跑哪一代: cd console-ui && npm run verify
要送出去(含推完自動回頭驗線上):cd console-ui && npm run deploy:personal
EOF
fi
fi
# 若有暫存的 tasks.md 變動,提醒 commit
TASKS_DIFF=$(git -C "$(pwd)" status --porcelain -- 'docs/3-specs/**/tasks.md' 2>/dev/null | head -5)
if [[ -n "$TASKS_DIFF" ]]; then
+16
View File
@@ -52,3 +52,19 @@ backup-*.sql
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
.github-public/
wrangler.leo21c.toml
# deploy-all.mjs 產的共用依賴(部署時 npm 安裝 wrangler 等,非 repo 內容)
# 2026-08-07:每次本機跑部署都會冒出來吵未推警察,且含不該進版控的鎖檔
/package.json
/package-lock.json
# console-ui 部署產物(deploy.mjs 依 deploy.targets.json 即時產生,不是原始碼)
console-ui/.staging/
# 「上一次通過線上實測的部署」紀錄——本機事實,不隨 repo 走
# (刻意不進版控:新 checkout 沒有紀錄 ⇒ 狀態未知 ⇒ 該被大聲提醒,而不是繼承別人的綠燈)
console-ui/.deploy-state.json
# Wrangler 本機開發用的密鑰檔——絕不進版控(2026-08-09 補:原本沒被擋,
# 而同目錄有 agent 在動工,一次 git add -A 就會把金鑰推上去)
.dev.vars
**/.dev.vars
+7 -4
View File
@@ -2,7 +2,10 @@
**讓 AI 用的工作流軟體(目前只支援 Claude Code**
> 想先看用它做出來的產品?**[Arcrun RAG](https://git.uncle6.me/Leo/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫),有[線上 demo](https://rag-demo.arcrun.dev/portal) 可直接玩
> 想先看用它做出來的產品?**[Arcrun RAG](https://github.com/youlinhsieh/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫)。
>
> 目前**沒有公開試玩站**(早期那個共用示範站已於 2026-08-08 退場)。想直接看產出長什麼樣,
> 可以看示範知識庫的公開鏡像 [arcrun-rag-demo-knowledge](https://github.com/youlinhsieh/arcrun-rag-demo-knowledge)——純靜態、免登入。
AI 很會寫程式,就要除錯,過程浪費很多 Token 及時間,但絕大部分是重複內容,例如登入認證、存取資料庫等。
@@ -310,7 +313,7 @@ acr update self-hosted:拉新版零件/引擎並重新
acr update --force 強制重部所有 worker(忽略未變動跳過快取)
```
> 給 AI 操盤手:開始前讀 `.claude/rules/06-mindset.md`(或 arcrun-mindset Skill——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。
> 給 AI 操盤手:開始前讀 [`llms.txt`](llms.txt)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。跑過 `acr install-harness` 之後,同一套規範會裝進你的專案(arcrun-mindset Skill)。
---
@@ -333,7 +336,7 @@ MIT
arcrun 的核心架構、WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造:
- **[@richblack](https://github.com/richblack)** — 創始人,產品設計與架構決策
- **[@youlinhsieh](https://github.com/youlinhsieh)** — 創始人,產品設計與架構決策
- **ClaudeAnthropic** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查
歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md)
想投稿零件?看 [CONTRIBUTING-components.md](CONTRIBUTING-components.md)。
+49 -25
View File
@@ -1,41 +1,65 @@
<!-- arcrun-harness:start -->
## 這個專案用 arcrun 做自動化
## 這個專案用 Arcrun 做自動化
當需要「串服務 / 排程 / 打 API / 資料處理自動化」時,用 arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
當需要「串服務/排程/打 API資料處理自動化」時,用 Arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。
Arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
arcrun 只有三種東西,先分清楚就不會做歪:
⚠️ **不要上網搜 Arcrun 文件**——網路上沒有,找到的都是錯的。
語法與範本全在 **arcrun-mindset** Skill 裡,`acr` 指令查得到現況。
### 怎麼開始:先寫「意圖」,不要先寫 YAML
你**不需要先知道有哪些零件**。把需求寫成一串「誰接誰」,丟去查,系統會告訴你哪些存在:
```
input >> ON_SUCCESS >> fetch_rows
fetch_rows >> 對每個 row >> notify
```
- 第一個節點固定是 `input`(觸發時帶進來的資料)
- **邊有這些**`ON_SUCCESS``對每個 <變數>`FOREACH)、`ON_TRUE``ON_FALSE`(配 `if_control`)、`ON_BRANCH``branch:`(配 `switch``try_catch`
- **沒有** `ON_FAILURE`——要處理失敗用 `try_catch` `ON_BRANCH(catch)`
完整語法、四份實跑過的範本、節點命名慣例 → 讀 **arcrun-mindset** Skill。
### Arcrun 只有三種東西,先分清楚就不會做歪
| 東西 | 是什麼 | 你能做的 |
|---|---|---|
| **工作流(workflow** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
| **recipe** | 打「一個固定外部 API」的設定(http_request + endpoint/header/body 模板) | 自由寫、**而且該投稿**(見下) |
| **零件(component** | WASM 程式(流程控制 / 資料處理 / http_request / auth),固定一小套 | **你不自製**由維護者管,走 GitHub PR |
| **工作流(workflow** | 把零件recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
| **recipe** | 打「一個固定外部 API」的設定(endpointheaderbody 模板) | 自由寫、**而且該投稿** |
| **零件(component** | WASM 程式(流程控制資料處理`http_request`auth),固定一小套 | **你不自製** PR |
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制資料處理通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
>
> ⚠️ 常見錯誤:把 `telegram_send``gmail_send``kbdb_get` 當**零件**寫。它們是 **recipe**。
### 三個最常踩的坑(讀懂這三條,就不會像前人誤解四輪)
### 查詢回 `not_found` 時:兩條路,都不准改寫成 code
**① recipe 是公共資產,發現缺的就自己做一個投稿,不必問人。**
`acr recipe push` = 把 recipe **投稿到公共庫**,不是存私人腳本。公開/私有只是 recipe 的一個屬性(同一身份可有公私版本並存),不是兩條不同的路。
→ 所以「想打某個 API 但沒有現成 recipe」時,**正解是自己寫一個 recipe 並 push 上去**(會 inject credential、push 時幫你檢查打不打得通)。這是被鼓勵的,別誤以為要自用、不上傳。
查詢會誠實回報(兩庫都查過才點名):
**② 缺一個能力 → 去補 APIcypher endpoint),不准用 recipe / 多步工作流拼裝出來。**
判準口訣:**「這段邏輯換一個介面(CLI→MCP)要不要重寫?要重寫 → 它是『能力』,該長在 API。」**
- ❌ 缺 `upsert` → 在工作流裡拼「先查、沒有再建」、或寫個 recipe 假裝補上。
- ✅ 缺 `upsert` → 去 API 加一個 `upsert` endpointCLI/MCP/recipe 都呼叫它。
recipe 只負責「打一個固定外部 API」這件單純事;它不是用來補 arcrun 自己缺的能力的。缺能力就回報 / 補在 API,不要繞。
| status | 意思 | 你該做什麼 |
|---|---|---|
| `found` / `resolved` | 有現成的可用 | **只填 payload** |
| `not_found` | 零件庫與 recipe 庫都沒有 | 照回應的 `suggestion` 走(見下兩條路),並看 `similar_components``similar_recipes` 有沒有能直接用的 |
| `unknown` | 查不到 registry(未部署/網路失敗) | **不代表不存在**,別據此改寫成 code |
**③ 已經有自製零件(例如 mira 的那幾個)→ 讓它退場,別再加新的。**
你不該自製零件;既有的自製零件要往這三條退場:
- `claude_api` 之類「工作流回頭叫 LLM」→ **刪掉**,需要 AI 判斷時是**你(操盤的 CC)自己做**,再叫工作流做確定性的下一步。arcrun 是 AI 用的工具,不是工具回頭用 AI
- `kbdb_*` 之類資料存取 → 改走已備好的 **`acr kbdb` 薄殼 / `kbdb_*` MCP 工具**template + record 模型),不要當零件。
- 純粹打某個固定外部 API 的假零件 → **改寫成 recipe** 投稿(見①)。
- **缺外部 API** → **自己寫一個 recipe**`acr recipe push`(幾行 YAML,不用部署 Worker、不用寫程式)。
recipe 是公共資產,發現缺的就補一個投稿,不必問人。
- **缺計算能力**(加解密/壓縮這類純運算) → 投稿**零件 PR**(要人類確認,罕見)
🔴 **查不到就改寫成 `code` 節點 =「腹語術」**(表面用 Arcrun、實際全寫 JS)。
`code` 只用於**局部整形**(例:剝掉 LLM 回應的雜訊、切段落),不用來取代零件與流程控制。
> 實錄:每一個寫進 `code` 的 `if` 都是沒被測過的新 bug;零件的價值是「被測過 1000 次」,寫進 code 就歸零。
### 其餘鐵律
- **先查能力再動手**`acr parts`(看可用零件)、`acr auth-recipe list`(看支援的認證服務)、`acr kbdb`(資料存取)。
- **暴露資料要人類同意**:部署對外 webhook / push recipe 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx / trace 為證,不口頭宣布
- **先查能力再動手**`acr search <關鍵字>`(一次掃零件/recipeauth-recipeworkflow)、
`acr parts`(零件)、`acr recipe list`recipe)、`acr auth-recipe list`(支援的認證)
- **需要 AI 判斷時你自己做**,不要讓工作流回頭呼叫 LLM。Arcrun 是 AI 用的工具,不是工具回頭用 AI
- **金鑰只拿名字**:定義裡只寫 `{{credential.<名字>}}`,真身絕不寫進 workflowrecipe 檔案。
- **暴露資料要人類同意**`acr push``acr recipe push` 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx/trace 為證,不口頭宣布。
開始前讀 **arcrun-mindset** Skill(世界觀)。使用者技術細節交給你,CLI 操作你來做。
開始前讀 **arcrun-mindset** Skill意圖語法+範本+世界觀)。使用者技術細節交給你,CLI 操作你來做。
<!-- arcrun-harness:end -->
+49 -15
View File
@@ -1,26 +1,60 @@
# 用 arcrun 完成這個自動化需求
# 用 Arcrun 完成這個自動化需求
使用者想做一個自動化。你的任務:用 arcrun 做出來,全程不要讓使用者自己寫程式。
使用者想做一個自動化。你的任務:用 Arcrun 做出來,全程不要讓使用者自己寫程式。
⚠️ **不要上網搜 Arcrun 文件**(網路上沒有)。先讀 **arcrun-mindset** Skill,再用 `acr` 指令查現況。
## 鐵則
- **用 arcrun 工作流 / recipe,絕不自己寫 Python/Node 腳本。** 使用者選 arcrun 就是不想要一次性腳本。
- 打外部 API → 寫 recipe`acr recipe push`),不自刻 HTTP client。
- 不自製零件(WASM)—— 零件由 arcrun 維護。你能用的是現有零件 + recipe + 工作流。
- 需要 AI 判斷時你自己做,不要讓工作流回頭呼叫 LLM。
- **用 Arcrun 工作流recipe,絕不自己寫 Python/Node 腳本。** 使用者選 Arcrun 就是不想要一次性腳本。
- **打外部 API → 寫 recipe**`acr recipe push`),不自刻 HTTP client。缺 recipe 就自己補一個,不必問人。
- **不自製零件(WASM**——零件由 Arcrun 維護。你能用的是現有零件 recipe 工作流。
- **需要 AI 判斷時你自己做**,不要讓工作流回頭呼叫 LLM。
- 🔴 **查不到零件就改寫成 `code` 節點 = 腹語術**,禁止。缺 API 寫 recipe、缺能力投稿零件。
## 步驟
1. 先讀 **arcrun-mindset** Skill(世界觀 + 資源去哪取)。
2.`acr parts` 看零件、`acr auth-recipe list` 看支援的認證。**先查再動手。**
3. 把使用者需求拆成工作流(哪些零件、什麼順序、什麼條件),寫成 `.yaml`
4. 需要 credentialAPI key / token)→ 用 `acr auth-recipe scaffold <service>` 看要哪些,
明確告訴使用者去哪取得、怎麼 `acr creds push`
5. `acr validate` 通過後 `acr push` 部署,告訴使用者 webhook URL / 怎麼 `acr run`
6. 完成給客觀證據(HTTP 2xx / trace),不要只說「做好了」。
## 遇到要暴露資料(對外 webhook)
### 1. 先寫「意圖」,不要先寫 YAML
把使用者的需求寫成一串「誰接誰」(**不必是真實零件名**,用你想得到的名字即可):
```
input >> ON_SUCCESS >> fetch_rows
fetch_rows >> 對每個 row >> notify
```
- 第一個節點固定是 `input`
- 邊有 `ON_SUCCESS``對每個 <變數>`FOREACH)、`ON_TRUE``ON_FALSE`(配 `if_control`)、`ON_BRANCH``branch:`(配 `switch``try_catch`);**沒有** `ON_FAILURE`
- 需要判斷 → 用條件邊(`if_control``ON_TRUE``ON_FALSE`),不要寫 code 判斷
語法細節、四份實跑過的範本、節點命名慣例 → **arcrun-mindset** Skill。
### 2. 丟去查,讓系統告訴你有什麼
`acr search <關鍵字>` 一次掃零件/recipeauth-recipeworkflow
或把意圖串丟 `/cypher/search`,逐節點拿 `found` / `resolved` / `not_found` / `unknown`
- `found``resolved`**只填 payload**
- `not_found` → 照回應的 `suggestion` 走(缺 API 寫 recipe、缺計算能力投稿零件),
並看 `similar_components``similar_recipes` 有沒有現成能用的
- `unknown`**不代表不存在**,別據此改寫成 code
### 3. 把意圖變成 workflow YAML
節點填上查到的真實零件/recipe + payload。
需要 credential 時:`acr auth-recipe scaffold <service>` 看要哪些,明確告訴使用者去哪取得、怎麼 `acr creds push`
🔑 定義裡只寫 `{{credential.<名字>}}`**真身絕不寫進檔案**。
### 4. 驗證 → 部署 → 給證據
```bash
acr validate <workflow>.yaml # 先驗
acr push <workflow>.yaml # 部署(暴露動作,見下)
acr run <workflow> # 觸發一次
acr logs <workflow> # 看執行紀錄
```
完成要給客觀證據(HTTP 2xx/trace),不要只說「做好了」。
## 遇到要暴露資料(對外 webhookrecipe 投稿)
停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。不要替他決定公開。
非互動環境下把完整指令印給使用者自己貼上跑。
## 還沒設定好 arcrun
## 還沒設定好 Arcrun
`acr` 指令不存在或還沒 `acr init`:先帶使用者完成前置設定
(裝 CLI → 拿 Cloudflare 帳號的兩串憑證 → `acr init --self-hosted`)。
拿 Cloudflare 憑證時用白話照抄式引導,不要對使用者講 KV / Worker / R2 等術語。
+19 -5
View File
@@ -66,7 +66,7 @@ if echo "$CMD" | grep -qE "acr (push|recipe push)\b"; then
if echo "$EXEC_PART" | grep -qE "(^|[;&|][[:space:]]*)acr[[:space:]]+(push|recipe[[:space:]]+push)\b"; then
if [ ! -t 0 ] && [ "${ARCRUN_HUMAN_CONFIRMED:-}" != "1" ]; then
block "在非互動環境自動執行暴露動作(acr push / recipe push 會讓東西可被外部呼叫)" \
"交人類在終端機執行(真 TTY 會自動放行)。可把指令完整複製給使用者貼上自己跑:\`acr push <你的 workflow.yaml>\`。或使用者先在對話明示同意後親自於終端機執行。不要替使用者決定公開。"
"交人類在終端機執行(真 TTY 會自動放行)。可把指令完整複製給使用者貼上自己跑:\`acr push <你的 workflow.yaml>\`。或使用者先在對話明示同意後親自於終端機執行。不要替使用者決定公開。(部署前的正路見 arcrun-mindset Skill:先 \`acr validate\`"
fi
fi
fi
@@ -76,15 +76,29 @@ fi
if echo "$CMD" | grep -qE "(^|[;&| ])(python3?|node)[ ]+[^ ]+\.(py|js|mjs|ts)\b"; then
# 排除明顯的測試 / 既有工具呼叫(pytest / npm test / jest 等)降低誤判
if ! echo "$CMD" | grep -qE "(pytest|jest|vitest|npm (run )?test|mocha|\btest_)"; then
remind "偵測到用 python/node 跑腳本。這專案用 arcrun,串服務/自動化不要自刻一次性腳本。" \
"先跑 \`acr parts\` 看有哪些零件,把需求寫成 workflow.yaml 用 \`acr run\`。若這確實不是自動化(例如跑測試/別的工具),忽略本提醒。"
remind "偵測到用 python/node 跑腳本。這專案用 Arcrun,串服務/自動化不要自刻一次性腳本。" \
"讀 arcrun-mindset Skill,先把需求寫成「意圖」串(\`input >> ON_SUCCESS >> <下一步>\`,邊只有 ON_SUCCESS 與「對每個 X」),再用 \`acr search <關鍵字>\` 哪些零件/recipe 存在,最後才寫 workflow.yaml → \`acr validate\` → \`acr run\`。若這確實不是自動化(例如跑測試/別的工具),忽略本提醒。"
fi
fi
# ── 提醒(不硬擋):自寫打固定 API 的 script,而非 recipe ──────────────
if echo "$CMD" | grep -qE "(curl|fetch|requests\.(get|post)|axios).*https?://"; then
remind "偵測到自己打外部 API。arcrun 裡「打固定 endpoint」應寫成 recipe,不自刻 HTTP 呼叫。" \
" \`acr recipe push\` 把這個 API 包成 recipeworkflow 裡用 component 引用它。見 arcrun-mindset Skill。"
remind "偵測到自己打外部 API。Arcrun 裡「打固定 endpoint」應寫成 recipe,不自刻 HTTP 呼叫。" \
" \`acr recipe search <服務名>\` 看有沒有現成的;沒有就自己寫幾行 YAMLcanonical_id/endpoint/method/auth_service)用 \`acr recipe push\` 投稿,workflow 裡用 \`http_request\` 該 recipe 引用它。缺 recipe 就自己補,不必問人。寫法見 arcrun-mindset Skill。"
fi
# ── 提醒(不硬擋):把 code 節點當成缺零件的替代品(「腹語術」)──────────────
# 查詢回 not_found 就改寫成 code = 表面用 Arcrun、實際全寫 JS。這是現世代最常見的走歪。
if [ "$TOOL" = "Write" ] || [ "$TOOL" = "Edit" ] || [ "$TOOL" = "MultiEdit" ]; then
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // ""')
if echo "$FILE" | grep -qE '\.(ya?ml)$' && echo "$CONTENT" | grep -qE 'component:[[:space:]]*["'"'"']?code\b'; then
# 只在 code 內容看起來在做流程控制/取代零件時提醒(含 if/for/fetch),單純整形不吵
if echo "$CONTENT" | grep -qE '\b(if[[:space:]]*\(|for[[:space:]]*\(|fetch\(|await[[:space:]]+fetch)'; then
remind "workflow 裡的 \`code\` 節點含流程控制/HTTP 呼叫——這可能是「腹語術」(表面用 Arcrun、實際全寫 JS)。" \
"\`code\` 只用於局部整形(例:剝掉 LLM 回應的雜訊、切段落)。缺外部 API → 寫 recipe\`acr recipe push\`);缺計算能力 → 投稿零件 PR;要判斷 → 用條件邊(\`if_control\` 配 \`ON_TRUE\`\`ON_FALSE\`,或 \`switch\`\`try_catch\` 配 \`ON_BRANCH\`),不要寫 code 判斷。每個寫進 code 的 if 都是沒被測過的新 bug。見 arcrun-mindset Skill。"
fi
fi
fi
exit 0
+10
View File
@@ -10,6 +10,16 @@
"timeout": 5
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/arcrun-guard.sh",
"timeout": 5
}
]
}
]
}
+245 -38
View File
@@ -1,78 +1,285 @@
---
name: arcrun-mindset
description: >-
arcrun 的世界觀 — 用 arcrun 開發自動化時的預設心態 + 資源去哪取。當你(AI 操盤手)要在
arcrun 上做任何事(串服務、處理資料、認證、把東西開放給人用)前讀這個。它讓你做出「方向對」
的選擇、知道資源在哪,避免技術上能跑但架構上錯、或自己重刻 arcrun 已有的東西。
在 Arcrun 上做任何事時使用(用戶說「幫我用 Arcrun 做 X」「用 arcrun 寫一個工作流」「把 X 自動化」)。
Arcrun 是跑在 Cloudflare 上的工作流引擎——你用 `>>` 寫「意圖」,系統告訴你有哪些現成零件與 recipe,
你只填 payload,不必自己寫程式。**不要上網搜 Arcrun 文件**(網路上沒有),也不要自己猜 YAML 格式:
先讀本 skill,再用 `acr` 指令(或 MCP 工具)查現成零件。
涵蓋:意圖工作流語法、四份實跑過的範本、零件 vs recipe 的分別、缺件的兩條路、已知的坑。
---
# arcrun mindset(給 AI 操盤手)
# Arcrun:怎麼寫意圖工作流
你在 arcrun 上幫使用者開發自動化。arcrun 很簡單,簡單到你常會把它想複雜、或退回自己熟悉的
Python/Node 自刻。這份幫你在岔路上選對方向,並告訴你資源在哪
> **你已經配備 Arcrun**(此專案裝了 `acr` CLI,可能另有 `arcrun_*` MCP 工具)。
> **別上網找文件**——網路上沒有 Arcrun 的文件,找到的都是錯的。答案都在本 skill 與 `acr` 指令裡
## 先做這三件(照順序)
1. `acr whoami` — 確認連到哪個帳號(**勿自行 curl 猜帳號 URL**
2. 讀本 skill 下面的語法與範本 → 寫出 `>>` 意圖
3. `acr parts``acr recipe list`(或 `acr search <關鍵字>` 一次掃全部)— 確認零件與 recipe 真的存在
**卡住時**`acr search <關鍵字>` 跨類搜尋;有 MCP 就 `arcrun_get_skill('INDEX')` 拿全館導航。
---
## 0. 一句話世界觀
**arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 = 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
你大部分時間在寫紙、改紙,不是在造新零件、也不是自己寫腳本。
**Arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
你大部分時間在**寫紙、改紙**,不是在造新零件、也不是自己寫腳本。
**Arcrun 只有三種東西,先分清楚就不會做歪:**
| 東西 | 是什麼 | 你能做的 |
|---|---|---|
| **工作流(workflow** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
| **recipe** | 打「一個固定外部 API」的設定(endpointheaderbody 模板) | 自由寫、**而且該投稿**(缺就自己補) |
| **零件(component** | WASM 程式(流程控制/資料處理/`http_request`auth),固定一小套 | **你不自製**,走 PR 由維護者管 |
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
---
## 1. 工作流是 default,不要退回自己寫 Python
<!-- 以下正文由 registry/skills/write_intent_workflow.md 於建置期複製而來(單一真相源)。
不要直接編輯本段——改 registry 那份,然後跑 `npm run build:harness`。 -->
使用者選 arcrun,就是不要「每次重刻、跑完即丟」的腳本。所以你的預設順序:
## 1. 意圖工作流的語法
1. **先想能不能用工作流做**(串現有零件 / recipe + 流程控制)。99% 可以。
2. 要打的服務有 HTTP API、但沒有對應 recipe → **寫一個 recipe**http_request + 固定設定 YAML,不用部署、不用審核)。
3. **只有**封閉純邏輯(流程控制 / 資料處理)、現有零件不夠、且值得全 arcrun 重用 → 才考慮零件(而零件走 PR,不是你現在做)。
一串「誰接誰」,每行一個關係:
> 典型走歪:「我先用 Python 測一下」。停。使用者要的是 arcrun 工作流。先 `acr parts` 看有什麼,用工作流串。
```
<節點A> >> <邊> >> <節點B>
```
## 2. 資源去哪取(不要自己重造 arcrun 已有的)
- **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名**
- **邊**=什麼情況下往下走
## 2. 邊有這些
| 邊 | 意思 | 真例 |
|---|---|---|
| `ON_SUCCESS` | 上一步成功就往下 | `input >> ON_SUCCESS >> prep` |
| `對每個 <變數>` | 上一步產出清單,逐項處理(FOREACH)| `parse_card >> 對每個 block >> post_block` |
| `ON_TRUE` / `ON_FALSE` | 條件成立/不成立各走一條(配 `if_control`| `判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram` |
| `ON_BRANCH``branch:` | 依標籤選路(配 `switch` 每個 case、`try_catch` 的 try/catch| `my_switch >> ON_BRANCH(branch_active) >> 處理啟用` |
### 2.1 條件分支怎麼寫(2026-08-01 起引擎支援)
**需要判斷時,用分支邊,不要寫 `code` 判斷。**
三顆流程控制零件都輸出 `data.branch` 標籤,引擎依標籤選路:
| 零件 | 輸出的標籤 | 接法 |
|---|---|---|
| `if_control` | `"true"` / `"false"` | `ON_TRUE``ON_FALSE` 各一條 |
| `switch` | 你在 `cases[].branch` 取的名字(沒中則 `default_branch`| 每條路一條 `ON_BRANCH`,邊上標 `branch` |
| `try_catch` | `"try"`(沒錯)/`"catch"`(有錯)| 兩條 `ON_BRANCH`,標 `try``catch` |
```
判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram
判斷有沒有新資料 >> ON_FALSE >> 結束
```
中文語意詞亦可:「成立時」=`ON_TRUE`、「否則」=`ON_FALSE`
💡 **不必背**:查零件時回應會附 `branch_hint`(有哪些標籤、用哪些邊型、可照抄的範例),
照著接就對了。
⚠️ 仍然**不要寫 `ON_FAILURE`**(沒有這種邊;要處理失敗用 `try_catch` `ON_BRANCH(catch)`)。
### 2.2 怎麼確認分支真的走對了(**別看不懂就以為壞掉**)
分支工作流「有沒有成功」看兩件事,**不是看某條沒走的路沒有輸出**:
1. **`verdict`**`GET /workflows/<name>/executions?limit=1`
`data.executions[0].verdict === "success"` 就是成功了。
2. **`trace` 裡有沒有出現該走的節點**:走 TRUE 路時 FALSE 路的節點**本來就不該出現**
——**那是正確行為,不是失敗**。
```
# 條件成立 → 只有 true 那條的節點在 trace
{"amount": 5000} → if_control 回 branch="true" → 走 ON_TRUE 那條
{"amount": 100} → if_control 回 branch="false" → 走 ON_FALSE 那條
```
🔴 **實撞(2026-08-01 考試)**:有考生的分支工作流**其實完全正常**
`amount=5000`→true、`amount=100`→false 都對),但它以為「跑不通」而放棄改寫成 code。
**看到只有一條路有輸出=分支正在正確運作**,不要因此判定失敗。
## 3. 第一個節點固定是 `input`
所有真範本都以 `input` 起頭——那是「觸發時帶進來的資料」。
---
## 4. 真範本(照抄結構、改內容)
> 以下四份**全部是實際部署且 `verdict=success` 的 workflow**,不是簡化示範。
> 用 `acr logs <name>`(有 MCP 則 `arcrun_get_workflow(<name>)` 可以拿完整定義。
### A. 最短:取資料 → 處理 `graph_neighbors`
```
input >> ON_SUCCESS >> fetch_triplets
fetch_triplets >> ON_SUCCESS >> bfs_neighbors
```
### B. 長鏈:多次查詢 → 組裝 → 問 AI → 收尾 `rag_chat`
```
input >> ON_SUCCESS >> prep
prep >> ON_SUCCESS >> kw_search
kw_search >> ON_SUCCESS >> sem_search
sem_search >> ON_SUCCESS >> fetch_triplets
fetch_triplets >> ON_SUCCESS >> fetch_blocks_a
fetch_blocks_a >> ON_SUCCESS >> assemble
assemble >> ON_SUCCESS >> ask_llm
ask_llm >> ON_SUCCESS >> finalize
```
`prep` 前處理/`assemble` 組 prompt`finalize` 收拾回應——三個常見的整形節點。
### C. 一節點分岔兩條 FOREACH `rag_ingest_card`
```
input >> ON_SUCCESS >> parse_card
parse_card >> 對每個 block >> post_block
parse_card >> 對每個 rel >> post_triplet
```
同一節點可有多條出邊,各自處理不同清單。
### D. 混合:直線 兩段 FOREACH `rag_takedown_direct`
```
input >> ON_SUCCESS >> prep
prep >> ON_SUCCESS >> list_dead_blocks
list_dead_blocks >> ON_SUCCESS >> build_deprecations
build_deprecations >> 對每個 dead_entry >> deprecate_entry
build_deprecations >> ON_SUCCESS >> list_triplets
list_triplets >> ON_SUCCESS >> pick_dead_triplets
pick_dead_triplets >> 對每個 dead_record >> deprecate_triplet
```
`build_deprecations` 同時有 FOREACH 出邊與 `ON_SUCCESS` 出邊——
前者處理清單、後者繼續主線。
---
## 5. 節點怎麼命名(照真範本的模式,查詢較容易媒合)
| 意圖 | 模式 | 真例 |
|---|---|---|
| 前處理/正規化 | `prep` | `rag_chat.prep` |
| 取一批資料 | `fetch_*``list_*` | `fetch_triplets``list_dead_blocks` |
| 搜尋 | `*_search` | `kw_search``sem_search` |
| 解析/切塊 | `parse_*` | `parse_card` |
| 寫入 | `post_*` | `post_block``post_triplet` |
| 組裝 | `assemble``build_*` | `assemble``build_deprecations` |
| 問 AI | `ask_llm` | `rag_chat.ask_llm` |
| 收尾整形 | `finalize` | `rag_chat.finalize` |
---
## 6. 寫完一定要查(**不要直接部署**)
```bash
curl -s -X POST https://arcrun-cypher-executor.<subdomain>.workers.dev/cypher/search \
-H 'content-type: application/json' -H 'X-Arcrun-API-Key: <namespace>' \
-d '{"triplets":["input >> ON_SUCCESS >> fetch_data","fetch_data >> ON_SUCCESS >> notify"]}'
```
回應的每個節點會有:
| status | 意思 | 你該做什麼 |
|---|---|---|
| `found` | 有這個節點。`source: component``input_schema`(怎麼填 payload)與 `success_rate``source: recipe` 附 description/endpoint | **只填 payload** |
| `not_found` | **兩庫(零件 registry+recipe 庫)都查過,確定沒有** | 照 `suggestion` 欄走:缺 API → 寫 recipeskill `write_recipe`);缺計算能力 → 投稿零件 PR(skill `add_new_wasm_component`)。`similar_components`/`similar_recipes` 是相近候選——先看有沒有現成的能直接用 |
| `unknown` | 查不到 registry | **不代表不存在**,別據此改寫成 code |
> 註(2026-07-31):`/cypher/search` 曾對任何節點名都回假 `found`,已修為真查兩庫。
> 舊實例(未更新部署)仍可能假 found——status 可信度以該實例部署版本為準。
---
## 7. 常犯的錯
1. **用不存在的邊**`ON_FAILURE`)→ 沒有這種邊;要處理失敗用 `try_catch` `ON_BRANCH(catch)`
⚠️ `ON_TRUE``ON_FALSE``ON_BRANCH` **是存在的**2026-08-01 起),見 §2.1——
本行以前寫「ON_TRUE 不存在」是舊世代,已更正
2. **第一個節點不是 `input`**
3. **把 recipe 當零件寫**——`telegram_send``gmail``kbdb_get`**recipe** 不是零件
→ 寫成 `http_request` 該 recipe
4. 🔴 **查詢回 `not_found` 就改寫成 `code` 節點**
→ 那叫「腹語術」(表面用 Arcrun、實際全寫 JS)。正解:缺 API 寫 recipe、缺能力投稿零件。
`code` 只用在**局部整形**(例:剝掉 LLM 回應的雜訊),不用來取代零件與流程控制。
---
## 8. 相關
- 完整版指引與十題考卷(含 haiku 實測 10/10):
頂層 repo `system-dev/docs/3-specs/arcrun-usable/`
- 下一步該讀哪支 skill(需 MCP):`arcrun_list_skills()`
- 定期掃資料 → `build_watcher_workflow`
- RAG 檢索問答 → `rag_with_arcrun`
- workflow 卡住不動 → `debug_paused_workflow`
---
## 9. 資源去哪取(不要自己重造 Arcrun 已有的)
| 你想知道 | 跑這個 |
|---|---|
| 有哪些零件可用 | `acr parts` |
| 某零件的設定範本 | `acr parts scaffold <name>` |
| 有哪些 recipe | `acr recipe list``acr recipe search <關鍵字>` |
| 支援哪些服務的認證 | `acr auth-recipe list` |
| 某服務認證要哪些 credential + 範例 | `acr auth-recipe scaffold <service>` |
| 已上傳的 recipe | `acr recipe list` |
| 某服務認證要哪些 credential 範例 | `acr auth-recipe scaffold <service>` |
| **一次掃全部**(零件/recipeauth-recipeworkflow | `acr search <關鍵字>` |
| 已部署的 workflow | `acr list` |
| 某次執行為什麼失敗 | `acr logs <workflow>` |
| 工作流語法、指令 | `acr --help` |
**先查再動手**——arcrun 多半已經有你要的零件 / recipe / 認證,不要自刻。
**先查再動手**——Arcrun 多半已經有你要的零件recipe認證,不要自刻。
## 3. arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
## 10. 做出來以後:驗證 → 部署
需要智慧判斷 / 自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
**不要在工作流中間放零件回頭呼叫 LLM**。arcrun 的大腦就是操盤的你。
```bash
acr validate <workflow>.yaml # 先驗,別直接部署
acr push <workflow>.yaml # 部署(暴露動作,見 §12
acr run <workflow> # 觸發一次,看實際結果
acr logs <workflow> # 看執行紀錄/失敗原因
```
## 4. arcrun 不替你做授權判斷
需要 credentialAPI keytoken)時:`acr auth-recipe scaffold <service>` 看要哪些,
明確告訴使用者去哪取得、怎麼 `acr creds push`
🔑 **金鑰只拿名字**workflowrecipe 裡只寫 `{{credential.<名字>}}`
**真身絕不寫進定義檔**(執行前才由系統回填)。
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 arcrun 的 bug、不是你做錯**。
不要在 arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
## 11. Arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
## 5. 把東西開放給別人用 = 要使用者明示同意
需要智慧判斷/自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
**不要在工作流中間放零件回頭呼叫 LLM**——Arcrun 的大腦就是操盤的你。
(唯一例外:`ask_llm` 這種「內容生成本身就是流程的一步」,見範本 B。)
部署對外 webhook、push recipe 會讓資料/能力**可被外部呼叫**(暴露面):
## 12. 把東西開放給別人用 = 要使用者明示同意
`acr push`(部署 workflow)與 `acr recipe push`(投稿 recipe)會讓資料/能力**可被外部呼叫**:
- 停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。**不替他決定公開。**
- 非互動環境(你直跑)遇到 → 停,要人類確認,絕不自己塞 confirm 假裝同意。
- arcrun 可提供保護(要求呼叫者帶 key / 限流)——提醒使用者。
- 非互動環境(你直跑)遇到 → 停,把完整指令印給使用者自己貼上跑,絕不自己塞 confirm 假裝同意。
- Arcrun 可提供保護(要求呼叫者帶 key限流)——提醒使用者。
## 6. 誠實(最重要)
## 13. Arcrun 不替你做授權判斷
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 Arcrun 的 bug、不是你做錯**。
不要在 Arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
## 14. 誠實(最重要)
- **不假綠**:沒打通就誠實說。缺 credential 打不到 2xx → 標「未驗收:缺 X」,不 mock 充綠燈。
- **不假裝防偽 / 不代替人類確認**有風險的動作(暴露資料)。
- **完成 = 客觀證據**HTTP 2xx + trace),不是口頭「做好了」。
- **不假裝防偽不代替人類確認**有風險的動作(暴露資料)。
- **完成 客觀證據**HTTP 2xx trace),不是口頭「做好了」。
---
## 怎麼用這份 mindset
## 動手前的自檢清單
每次準備動手,先過一遍:
1. 這能用工作流 / recipe 做嗎?(多半能 → 別自己寫 Python、別造零件)
2. 我查過 `acr parts` / `acr auth-recipe` 了嗎?(arcrun 可能已有
3. 我是不是讓工作流回頭呼叫 AI?(是 → 改成我自己做)
4. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
5. 我有沒有假裝(假綠 / 假防偽 / 代替人類確認)?(有 → 停,誠實標明)
1. 我把意圖寫成 `>>` 串了嗎?(還是直接跳去寫 YAML/寫程式)
2. 我查過 `acr search` / `acr parts` / `acr recipe list` 了嗎?
3. 查詢回 `not_found` 時,我走的是 recipe/零件 PR 兩條路,**還是偷偷改寫成 `code`**?(後者=腹語術
4. 我是不是讓工作流回頭呼叫 AI 做判斷?(是 → 改成我自己做)
5. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
6. 我有沒有假裝(假綠/假防偽/代替人類確認)?(有 → 停,誠實標明)
@@ -0,0 +1,41 @@
---
name: arcrun-mindset
description: >-
在 Arcrun 上做任何事時使用(用戶說「幫我用 Arcrun 做 X」「用 arcrun 寫一個工作流」「把 X 自動化」)。
Arcrun 是跑在 Cloudflare 上的工作流引擎——你用 `>>` 寫「意圖」,系統告訴你有哪些現成零件與 recipe,
你只填 payload,不必自己寫程式。**不要上網搜 Arcrun 文件**(網路上沒有),也不要自己猜 YAML 格式:
先讀本 skill,再用 `acr` 指令(或 MCP 工具)查現成零件。
涵蓋:意圖工作流語法、四份實跑過的範本、零件 vs recipe 的分別、缺件的兩條路、已知的坑。
---
# Arcrun:怎麼寫意圖工作流
> **你已經配備 Arcrun**(此專案裝了 `acr` CLI,可能另有 `arcrun_*` MCP 工具)。
> **別上網找文件**——網路上沒有 Arcrun 的文件,找到的都是錯的。答案都在本 skill 與 `acr` 指令裡。
## 先做這三件(照順序)
1. `acr whoami` — 確認連到哪個帳號(**勿自行 curl 猜帳號 URL**
2. 讀本 skill 下面的語法與範本 → 寫出 `>>` 意圖
3. `acr parts``acr recipe list`(或 `acr search <關鍵字>` 一次掃全部)— 確認零件與 recipe 真的存在
**卡住時**`acr search <關鍵字>` 跨類搜尋;有 MCP 就 `arcrun_get_skill('INDEX')` 拿全館導航。
---
## 0. 一句話世界觀
**Arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 = 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
你大部分時間在**寫紙、改紙**,不是在造新零件、也不是自己寫腳本。
**Arcrun 只有三種東西,先分清楚就不會做歪:**
| 東西 | 是什麼 | 你能做的 |
|---|---|---|
| **工作流(workflow** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
| **recipe** | 打「一個固定外部 API」的設定(endpointheaderbody 模板) | 自由寫、**而且該投稿**(缺就自己補) |
| **零件(component** | WASM 程式(流程控制/資料處理/`http_request`auth),固定一小套 | **你不自製**,走 PR 由維護者管 |
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
---
@@ -0,0 +1,67 @@
---
## 9. 資源去哪取(不要自己重造 Arcrun 已有的)
| 你想知道 | 跑這個 |
|---|---|
| 有哪些零件可用 | `acr parts` |
| 某零件的設定範本 | `acr parts scaffold <name>` |
| 有哪些 recipe | `acr recipe list``acr recipe search <關鍵字>` |
| 支援哪些服務的認證 | `acr auth-recipe list` |
| 某服務認證要哪些 credential 範例 | `acr auth-recipe scaffold <service>` |
| **一次掃全部**(零件/recipeauth-recipeworkflow | `acr search <關鍵字>` |
| 已部署的 workflow | `acr list` |
| 某次執行為什麼失敗 | `acr logs <workflow>` |
| 工作流語法、指令 | `acr --help` |
**先查再動手**——Arcrun 多半已經有你要的零件/recipe/認證,不要自刻。
## 10. 做出來以後:驗證 → 部署
```bash
acr validate <workflow>.yaml # 先驗,別直接部署
acr push <workflow>.yaml # 部署(暴露動作,見 §12)
acr run <workflow> # 觸發一次,看實際結果
acr logs <workflow> # 看執行紀錄/失敗原因
```
需要 credentialAPI keytoken)時:`acr auth-recipe scaffold <service>` 看要哪些,
明確告訴使用者去哪取得、怎麼 `acr creds push`。
🔑 **金鑰只拿名字**workflowrecipe 裡只寫 `{{credential.<名字>}}`
**真身絕不寫進定義檔**(執行前才由系統回填)。
## 11. Arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
需要智慧判斷/自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
**不要在工作流中間放零件回頭呼叫 LLM**——Arcrun 的大腦就是操盤的你。
(唯一例外:`ask_llm` 這種「內容生成本身就是流程的一步」,見範本 B。)
## 12. 把東西開放給別人用 = 要使用者明示同意
`acr push`(部署 workflow)與 `acr recipe push`(投稿 recipe)會讓資料/能力**可被外部呼叫**:
- 停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。**不替他決定公開。**
- 非互動環境(你直跑)遇到 → 停,把完整指令印給使用者自己貼上跑,絕不自己塞 confirm 假裝同意。
- Arcrun 可提供保護(要求呼叫者帶 key/限流)——提醒使用者。
## 13. Arcrun 不替你做授權判斷
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 Arcrun 的 bug、不是你做錯**。
不要在 Arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
## 14. 誠實(最重要)
- **不假綠**:沒打通就誠實說。缺 credential 打不到 2xx → 標「未驗收:缺 X」,不 mock 充綠燈。
- **不假裝防偽/不代替人類確認**有風險的動作(暴露資料)。
- **完成 客觀證據**HTTP 2xx + trace),不是口頭「做好了」。
---
## 動手前的自檢清單
1. 我把意圖寫成 `>>` 串了嗎?(還是直接跳去寫 YAML/寫程式)
2. 我查過 `acr search` / `acr parts` / `acr recipe list` 了嗎?
3. 查詢回 `not_found` 時,我走的是 recipe/零件 PR 兩條路,**還是偷偷改寫成 `code`**?(後者=腹語術)
4. 我是不是讓工作流回頭呼叫 AI 做判斷?(是 → 改成我自己做)
5. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
6. 我有沒有假裝(假綠/假防偽/代替人類確認)?(有 → 停,誠實標明)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "arcrun",
"version": "1.3.13",
"version": "1.3.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arcrun",
"version": "1.3.13",
"version": "1.3.14",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
+4 -2
View File
@@ -8,7 +8,9 @@
"main": "./dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"build": "npm run build:harness && npm run check:harness && tsc",
"build:harness": "node scripts/build-harness-skill.mjs",
"check:harness": "node scripts/check-harness-generation.mjs",
"dev": "tsc --watch",
"test": "node --test \"tests/**/*.test.ts\"",
"prepublishOnly": "npm run build && chmod +x dist/index.js"
@@ -42,6 +44,6 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/uncle6me-web/Arcrun.git"
"url": "git+https://github.com/youlinhsieh/Arcrun.git"
}
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* build-harness-skill.mjs — 由 registry/skills/ 組出 harness 的 arcrun-mindset SKILL.md
*
* 【為什麼是「建置期複製」而不是人工維護兩份】
* `registry/skills/write_intent_workflow.md` 是意圖語法的**單一真相源**——它同時是
* MCP `arcrun_get_skill()` 回給雲端 AI 的內容。harness 的 skill 若人工再抄一份,
* 兩份必然漂移(2026-07-31 實錄:harness 那份停在上一代,grep「意圖」「>>」= 0 命中,
* 只講世界觀,害新裝的用戶 AI 學不到 `>>`)。
*
* 作法:harness skill = 三段拼接
* SKILL.md.head ← harness 專屬(frontmatterCLI 入口/三種東西的分型)
* registry 的 write_intent_workflow.md 正文 ← 單一真相源,只此一份被維護
* SKILL.md.tail ← harness 專屬(acr 指令表/暴露同意/誠實鐵律)
*
* 為什麼不用 symlink / npm 打包直接引用:npm `files` 只收 `harness/`
* registry/ 不進套件;symlink 在 npm pack 與 Windows 上不可靠。建置期複製最單純。
*
* 產物 `SKILL.md` **有 commit 進 repo**npm 套件裝的是它,不會跑 build),
* 由 check-harness-generation.mjs 驗證它與 registry 沒有漂移。
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url)); // cli/scripts
const repoRoot = join(here, '..', '..'); // repo 根
const skillDir = join(here, '..', 'harness', 'skills', 'arcrun-mindset');
const registrySkill = join(repoRoot, 'registry', 'skills', 'write_intent_workflow.md');
const head = readFileSync(join(skillDir, 'SKILL.md.head'), 'utf8').trimEnd();
const tail = readFileSync(join(skillDir, 'SKILL.md.tail'), 'utf8').trimEnd();
const body = readFileSync(registrySkill, 'utf8');
// 取 registry skill 的正文:去掉它自己的 H1 標題與「何時用這個 skill」那段
// harness 的 head 已用 CLI 語境寫過入口),從第一個 `## 1.` 章節起收。
const idx = body.indexOf('## 1. 意圖工作流的語法');
if (idx < 0) {
console.error('❌ registry/skills/write_intent_workflow.md 找不到「## 1. 意圖工作流的語法」章節;');
console.error(' registry skill 結構變了 → 請同步更新 cli/scripts/build-harness-skill.mjs 的取段規則。');
process.exit(1);
}
const middle = body
.slice(idx)
// registry 版把 MCP 工具當預設介面;harness 裝在有 acr CLI 的專案 → 補上 CLI 等價指令
.replace(/`arcrun_get_workflow\(<name>\)`/g, '`acr logs <name>`(有 MCP 則 `arcrun_get_workflow(<name>)`')
.replace(/`arcrun_list_components` \/ `arcrun_search_components`/g, '`acr parts` / `acr search`')
.replace(/下一步該讀哪支 skill`arcrun_list_skills\(\)`/g, '下一步該讀哪支 skill(需 MCP):`arcrun_list_skills()`')
.trimEnd();
const out = [
head,
'',
'<!-- 以下正文由 registry/skills/write_intent_workflow.md 於建置期複製而來(單一真相源)。',
' 不要直接編輯本段——改 registry 那份,然後跑 `npm run build:harness`。 -->',
'',
middle,
'',
tail,
'',
].join('\n');
writeFileSync(join(skillDir, 'SKILL.md'), out, 'utf8');
console.log(`✓ harness skill 已由 registry 重建:${out.length} bytes`);
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* check-harness-generation.mjs — 世代閘:harness 內容脫節就讓 build/publish 失敗
*
* 【為什麼要這道閘】
* 2026-07-31 實錄:`acr install-harness` 的管道一直是好的,但它鋪出去的**內容停在上一代**——
* harness skill grep「意圖」「>>」= 0 命中,只講世界觀。管道綠燈、交付物過時,
* 沒有任何機械檢查會抱怨 ⇒ 世代脫節可以無聲存在好幾個月。
*
* 這道閘檢查四件交付物的「現世代指紋」。缺指紋 = exit 1,擋掉 build 與 npm publish。
* 指紋要挑「上一代絕不會有、現世代一定有」的字串,不是隨便的關鍵字。
*/
import { readFileSync, existsSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
const here = dirname(fileURLToPath(import.meta.url));
const harness = join(here, '..', 'harness');
const repoRoot = join(here, '..', '..');
/** @type {{file: string, must: [string, string][], mustNot?: [string,string][]}[]} */
const CHECKS = [
{
file: 'skills/arcrun-mindset/SKILL.md',
must: [
['>>', '意圖語法(`A >> 邊 >> B`)——步驟 1 的核心教材'],
['ON_SUCCESS', '合法邊之一'],
['對每個', 'FOREACH 邊(十題裡有四題要用)'],
['input', '第一個節點固定是 input'],
['not_found', '現世代查詢狀態(舊版寫 missing/假 found'],
['腹語術', '缺件不准改寫成 code 的紅線'],
['recipe', '零件 vs recipe 分型'],
// 條件邊自 2026-08-01 起引擎已支援(cypher-executor/src/graph-executor.ts
// case 'ON_TRUE'/'ON_FALSE'/'ON_BRANCH'31 個測試全過)。教材該教會怎麼用,
// 不是教「不存在」——這條 must 同時防「哪天又被改回舊世代說法」的回歸。
['ON_TRUE', '條件邊(配 if_control)自 2026-08-01 起引擎已支援,教材須教會用法'],
],
mustNot: [
// ON_FAILURE 才是真的不存在(VALID_EDGE_TYPES 只有 ON_FAIL,見
// cypher-executor/src/lib/constants.ts)。只准出現在「教它不存在」的脈絡。
// 2026-08-10 修正:這道閘原本擋的是 ON_TRUE——但 ON_TRUE/ON_FALSE/ON_BRANCH
// 已是引擎現世代能力,正確教材反而被這道閘擋下,是閘的判準過時了,不是教材寫錯。
['ON_FAILURE', '引擎沒有這種邊(只有 ON_FAIL);教材不該把它教成可用的邊', /不要寫|不存在|沒有這種|❌|非法/],
],
},
{
file: 'CLAUDE.block.md',
must: [
['>>', '意圖語法要在 CLAUDE.md 就先亮相'],
['not_found', '缺件兩條路的觸發點'],
],
},
{
file: 'commands/arcrun.md',
must: [
['>>', '/arcrun 的第一步就該是寫意圖'],
['acr search', '現世代的跨類搜尋指令'],
],
},
{
file: 'hooks/arcrun-guard.sh',
must: [
['arcrun-mindset', 'hook 被擋下時要把 AI 導向 skill,而不是叫它去翻 repo 文件'],
['>>', 'hook 的正路提示要提到意圖語法'],
],
},
];
let fail = 0;
const say = (s) => console.log(s);
say('\n 世代閘:檢查 harness 交付物是否為現世代內容\n');
for (const c of CHECKS) {
const p = join(harness, c.file);
if (!existsSync(p)) {
say(`${c.file} — 檔案不存在`);
fail++;
continue;
}
const text = readFileSync(p, 'utf8');
const missing = c.must.filter(([needle]) => !text.includes(needle));
const badNot = (c.mustNot ?? []).filter(([needle, , allowIfNear]) => {
if (!text.includes(needle)) return false;
if (!allowIfNear) return true;
// 允許「在教『不要用』的脈絡裡」出現:看該字串所在行是否有豁免詞
return !text
.split('\n')
.filter((l) => l.includes(needle))
.every((l) => allowIfNear.test(l));
});
if (missing.length === 0 && badNot.length === 0) {
say(`${c.file}`);
} else {
fail++;
say(`${c.file}`);
for (const [needle, why] of missing) say(` 缺指紋「${needle}」— ${why}`);
for (const [needle, why] of badNot) say(` 不該出現「${needle}」— ${why}`);
}
}
// harness skill 必須是由 registry 重建的最新版(防「改了 registry 忘了重跑 build」)
const skillPath = join(harness, 'skills', 'arcrun-mindset', 'SKILL.md');
const registrySkill = join(repoRoot, 'registry', 'skills', 'write_intent_workflow.md');
if (existsSync(skillPath) && existsSync(registrySkill)) {
try {
execFileSync(process.execPath, [join(here, 'build-harness-skill.mjs')], { stdio: 'pipe' });
const rebuilt = readFileSync(skillPath, 'utf8');
const before = statSync(skillPath); // 重建後內容即為期望值
void before;
// 重建是冪等的:若重建後與 git 中的版本不同,git diff 會在 CI 顯示;
// 這裡直接比對「重建結果是否含 registry 當前的關鍵段落」
const reg = readFileSync(registrySkill, 'utf8');
const marker = reg.includes('## 7. 常犯的錯') ? '## 7. 常犯的錯' : null;
if (marker && !rebuilt.includes(marker)) {
say(` ❌ harness skill 與 registry 漂移:registry 有「${marker}」但重建產物沒有`);
fail++;
} else {
say(' ✓ harness skill 與 registry/skills/write_intent_workflow.md 同步');
}
} catch (e) {
say(` ❌ 無法由 registry 重建 harness skill${e.message}`);
fail++;
}
}
say('');
if (fail) {
say(` 🔴 世代閘擋下(${fail} 項)。harness 交付的內容落後於現世代。`);
say(' 修法:改 registry/skills/write_intent_workflow.md(單一真相源)或對應的');
say(' cli/harness/ 檔案,然後跑 `npm run build:harness` 重建,再跑本檢查。\n');
process.exit(1);
}
say(' ✅ 世代閘通過:四件交付物都帶現世代指紋\n');
+7 -6
View File
@@ -230,15 +230,16 @@ async function initSelfHosted(
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
}
// 3.5 語義查詢開關issue #7 / T2.4):問用戶要不要開(預設關,free-tier 友善)。
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword,零花費。
// 之後想開:跟 CC 說「幫我開語義查詢」或設 kbdb_embed:true + acr update(不必重 init)。
// 3.5 語義查詢(issue #7 / T2.4):**預設開**2026-08-09 翻轉,leo:「語義搜尋已經
// 確定是一安裝就提供的功能」——預設關會產出一批「看起來裝好了、其實少一條腿」的
// 實例,之後畫面上還被誤說成「沒開通」)。顯式回答 n 才關(極端省額度者自選)。
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword。
const embedAns = (await prompt(
rl,
'要開語義查詢嗎?(KBDB 加 AI 向量搜尋;用 CF Vectorize可能多花費;預設關,之後可隨時開) [y/N]',
'要開語義查詢嗎?(內建功能,建議保持開啟;用 CF Vectorize有免費額度) [Y/n]',
)).trim().toLowerCase();
const kbdbEmbed = embedAns === 'y' || embedAns === 'yes';
if (kbdbEmbed) console.log(chalk.gray(' → 已選語義查詢:部署時會建 Vectorize index。'));
const kbdbEmbed = !(embedAns === 'n' || embedAns === 'no');
if (!kbdbEmbed) console.log(chalk.yellow(' → 已選語義查詢:這台實例將只有關鍵字搜尋(之後可設 kbdb_embed:true + acr update 補開)。'));
// 4. 下載 repo 部署物(含預編譯 wasm+ 注入 KV id + wrangler deploy 全部 Worker
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
+8 -1
View File
@@ -110,11 +110,18 @@ function mergeSettings(cwd: string, src: string): void {
writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8');
}
/** 遞迴複製目錄樹(覆蓋同名檔)。 */
/** 建置期產物的來源片段(`SKILL.md.head` / `.tail`),只給 build-harness-skill.mjs 用,
* 不該被鋪進使用者專案(使用者拿到的是拼接好的 `SKILL.md`)。 */
function isBuildSource(name: string): boolean {
return name.endsWith('.head') || name.endsWith('.tail');
}
/** 遞迴複製目錄樹(覆蓋同名檔;跳過建置期來源片段)。 */
function copyTree(srcDir: string, dstDir: string): void {
if (!existsSync(srcDir)) return;
mkdirSync(dstDir, { recursive: true });
for (const name of readdirSync(srcDir, { withFileTypes: true })) {
if (isBuildSource(name.name)) continue;
const s = join(srcDir, name.name);
const d = join(dstDir, name.name);
if (name.isDirectory()) copyTree(s, d);
+7 -3
View File
@@ -84,9 +84,13 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
// self-hosted → 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
// 語義查詢開關issue #7):config.kbdb_embed:true → 部署建 Vectorize index + 注入 binding
// 這也是「CC 幫開」的落地路徑:CC 寫 kbdb_embed:true 進 config → acr update redeploy 即生效
kbdbEmbed: config.kbdb_embed === true,
// 語義查詢(issue #7):預設**開**,只有 config 顯式寫 kbdb_embed:false 才關
// 🔴 2026-08-09 翻轉預設(leo:「語義搜尋已經確定是一安裝就提供的功能」)
// 舊判斷 `=== true` 的實害:config 沒這個欄位(舊 config / 一鍵安裝實例本機補跑 update)
// 時 redeploy 會把 kbdb 的 [[vectorize]]+[ai] binding 靜默剝掉——一台**原本正常**的
// 實例就這樣失去語意搜尋,畫面上還被說成「還沒開通」。wrangler deploy 是整份覆蓋,
// binding 不在 toml 裡=直接消失,這正是「裝好的實例壞掉」的機制之一。
kbdbEmbed: config.kbdb_embed !== false,
};
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
+5 -3
View File
@@ -28,10 +28,12 @@ export interface ArcrunConfig {
mcp_url?: string;
multi_tenant?: boolean;
// 語義查詢開關(issue #7 / SDD T2.4self-hosted 從零做)。
// true → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding
// 🔴 2026-08-09 預設翻轉(leo:「語義搜尋已經確定是一安裝就提供的功能」):
// 未設 → **視同開**init/update 皆以 `!== false` 判斷)。只有顯式 false 才關。
// true/未設 → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding
// kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。
// 未設/false → base 維持 LIKE keywordfree-tier 友善,不建 index、不花費)。
// 開法:設 kbdb_embed:true → redeployacr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update
// false → base 維持 LIKE keyword顯式選擇才有這個狀態;缺欄位不再等於關——
// 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)
kbdb_embed?: boolean;
// 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
// (讀到不報錯,不再寫入/檢查)。
+65 -15
View File
@@ -163,8 +163,27 @@ export interface DeployContext {
kbdbEmbed?: boolean;
}
/** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed';
/**
* Vectorize index 名(kbdb embed 模組用)。**bge-m3 = 1024 維、cosine。**
*
* 🔴 2026-08-03 換代(leo 拍板;5 組中文測資實證:舊 `bge-base-en-v1.5` 排序 2/5、
* margin 0.0413**中文根本不能用**`bge-m3` 5/5、+0.1410、959ms)。
* leo 08-05:「換 embed model 當然要合併,當然要換 vectorize,原本的根本不能用」。
*
* **換模型必須換 index,且必須換「名字」**:
* ① 維度 768→1024,舊 index 收不進新向量
* ② 就算維度相同也不能沿用——不同模型的向量混在同一 index,比對出來是垃圾;
* 而 #58Vectorize vector delete 未接)代表舊向量刪不掉
* ⇒ **開新名字的 index 反而順手繞開 #58**,且新舊並存可回滾。
*
* ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用(deploy.ts:426
* ⇒ t36 的四個 metadata indexowner_id/entry_type/source/libraryArcrun#11 根因修復)
* 會自動建在新 index 上,**不會因為改名而遺失**(已查證,非假設)。
*
* 既有實例遷移:部署後 `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0
* 確認語意查詢正常後,舊的 `arcrun-kbdb-embed` 可自行刪除。
*/
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3';
export interface DeployResult {
implemented: boolean;
@@ -317,20 +336,49 @@ export async function downloadAndDeploy(
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql${migPath}`);
}
// 3.6 credentials 目錄表(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at)。
// 現行 credential 規範見 .claude/rules/01-tech-stack.md「Credential 儲存規範」。
// 同一顆 D1(與 KBDB base 共用),冪等 IF NOT EXISTS,套用機制與 0001_base.sql 完全相同
// (同一個 applyD1Migration helper,同一支 CF D1 query API)。D19:這張表不含密文,
// 密文本體住在 Workers per-script Secrets(見 cypher-executor/src/routes/credentials.ts)。
const credMigPath = join(root, 'kbdb', 'migrations', '0002_credentials.sql');
if (existsSync(credMigPath)) {
// 3.6 credential template seedD38 圍牆修復,總管交辦,2026-08-07):credential 目錄改走
// KBDB template 機制(entries 表 entry_type='credential',比照 recipe_stat/execution_log
// 慣例),取代舊的獨立 credentials 表(0002,已退役,見該檔頭部說明)。冪等,套用機制
// 與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets(見
// cypher-executor/src/routes/credentials.tsD19「擁有目錄不擁有內容物」不變
const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
if (existsSync(credTplMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(credMigPath, 'utf8'));
await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0002_credentials (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0002_credentials.sql${credMigPath}`);
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql${credTplMigPath}`);
}
// 3.6b 退役舊 credentials 表(D382026-08-07):把該表殘留資料(若有)搬進 entries 後
// 拆表,讓 KBDB 回到「只有三張核心表」的狀態。冪等且對「從未跑過 0002」的全新實例
// 無害(表不存在時本檔第一步先補空殼再立刻拆掉,詳見檔頭)。每次部署都會重跑,
// 但真資料只搬一次(NOT EXISTS 判斷防重複)。
const dropCredMigPath = join(root, 'kbdb', 'migrations', '0006_drop_credentials_table.sql');
if (existsSync(dropCredMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(dropCredMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0006_drop_credentials_table (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql${dropCredMigPath}`);
}
// 3.7 execution_log template seedKV 額度事故修復,2026-08-07):workflow 執行紀錄改走
// KBDB template 機制(entries 表 entry_type='execution_log',比照 recipe_stat 慣例;
// schema 零異動,只 seed 一列 template 定義,同 0001_base.sql §3 手法,self-hosted 同步套用)。
const execLogMigPath = join(root, 'kbdb', 'migrations', '0004_execution_log_template.sql');
if (existsSync(execLogMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(execLogMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0004_execution_log_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql${execLogMigPath}`);
}
}
@@ -388,7 +436,9 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
/**
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
* REST `POST /accounts/{id}/vectorize/v2/indexes`dimensions=768/metric=cosine,對齊 bge-base-en-v1.5)。
* REST `POST /accounts/{id}/vectorize/v2/indexes`dimensions=1024 / metric=cosine,對齊 bge-m3)。
* ⚠️ 這行別寫成 `**dimensions=1024**/metric`——`*` 緊接 `/` 會提早關掉 block comment(實撞 TS1127)。
* 維度必須與 `kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 一致——不一致時 upsert 直接被 CF 拒絕。
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
*/
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
@@ -398,8 +448,8 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: KBDB_VECTORIZE_INDEX,
config: { dimensions: 768, metric: 'cosine' },
description: 'arcrun KBDB optional embed module (issue #7)',
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
signal: AbortSignal.timeout(60_000),
});
+54 -5
View File
@@ -1,22 +1,68 @@
{
"_readme": [
"部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名』。",
"部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名+對外網址』。",
"",
"為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成 build 期參數。",
"誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。今天實際踩的:",
"為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成部署期參數。",
"誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。實際踩的:",
" · demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo 看到『Mira 介面』的真因)",
" · 兩站都漏 ARCRUN_API_BASE → apiBase 空字串 → 前端打自己回 405 → 登不進去",
" · 兩個帳號有同名 arcrun-console-ui 專案,wrangler 又登入在 uncle6",
" → 不指定帳號直接 deploy 會部到 demo 站上(差點蓋掉)",
"",
"🔴 第四次(2026-08-08 發現,同一種病換了形式):",
" 上面三次的『解』是 deploy.targets.json build.mjs 在 build 時把 profile/apiBase",
" 烤進產物。但 t160e744ad1)為了清世代債把 build.mjs 整支刪掉、改成直接託管 public/,",
" **沒有人把『把宣告值寫進產物』這件事接手過去** ⇒ deploy.mjs 照樣在終端機印",
" 『profilefull / apiBase:…leo21c…』,推上去的卻是 public/config.js 裡凍住的",
" cypher.arcrun.dev 凍在 4 頁的 VIEWS。也就是說:",
" **`npm run deploy:personal` 會把個人站的 API 打到企業 demo 的後端、頁面砍成 4 頁**",
" 而終端機從頭到尾顯示『成功』。(第三次的 accountId 是靠 env 傳的,倖存;前兩次的解等於被還原。)",
"",
" → 現在的規矩:**產物由 deploy.mjs 依本檔即時產生(.staging/<目標>),",
" 推之前驗產物、推之後驗線上網址**。public/ 裡不再放任何跟目標有關的值。",
" · public/config.js 已刪除——它是產物不是原始碼(自架站的 /config.js 由",
" arcrun-rag 的 build-ui-bundle 動態產生,不吃這個檔)",
" · public/console/index.html 的 VIEWS/HOME 只是本機 preview 的預設值,",
" 部署時一律被 _profiles 覆寫,覆寫沒命中就中止部署",
"",
"🔴 第五次(2026-08-08 同日,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,",
" 你要確定不可再犯」):**組態對 ≠ 世代對**。",
" 當天實測:三個對外網址的 apiBaseviewshome **三項全過**",
" 但它們跑的是 07-22 那一代的 portal82,911 bytes、舊金色 serif 品牌、Songti 12 處),",
" repo 已是 343,969 bytes 的新品牌世代。**組態全綠、介面落後半個月,沒有任何檢查會叫。**",
" → 故 verify-live 加第二層「世代指紋」:逐一抓線上資產、遮掉本來就該隨目標不同的",
" 那兩行(VIEWS/HOME),其餘按位元組比對 repo public/。",
" 不用關鍵字清單——清單要人維護,而舊世代能無聲上線正是因為沒人記得維護它。",
"",
"版本差異(leo 2026-07-22 定調):頁面都存在,由 profile 決定顯示哪些。",
" personal(full) 個人版:7 頁全開,落地駕駛艙",
" enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁",
" 未來擴充:個人版新用戶上限 1、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。",
"",
"用法:npm run deploy:personal / npm run deploy:enterprise"
"🧊 frozen 欄位(2026-08-08 leo 立):標了 frozen 的目標=**這個帳號的資源不歸我們動**。",
" deploy 拒絕部署它,verify 連抓都不抓(不 curl、不探測)。",
" 它不是「壞掉所以跳過」,是刻意的邊界;要解凍是人的決定(拿掉欄位並說明理由)。",
" 目標本身**保留不刪**——刪掉就變成下一個 AI 眼中「從來沒有過這個站」的失憶。",
"",
"用法:npm run deploy:personal",
" npm run deploy:personal -- --dry-run (只產出並驗產物,不推)",
" npm run verify (不部署,只驗線上:組態=宣告值、世代=當代)",
" npm run verify -- --url <網址> (只問某個網址:它跑的是不是當代的)"
],
"_profiles": {
"full": {
"description": "個人版:7 頁全開,落地駕駛艙",
"views": ["cockpit", "search", "card", "workflows", "creds", "inbox", "settings"],
"home": "cockpit"
},
"rag": {
"description": "企業版:搜尋/card/工作流/設定,落地搜尋頁",
"views": ["search", "card", "workflows", "settings"],
"home": "search"
}
},
"personal": {
"description": "leo 私人實例(原 Mira)。入口 mira.uncle6.me → leo21c worker。",
"accountId": "51a01bfa2665bd7bc3fd080dc40cf3e1",
@@ -24,6 +70,7 @@
"profile": "full",
"brand": "Arcrun",
"apiBase": "https://arcrun-cypher-executor.leo21c.workers.dev",
"verifyUrls": ["https://mira.uncle6.me", "https://arcrun-console-ui.pages.dev"],
"limits": {
"maxUsers": 1,
"libraryPermissions": false
@@ -31,12 +78,14 @@
},
"enterprise": {
"description": "企業版 demo 站。rag-demo.arcrun.dev → uncle6 帳號 cypher。",
"frozen": "leo 2026-08-08:「要看範例只在 youlin 網站,不要去碰 uncle6」——這站是 uncle6 帳號的資源,已廢。不更新、不下架、不探測。要動它是 leo 的閘。",
"description": "【已凍結・沿革】企業版 demo 站(uncle6 帳號)。保留紀錄用,不是現行部署對象。",
"accountId": "58309bb90fd93ad6d0fe0aae99170e9d",
"projectName": "arcrun-console-ui",
"profile": "rag",
"brand": "Arcrun",
"apiBase": "https://cypher.arcrun.dev",
"verifyUrls": ["https://rag-demo.arcrun.dev"],
"limits": {
"maxUsers": null,
"libraryPermissions": true
+4 -4
View File
@@ -2,11 +2,11 @@
"name": "arcrun-console-ui",
"version": "0.1.0",
"private": true,
"description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm,直接託管",
"description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm;部署時由 deploy.mjs 依 deploy.targets.json 產出 .staging/<目標> 再推",
"scripts": {
"deploy": "node scripts/deploy.mjs",
"deploy:personal": "node scripts/deploy.mjs personal",
"deploy:enterprise": "node scripts/deploy.mjs enterprise",
"preview": "npx serve public"
"verify": "node scripts/verify-live.mjs",
"preview": "node scripts/deploy.mjs personal --dry-run && npx serve .staging/personal"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

-2
View File
@@ -1,2 +0,0 @@
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
window.ARCRUN_CONFIG = { apiBase: "https://cypher.arcrun.dev" };
+39 -6
View File
@@ -445,6 +445,15 @@ window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase)
</div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">Portal 帳號密碼救援</div>
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<input type="email" id="st-portal-recover-email" class="txt" placeholder="Portal 帳號 Email">
<button class="btn" id="st-portal-recover-btn">產生新密碼</button>
<div id="st-portal-recover-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
<div id="st-info"><div class="muted">載入中…</div></div>
@@ -862,7 +871,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
var libs = x.d.libraries || [];
if (!libs.length) {
lmHonest('還沒有藏書地圖', '還沒有任何庫跑過重算——對 KBDB 呼 <code style="font-size:12.5px">POST /map/recompute?library=庫名</code> backfill 後,這裡會出現全館導覽。<br>不影響下方搜尋,可直接搜全庫。');
lmHonest('還沒有藏書地圖', '這個租戶目前沒有任何三元組資料(地圖是查詢時即時核對重算的,不是要人手動 backfill——資料一進來下次載入就會出現)。<br>不影響下方搜尋,可直接搜全庫。');
return;
}
LM.libs = libs; LM.details = {};
@@ -970,7 +979,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + '')) + '</span>'; return; }
var d = x.d;
if (S.semantic && d.mode === 'keyword') {
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
// 2026-08-09 leo:語意搜尋是安裝即提供的功能,降級=故障,不說「尚未啟用」。
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋目前故障</div><div class="b">' + esc(d.capability_hint || '語意搜尋目前故障(實例缺 Vectorize/AI 設定),以下先給關鍵字結果,不假裝是語意結果。') + '<br>維運資訊:' + esc(d.admin_hint || '(此版本後端未回報細節)') + '</div></div>';
}
var entries = d.entries || [];
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +
@@ -1456,17 +1466,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.then(function (d) {
// t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示——
// 沒有東西要用戶操作;未啟用才給一句人話與下一步。
// 2026-08-09 leo:語意搜尋是安裝即提供的功能——探測到降級=這台實例壞了,
// 照實標「故障」,不說「尚未啟用」(那會把 bug 說成沒提供的功能)。
var on = d.mode === 'semantic';
$('st-vec').textContent = on
? '● 已啟用——搜尋頁切到「語意」就能用意思找資料。'
: '○ 尚未啟用——目前用關鍵字搜尋,不會假裝有語意結果。';
? '● 正常——搜尋頁切到「語意」就能用意思找資料。'
: '○ 故障——語意搜尋是內建功能,這台實例現在少了它(系統端問題,不是操作問題)。';
var hint = $('st-vec-hint');
if (on) {
hint.style.display = 'none';
} else {
hint.style.display = '';
hint.innerHTML = '一鍵安裝的實例會在安裝時自動開通語意索引。'
+ '如果你這個實例是較早裝的、或安裝當下開通沒成功,重新跑一次安裝流程即可補上(已建好的資料不會重來)。';
hint.innerHTML = '修復方式:重新跑一次安裝流程(用原本的 Cloudflare 帳號),會把缺的語意索引設定補回來;已建好的資料不會重來。'
+ (d.admin_hint ? '<br>維運資訊:' + esc(d.admin_hint) : '');
}
})
.catch(function () {
@@ -1523,6 +1535,27 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
})
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
// arcrun-rag#25portal admin 密碼救援——只吃 console owner sessionS.token,本頁登入用的
// 那把),不吃 portal session,所以就算忘記 portal 密碼、進不去 portal 也走得通。
$('st-portal-recover-btn').addEventListener('click', function () {
var email = $('st-portal-recover-email').value.trim();
var st = $('st-portal-recover-status');
if (!email) { st.innerHTML = '<span class="err">請輸入 Email</span>'; return; }
st.textContent = '處理中…';
fetch(API_BASE + '/portal/admin/recover-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },
body: JSON.stringify({ email: email })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { st.innerHTML = '<span class="err">' + esc(x.d.error || '失敗') + '</span>'; return; }
st.innerHTML = '<span class="ok">新密碼:<code style="font-size:15px;user-select:all">' + esc(x.d.password) + '</code>(只顯示這一次,請立刻抄下)</span>';
$('st-portal-recover-email').value = '';
toast('新密碼已產生,請立刻抄下');
})
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
// t36:原本這裡綁在那顆假開關上(點了只會 toast 一段 CLI 指示)。開關已移除,
// 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener
// 當場拋錯,把後面所有綁定(含登出)一起打斷。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="arcrun icon"><title>arcrun icon</title><rect width="1024" height="1024" fill="#17181A"/><path fill="#FDFCFB" fill-rule="nonzero" d="M463.01,612.91 L436.06,612.91 L436.06,485.41 L435.86,477.78 L435.27,470.46 L434.28,463.44 L432.89,456.73 L431.11,450.31 L428.93,444.20 L426.36,438.39 L423.39,432.88 L420.02,427.68 L416.26,422.78 L412.10,418.18 L407.55,413.88 L402.62,409.91 L397.31,406.28 L391.65,402.99 L385.61,400.06 L379.21,397.47 L372.44,395.22 L365.30,393.32 L357.79,391.76 L349.92,390.55 L341.68,389.69 L333.08,389.17 L324.10,389.00 L317.39,389.10 L310.90,389.40 L304.63,389.89 L298.59,390.58 L292.77,391.47 L287.17,392.56 L281.80,393.85 L276.65,395.33 L271.72,397.02 L267.02,398.90 L262.53,400.98 L258.28,403.25 L254.20,405.69 L250.26,408.26 L246.45,410.95 L242.78,413.76 L239.25,416.71 L235.86,419.77 L232.60,422.97 L229.48,426.29 L226.50,429.74 L223.65,433.31 L220.94,437.01 L218.37,440.83 L257.76,476.08 L259.43,473.77 L261.16,471.52 L262.96,469.32 L264.81,467.18 L266.73,465.09 L268.71,463.05 L270.75,461.07 L272.85,459.15 L275.01,457.27 L277.23,455.45 L279.51,453.69 L281.86,451.98 L284.30,450.36 L286.86,448.89 L289.55,447.55 L292.37,446.36 L295.31,445.31 L298.38,444.39 L301.58,443.62 L304.90,442.99 L308.34,442.50 L311.91,442.15 L315.61,441.94 L319.44,441.87 L323.74,441.95 L327.85,442.21 L331.75,442.65 L335.45,443.25 L338.95,444.03 L342.24,444.98 L345.34,446.10 L348.23,447.40 L350.93,448.87 L353.42,450.51 L355.71,452.32 L357.79,454.31 L359.70,456.45 L361.44,458.74 L363.01,461.18 L364.42,463.75 L365.66,466.47 L366.73,469.34 L367.64,472.35 L368.39,475.50 L368.97,478.80 L369.38,482.24 L369.63,485.82 L369.71,489.55 L369.71,509.25 L323.58,509.25 L314.52,509.39 L305.80,509.82 L297.44,510.53 L289.43,511.52 L281.78,512.80 L274.47,514.37 L267.52,516.22 L260.93,518.35 L254.68,520.77 L248.79,523.47 L243.25,526.45 L238.06,529.72 L233.26,533.28 L228.88,537.14 L224.91,541.30 L221.36,545.76 L218.23,550.52 L215.52,555.57 L213.22,560.93 L211.34,566.58 L209.88,572.53 L208.84,578.78 L208.21,585.33 L208.00,592.18 L208.15,598.13 L208.62,603.87 L209.39,609.41 L210.48,614.75 L211.87,619.89 L213.57,624.83 L215.58,629.57 L217.91,634.11 L220.54,638.44 L223.48,642.57 L226.73,646.50 L230.29,650.23 L234.14,653.71 L238.25,656.88 L242.63,659.75 L247.28,662.32 L252.19,664.59 L257.37,666.56 L262.82,668.22 L268.53,669.58 L274.51,670.64 L280.75,671.40 L287.26,671.85 L294.04,672.00 L299.07,671.91 L303.96,671.63 L308.72,671.17 L313.33,670.53 L317.81,669.71 L322.16,668.70 L326.37,667.50 L330.44,666.13 L334.37,664.57 L338.17,662.82 L341.83,660.89 L345.35,658.78 L348.71,656.49 L351.88,654.01 L354.85,651.35 L357.62,648.50 L360.20,645.47 L362.59,642.26 L364.78,638.87 L366.78,635.29 L368.58,631.52 L370.19,627.58 L371.60,623.45 L372.82,619.13 L375.93,619.13 L376.52,622.61 L377.24,625.98 L378.09,629.22 L379.07,632.35 L380.19,635.36 L381.44,638.24 L382.83,641.01 L384.34,643.67 L385.99,646.20 L387.78,648.61 L389.69,650.91 L391.74,653.08 L393.92,655.11 L396.23,656.96 L398.66,658.64 L401.22,660.14 L403.90,661.46 L406.71,662.61 L409.64,663.58 L412.71,664.37 L415.89,664.99 L419.21,665.43 L422.65,665.69 L426.21,665.78 L463.01,665.78 L463.01,612.91 Z M475.77,630.42 L546.23,713.58 L762.31,530.50 L546.23,347.42 L475.77,430.58 L593.69,530.50 L475.77,630.42 Z M667.77,630.42 L738.23,713.58 L954.31,530.50 L738.23,347.42 L667.77,430.58 L785.69,530.50 L667.77,630.42 Z"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+5 -2
View File
@@ -7,8 +7,11 @@
根目錄直接導向搜尋 Portal。
為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到):
個網域(rag-demo.arcrun.dev)是給**客戶測試**的入口,
客戶測試指南寫的就是「一個網址、一組帳密」——多一層選擇=多一個困惑點,
份 UI 部署出去的網址是給**使用者**的入口(個人站 mira.uncle6.me
以及自架用戶自己的網址),進站就是要能用——多一層選擇=多一個困惑點,
2026-08-08 更正:原註解寫「這個網域=rag-demo.arcrun.dev 是客戶測試入口」,
那是 uncle6 帳號那個已廢的 demo 站,leo 已定案不再拿它當範例;
註解留著會把下一個人導向錯的環境,故改寫。理由本身仍然成立。)
而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。
維運者要進 console 直接打 /console/ 即可。
File diff suppressed because one or more lines are too long
+12 -5
View File
@@ -1,10 +1,15 @@
import fs from 'node:fs';
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)
//
// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語
// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。
// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。
const start = html.indexOf('var DAEMON_BASE_DEFAULT');
const endMark = "return '(封測版未簽章,第一次請右鍵→打開)';\n }";
const end = html.indexOf(endMark) + endMark.length;
if (start < 0 || end < start) throw new Error('抽不到函式區塊');
const hintAt = html.indexOf('function daemonHint', start);
const endMark = '\n }';
const end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;
if (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');
const src = html.slice(start, end);
const cases = [
@@ -26,11 +31,13 @@ for (const [name, ua] of cases) {
console.log(` url: ${url}`);
if (name==='Windows') {
chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('mac-unsigned.zip'));
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));
chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));
}
if (name==='Mac') {
chk('Mac 給 mac zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac-unsigned.zip'));
// 2026-08-05Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip
// ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。
chk('Mac 給 dmg(不是 zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));
chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));
chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));
}
+78 -30
View File
@@ -1,60 +1,108 @@
/**
* deploy.mjs — 依具名目標部署 console-ui 到 Cloudflare Pages
*
* 用法:npm run deploy:personal / npm run deploy:enterprise
* 用法:npm run deploy:personal
* npm run deploy:personal -- --dry-run (只產出並驗產物,不推)
*
* 為什麼不直接用 `wrangler pages deploy`2026-07-22 leo 立,實際踩到才補):
* **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案**
* · leo21c → arcrun-console-ui.pages.dev(個人版 console
* · uncle6 → 綁 rag-demo.arcrun.dev(企業版 demo 站)
* wrangler 若 OAuth 登入在 uncle6`--project-name arcrun-console-ui` 會部到 demo 站上。
* wrangler 若 OAuth 登入在別的帳號,`--project-name arcrun-console-ui` 會部到別人的站上。
* 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。
*
* 同時把 profile/apiBase 綁進目標(deploy.targets.json),不再靠部署者記得帶環境變數——
* 帶漏過三次:demo 站漏 profile=rag 顯示成個人版、兩站漏 apiBase 導致登入 405。
* 帶漏過三次:漏 profile 顯示成錯的版本、漏 apiBase 導致登入 405。
*
* 🔴 三道閘,全部**讀磁碟上真的要被推的那份**,不看本腳本自己印了什麼
* 2026-08-08 事故的形狀正是「印的是 A、推的是 B」):
* ① 產物閘 :宣告值有沒有真的寫進產物(apiBase / VIEWS / HOME
* ② 世代閘 :產物是不是當代(指紋+t160 的文字指紋)
* ③ 線上閘 :推完回頭抓線上,組態+世代都要對上,否則本次部署算失敗
* 三閘都過才寫 .deploy-state.json(那份紀錄是「經過線上實測」的意思,不是「我跑過指令」)。
*/
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { ROOT, assertArtifact, buildArtifact, loadTargets, resolveTarget, writeState } from './targets.mjs';
import { printReport, verifyTarget } from './verify-live.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const name = args.find((a) => !a.startsWith('--'));
const name = process.argv[2];
if (!name || !targets[name]) {
console.error(`用法:npm run deploy:<target>\n可用目標:${names.join(' / ')}`);
if (name) console.error(`(收到未知目標:"${name}"`);
let t;
try {
if (!name) throw Object.assign(new Error('沒有指定部署目標'), { usage: true });
t = resolveTarget(name);
} catch (e) {
console.error(`${e.message}`);
if (e.usage) console.error(`用法:npm run deploy:<target>\n可用目標:${loadTargets().active.join(' / ')}`);
process.exit(1);
}
if (t.frozen) {
console.error(`✘ 目標 ${name} 已凍結,拒絕部署。\n ${t.frozen}`);
console.error(' (要解凍是人的決定:改 deploy.targets.json 拿掉 frozen 欄位,並說明理由。)');
process.exit(1);
}
const t = targets[name];
console.log(`\n部署目標:${name}`);
console.log(` 說明 ${t.description}`);
console.log(` 帳號 ${t.accountId}`);
console.log(` 專案 ${t.projectName}`);
console.log(` profile ${t.profile}`);
console.log(` apiBase ${t.apiBase}\n`);
console.log(` apiBase ${t.apiBase}`);
const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
// t160leo 07-31:「如果你會搞不清楚,就把錯的東西刪掉」):build 步驟已隨舊世代
// src/ 一起 git rm——public/ 是唯一世代真身(手改演進),deploy=直接託管它。
// 病史:src/(舊代 renderer 快照)與 public/(新代真身)並存,deploy 自動跑 build
// 從舊 src 重產 public ⇒ 任何一次部署都可能把 UI 打回舊世代(07-27 記帳、07-31 引爆:
// t159 重打包用了舊 public 的分支副本,leo 刷新看到被淘汰的「登記新庫」表單)。
// 世代閘:部署前驗 public 指紋,舊世代(缺新文案/含人工建庫表單)直接拒部。
const portalHtml = readFileSync(join(ROOT, 'public', 'portal', 'index.html'), 'utf8');
if (!portalHtml.includes('不需要人工新增') || portalHtml.includes('登記新庫')) {
console.error('✘ 世代閘:public/portal/index.html 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕部署舊 UI。');
// ── ①② 產出 + 驗產物 ────────────────────────────────────────────────
const outDir = join(ROOT, '.staging', name);
try {
buildArtifact(t, outDir);
} catch (e) {
console.error(`\n✘ 產出失敗:${e.message}`);
process.exit(1);
}
const gate = assertArtifact(t, outDir);
console.log(`\n產物:${outDir}`);
console.log(` 世代指紋:${gate.generation.slice(0, 12)}`);
if (!gate.ok) {
console.error('\n✘ 產物閘不通過——推上去的會跟宣告的不一樣,拒絕部署:');
for (const p of gate.problems) console.error(` · ${p}`);
process.exit(1);
}
console.log(' ✅ 產物閘:宣告值確實寫進產物,且是當代。');
if (dryRun) {
console.log('\n--dry-run:到此為止,沒有推任何東西。)');
process.exit(0);
}
// ── 推 ───────────────────────────────────────────────────────────────
const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
// --commit-dirty:本地部署常有未提交變更,不因此中斷
const deploy = spawnSync(
'npx',
['wrangler', 'pages', 'deploy', 'public', '--project-name', t.projectName, '--commit-dirty=true'],
['wrangler', 'pages', 'deploy', outDir, '--project-name', t.projectName, '--commit-dirty=true'],
{ stdio: 'inherit', cwd: ROOT, env },
);
process.exit(deploy.status ?? 1);
if (deploy.status !== 0) {
console.error('\n✘ wrangler 部署失敗。');
process.exit(deploy.status ?? 1);
}
// ── ③ 線上閘 ─────────────────────────────────────────────────────────
console.log('\n── 回頭驗線上(組態+世代)──');
const report = await verifyTarget(name, { wait: true });
printReport([report]);
if (!report.ok) {
console.error('\n✘ 推上去了,但線上跑的 ≠ 我們手上這一份。**本次部署視為失敗**。');
console.error(' wrangler 說成功不代表對外網址就對——這正是要被擋掉的那個病。)');
process.exit(1);
}
writeState(name, {
generation: gate.generation,
apiBase: t.apiBase,
profile: t.profile,
urls: t.verifyUrls,
verifiedAt: new Date().toISOString(),
});
console.log('\n✅ 部署完成,且線上實測=宣告值+當代世代。已記入 .deploy-state.json。');
+269
View File
@@ -0,0 +1,269 @@
/**
* targets.mjs — 部署目標的唯一讀取點(deploy.mjs 與 verify-live.mjs 共用)。
*
* 存在的理由:宣告值(deploy.targets.json)只准被解讀一次。
* 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算,
* 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。
* 這支把「一個目標展開成期望的產物長相」定死成一個函式,三邊共用同一個答案。
*
* 🔴 2026-08-08 第二層(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
* 你要確定不可再犯」):組態對 ≠ 世代對。
* 一個網址可以 apiBase/profile 全部正確,卻對外展示一套早就被淘汰的介面,
* 而所有只驗組態的檢查都說它綠。故本檔另外定義「世代指紋」(見下半段):
* 把「線上這一份是不是當代的」變成一個可機械比對的值。
*/
import { createHash } from 'node:crypto';
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const PUBLIC_DIR = join(ROOT, 'public');
export function loadTargets() {
const raw = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
const profiles = raw._profiles;
if (!profiles) throw new Error('deploy.targets.json 缺 _profilesprofile → views/home 對照)');
const names = Object.keys(raw).filter((k) => !k.startsWith('_'));
const active = names.filter((n) => !raw[n].frozen);
return { raw, profiles, names, active };
}
export function resolveTarget(name) {
const { raw, profiles, names } = loadTargets();
const t = raw[name];
if (!t) {
const err = new Error(`未知的部署目標:"${name}"。可用:${names.join(' / ')}`);
err.usage = true;
throw err;
}
// 凍結目標:連讀都不准碰(frozen.reason 說明是誰、何時、為什麼)。
// 這不是「壞掉所以跳過」,是「這個帳號的資源不歸我們動」——工具自己守,不靠人記得。
if (t.frozen) return { name, ...t, frozen: t.frozen, views: profiles[t.profile]?.views, home: profiles[t.profile]?.home };
const p = profiles[t.profile];
if (!p) {
throw new Error(
`目標 ${name} 的 profile="${t.profile}" 在 _profiles 裡沒有定義(可用:${Object.keys(profiles).join(' / ')})。` +
'\n宣告了一個沒人知道怎麼落地的 profile ⇒ 拒絕部署,不要猜。',
);
}
if (!t.apiBase) throw new Error(`目標 ${name} 沒有 apiBase——空值會讓前端安靜地連不上,拒絕部署。`);
if (!t.accountId) throw new Error(`目標 ${name} 沒有 accountId——不指定帳號可能部到別人的站上,拒絕部署。`);
if (!Array.isArray(t.verifyUrls) || t.verifyUrls.length === 0) {
throw new Error(`目標 ${name} 沒有 verifyUrls——沒有對外網址就無法驗「站上跑的=宣告的」,拒絕部署。`);
}
return { name, ...t, views: p.views, home: p.home };
}
/** 這個目標「應該長成什麼樣」——產物閘與線上閘都比對這一份。 */
export function expected(t) {
return {
configJs: configJsFor(t),
apiBase: t.apiBase,
viewsLine: ` var VIEWS = ${JSON.stringify(t.views)};`,
homeLine: ` var HOME = ${JSON.stringify(t.home)};`,
};
}
export function configJsFor(t) {
return (
'// 由 console-ui/scripts/deploy.mjs 於部署時依 deploy.targets.json 產生——請勿手改,也不進 git。\n' +
`// 目標:${t.name}${t.description}\n` +
`window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(t.apiBase)} };\n`
);
}
/** 從 config.js 的文字裡取出 apiBase(線上/產物共用同一個解析法)。 */
export function parseApiBase(text) {
const m = text.match(/apiBase\s*:\s*"([^"]*)"/);
return m ? m[1] : null;
}
// ─────────────────────────────────────────────────────────────────────────────
// 世代指紋(2026-08-08 第二層)
//
// 問題:verify-live 原本只驗組態(apiBase / VIEWS / HOME)。實測當天三個對外網址
// 這三項全綠,但線上跑的是 2026-07-22 那一代的 portal82,911 bytes、
// 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的
// 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。
//
// 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶——
// 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。
//
// 為什麼是位元組而不是「找幾個關鍵字」:
// 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線,
// 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事:
// repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。
//
// 誠實的 trade-offmindset §7,不假裝完美):
// ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**,
// 因為那時線上確實不是當代的。它會吵,但吵的是真的。
// ② 若哪天 CF 邊緣開始改寫 HTMLRocket Loader 之類),會出現假 ❌。
// 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致),
// 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查——
// **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。
// ─────────────────────────────────────────────────────────────────────────────
/** 納入世代指紋的資產:filepublic/ 底下的路徑,urlPath=線上要抓的位址。 */
export const GENERATION_ASSETS = [
{ file: 'index.html', urlPath: '/' },
{ file: 'portal/index.html', urlPath: '/portal/' },
{ file: 'console/index.html', urlPath: '/console/' },
{ file: 'favicon.svg', urlPath: '/favicon.svg' },
];
/**
* 「本來就該隨部署目標不同」的行——比世代時遮掉,否則個人版與企業版永遠指紋不同。
* 遮的只有這兩行;其餘全部按原樣比對。
* config.js 整支不納入世代(它是純產物,由 apiBase 那一項單獨驗)。
*/
const TARGET_DEPENDENT_LINES = [
{ file: 'console/index.html', re: /^[ \t]*var VIEWS = .*$/m, tag: '«VIEWS:由部署目標決定»' },
{ file: 'console/index.html', re: /^[ \t]*var HOME = .*$/m, tag: '«HOME:由部署目標決定»' },
];
/** 遮掉目標相依的行。抓不到就原樣回傳(線上是舊世代時本來就可能沒有那幾行 → 該判 ❌)。 */
export function maskTargetValues(file, bytes) {
const rules = TARGET_DEPENDENT_LINES.filter((r) => r.file === file);
if (!rules.length) return bytes;
let text = Buffer.from(bytes).toString('utf8');
for (const r of rules) text = text.replace(r.re, r.tag);
return Buffer.from(text, 'utf8');
}
export function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
/**
* 由「檔名 → 位元組(抓不到給 null)」算出世代指紋。
* @param {Array<{file:string, bytes:Buffer|null}>} entries
*/
export function fingerprintOf(entries) {
const assets = {};
const lines = [];
for (const { file, bytes } of entries) {
if (bytes == null) {
assets[file] = { sha: null, size: null, missing: true };
lines.push(`${file}\tMISSING`);
continue;
}
const masked = maskTargetValues(file, bytes);
const sha = sha256(masked);
assets[file] = { sha, size: Buffer.from(bytes).length, missing: false };
lines.push(`${file}\t${sha}`);
}
return { assets, digest: sha256(Buffer.from(lines.join('\n'), 'utf8')) };
}
/** repo(或某個產物目錄)現在這一代長什麼樣。這就是「當代」的定義。 */
export function generationOfDir(dir = PUBLIC_DIR) {
return fingerprintOf(
GENERATION_ASSETS.map(({ file }) => {
let bytes = null;
try {
bytes = readFileSync(join(dir, file));
} catch {
bytes = null;
}
return { file, bytes };
}),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 產物:把宣告值真的寫進去(e730b3f 標的 WIP,本次收掉)
// ─────────────────────────────────────────────────────────────────────────────
/**
* 依目標把 public/ 展開成「要推上去的那一份」。
* 🔴 覆寫沒命中就中止——宣告了卻沒寫進產物,正是這串事故的根。
*/
export function buildArtifact(t, outDir) {
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
cpSync(PUBLIC_DIR, outDir, { recursive: true });
const exp = expected(t);
// ① config.js:產物,不是原始碼(public/ 裡不留)
writeFileSync(join(outDir, 'config.js'), exp.configJs, 'utf8');
// ② console 的 VIEWS/HOMEpublic/ 裡那兩行只是本機 preview 的預設值
const consolePath = join(outDir, 'console', 'index.html');
let html = readFileSync(consolePath, 'utf8');
for (const [re, line, what] of [
[/^[ \t]*var VIEWS = .*$/m, exp.viewsLine, 'VIEWS'],
[/^[ \t]*var HOME = .*$/m, exp.homeLine, 'HOME'],
]) {
if (!re.test(html)) {
throw new Error(
`產物覆寫沒命中:console/index.html 找不到 ${what} 那一行 ⇒ 中止部署。\n` +
'(前端改版把那行換了寫法時會發生。宣告值寫不進去就不准推——這正是 2026-08-08 事故的形狀。)',
);
}
html = html.replace(re, line);
}
writeFileSync(consolePath, html, 'utf8');
return outDir;
}
/**
* 產物閘:推之前,回頭讀「真的要被推上去的那些檔案」,確認=宣告值。
* 不看 deploy.mjs 自己印了什麼——只看磁碟上那份。
*/
export function assertArtifact(t, outDir) {
const exp = expected(t);
const problems = [];
const cfg = readFileSync(join(outDir, 'config.js'), 'utf8');
const gotApiBase = parseApiBase(cfg);
if (gotApiBase !== t.apiBase) problems.push(`config.js 的 apiBase:宣告 ${t.apiBase},產物 ${gotApiBase}`);
const html = readFileSync(join(outDir, 'console', 'index.html'), 'utf8');
const gotViews = html.match(/^[ \t]*var VIEWS = .*$/m)?.[0];
const gotHome = html.match(/^[ \t]*var HOME = .*$/m)?.[0];
if (gotViews !== exp.viewsLine) problems.push(`console VIEWS:宣告 ${exp.viewsLine.trim()},產物 ${gotViews?.trim()}`);
if (gotHome !== exp.homeLine) problems.push(`console HOME:宣告 ${exp.homeLine.trim()},產物 ${gotHome?.trim()}`);
// 世代閘(產物側):注入不得改動世代相關位元組
const src = generationOfDir(PUBLIC_DIR);
const art = generationOfDir(outDir);
if (src.digest !== art.digest) {
problems.push(`產物世代指紋 ${art.digest.slice(0, 12)} ≠ public/ 的 ${src.digest.slice(0, 12)}(注入改到了不該改的位元組)`);
}
// 世代閘(內容側,沿用 t160 的文字指紋——擋「整份 public 被換成舊代」)
//
// 🔴 只看「使用者看得到的內容」,比對前先剝掉 HTML 註解。
// 2026-08-08 實撞:原版直接對全文比對「登記新庫」,而 66f1b5908-03)在 portal 裡
// 加了一則**說明「已經把登記新庫拿掉了」的註解** ⇒ 這道閘從那天起每次都誤判,
// `npm run deploy:personal` 連續五天推不出去、而錯誤訊息說的是「你的 UI 是舊代」。
// ⇒ 手工維護的關鍵字清單會腐爛,這就是實例;世代的主判準因此改用位元組指紋,
// 這道文字閘只留來擋「整份 public 被換成舊代」,且必須剝註解才不會自傷。
const portalRaw = readFileSync(join(outDir, 'portal', 'index.html'), 'utf8');
const portal = portalRaw.replace(/<!--[\s\S]*?-->/g, '');
if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) {
problems.push('portal/index.html 不是現行世代(可見內容缺「不需要人工新增」或仍有「登記新庫」)');
}
return { ok: problems.length === 0, problems, generation: art.digest };
}
/** 部署狀態記錄檔(只在「線上實測通過」之後才寫,見 deploy.mjs)。 */
export const STATE_FILE = join(ROOT, '.deploy-state.json');
export function readState() {
try {
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
} catch {
return {};
}
}
export function writeState(name, record) {
const state = readState();
state[name] = record;
writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
+220
View File
@@ -0,0 +1,220 @@
/**
* verify-live.mjs — 驗「線上網址現在真的在跑的那一份」=「我們手上這一份」。
*
* 用法:
* node scripts/verify-live.mjs 驗全部服役中目標的全部對外網址
* node scripts/verify-live.mjs personal 只驗某個目標
* node scripts/verify-live.mjs --wait 容忍 CF Pages 生效延遲(重試)
* node scripts/verify-live.mjs --url <網址> 只對某個網址驗世代(不需要是宣告目標)
* npm run verify
*
* 兩層,缺一不可:
* ① 組態層:apiBaseprofile 的 views/home deploy.targets.json 宣告值
* ② 世代層:線上資產的位元組指紋 = repo public/ 的指紋
*
* 為什麼要第二層(2026-08-08,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
* 你要確定不可再犯」):當天實測三個對外網址,第一層**三項全過**,
* 而它們跑的是 07-22 那一代的 portal82,911 bytes、金色 serif 舊品牌),
* repo 是 343,969 bytes 的新品牌世代。
* ⇒ **組態可以完全正確,同時展示一套早就被淘汰的介面,而機械檢查一片綠。**
* 第二層就是為了讓這個狀態不可能無聲存在。
*
* 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.jsVIEWSHOME
* 與世代指紋都是**純文字資產比對**,抓原始碼比對是這幾項的正確驗法;
* 「頁面真的能用」另外走瀏覽器實載。
* 🔴 frozen 目標(見 deploy.targets.json)連抓都不抓——不是我們的帳號,不碰。
*/
import {
GENERATION_ASSETS,
fingerprintOf,
generationOfDir,
loadTargets,
parseApiBase,
readState,
resolveTarget,
} from './targets.mjs';
const NOCACHE = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' };
async function get(url) {
const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}_nc=${Date.now()}`, {
headers: NOCACHE,
cache: 'no-store',
redirect: 'follow',
});
const buf = Buffer.from(await res.arrayBuffer());
return { status: res.status, bytes: buf, text: buf.toString('utf8') };
}
/** 抓線上的世代資產,算指紋。抓不到的當 MISSING(照樣算,缺檔本來就是另一代)。 */
async function liveGeneration(base) {
const entries = [];
const detail = {};
for (const { file, urlPath } of GENERATION_ASSETS) {
try {
const r = await get(`${base.replace(/\/$/, '')}${urlPath}`);
const ok = r.status === 200;
entries.push({ file, bytes: ok ? r.bytes : null });
detail[file] = { status: r.status, text: ok ? r.text : null };
} catch (e) {
entries.push({ file, bytes: null });
detail[file] = { status: `連線失敗:${e.message}`, text: null };
}
}
return { ...fingerprintOf(entries), detail };
}
/** 驗一個網址。t 給 null=只驗世代(ad-hoc 模式)。 */
export async function verifyUrl(t, url, want) {
const checks = [];
const base = url.replace(/\/$/, '');
const live = await liveGeneration(base);
// ── 世代層 ──────────────────────────────────────────────
const genOk = live.digest === want.digest;
const diffs = Object.entries(want.assets)
.filter(([f, a]) => live.assets[f]?.sha !== a.sha)
.map(([f, a]) => {
const l = live.assets[f] ?? {};
const st = live.detail[f]?.status;
return `${f}repo ${a.size ?? '缺'} bytes / 線上 ${l.missing ? `抓不到(${st}` : `${l.size} bytes`}`;
});
checks.push({
name: '世代',
ok: genOk,
want: `${want.digest.slice(0, 12)}repo public/`,
got: genOk
? `${live.digest.slice(0, 12)}`
: `${live.digest.slice(0, 12)}\n 不同的資產:\n ${diffs.join('\n ')}`,
});
if (!t) return { url, ok: genOk, checks };
// ── 組態層 ──────────────────────────────────────────────
try {
const cfg = await get(`${base}/config.js`);
const got = cfg.status === 200 ? parseApiBase(cfg.text) : `HTTP ${cfg.status}`;
checks.push({ name: 'apiBase', ok: got === t.apiBase, want: t.apiBase, got: got ?? '(config.js 裡找不到 apiBase)' });
} catch (e) {
checks.push({ name: 'apiBase', ok: false, want: t.apiBase, got: `連線失敗:${e.message}` });
}
const con = live.detail['console/index.html'];
const conText = con?.text;
const views = conText?.match(/var VIEWS = (\[[^\]]*\]);/);
const home = conText?.match(/var HOME = "([^"]*)";/);
const gotViews = conText ? (views ? views[1] : '(找不到 VIEWS)') : `HTTP ${con?.status}`;
const gotHome = conText ? (home ? home[1] : '(找不到 HOME)') : `HTTP ${con?.status}`;
checks.push({
name: `profile(${t.profile}).views`,
ok: gotViews === JSON.stringify(t.views),
want: JSON.stringify(t.views),
got: gotViews,
});
checks.push({ name: `profile(${t.profile}).home`, ok: gotHome === t.home, want: t.home, got: gotHome });
return { url, ok: checks.every((c) => c.ok), checks };
}
export async function verifyTarget(name, { wait = false } = {}) {
const t = resolveTarget(name);
if (t.frozen) return { name, target: t, skipped: true, ok: true, results: [] };
const want = generationOfDir();
const attempts = wait ? 8 : 1;
let results = [];
for (let i = 1; i <= attempts; i++) {
results = [];
for (const url of t.verifyUrls) results.push(await verifyUrl(t, url, want));
if (results.every((r) => r.ok) || i === attempts) break;
process.stdout.write(` … 尚未生效,5s 後重試(${i}/${attempts - 1}\n`);
await new Promise((r) => setTimeout(r, 5000));
}
return { name, target: t, ok: results.every((r) => r.ok), results };
}
export function printReport(reports) {
for (const r of reports) {
console.log(`\n${r.name}${r.target.description}`);
if (r.skipped) {
console.log(` ⏸️ 已凍結,不抓不驗:${r.target.frozen}`);
continue;
}
console.log(` 宣告:profile=${r.target.profile} apiBase=${r.target.apiBase}`);
for (const u of r.results) {
console.log(` ${u.ok ? '✅' : '❌'} ${u.url}`);
for (const c of u.checks) {
if (c.ok) console.log(`${c.name} = ${c.got}`);
else console.log(`${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`);
}
}
}
}
export async function verifyAll(names, opts) {
const reports = [];
for (const n of names) reports.push(await verifyTarget(n, opts));
return reports;
}
const isCli = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
if (isCli) {
const args = process.argv.slice(2);
const wait = args.includes('--wait');
const urlIdx = args.indexOf('--url');
if (args.includes('--offline-lag')) {
// 不連網,只問一句:「我手上這一代,有沒有真的送出去過?」
// 給 Stop hook 用(每回合都跑,所以不准連網、不准慢)。
// 唯一的事實來源是 .deploy-state.json,而它**只在線上實測通過後**才被寫(見 deploy.mjs
// ⇒ 它說綠就是真的有人驗過線上,不是「我跑過部署指令」。
const here = generationOfDir().digest;
const state = readState();
const stale = [];
for (const n of loadTargets().active) {
const s = state[n];
if (!s) stale.push(`${n}:沒有任何一次通過線上實測的部署紀錄(線上是哪一代,現在沒人知道)`);
else if (s.generation !== here) {
stale.push(`${n}:最後一次驗過的是 ${s.generation.slice(0, 12)}${s.verifiedAt.slice(0, 10)}),現在手上是 ${here.slice(0, 12)}`);
}
}
if (stale.length) {
console.log(stale.join('\n'));
process.exit(1);
}
process.exit(0);
}
if (urlIdx !== -1) {
// ad-hoc:只問「這個網址上跑的是不是當代的」——不需要它是宣告過的目標。
const url = args[urlIdx + 1];
if (!url) {
console.error('用法:node scripts/verify-live.mjs --url <網址>');
process.exit(2);
}
const want = generationOfDir();
const r = await verifyUrl(null, url, want);
console.log(`\n【世代檢查】${url}`);
for (const c of r.checks) {
if (c.ok) console.log(`${c.name} = ${c.got}`);
else console.log(`${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`);
}
if (!r.ok) {
console.error('\n❌ 這個網址上跑的不是當代的前端——它展示的是一套已經被淘汰的介面。');
process.exit(1);
}
console.log('\n✅ 這個網址上跑的=我們手上這一份。');
process.exit(0);
}
const picked = args.filter((a) => !a.startsWith('--'));
const names = picked.length ? picked : loadTargets().names;
const reports = await verifyAll(names, { wait });
printReport(reports);
const bad = reports.filter((r) => !r.ok);
if (bad.length) {
console.error(`\n${bad.length} 個目標與宣告/當代不符:${bad.map((b) => b.name).join('、')}`);
console.error(' (線上實際在跑的 ≠ 我們手上這一份——這正是要被擋掉的那個病)');
process.exit(1);
}
console.log('\n✅ 所有服役中目標:線上組態=宣告值,線上世代=repo 當代。');
}
+33 -58
View File
@@ -21,98 +21,73 @@ import type { Bindings } from '../types';
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
import { wasmWorkerUrl } from '../lib/component-loader';
import { createArcrunHostFunctions } from '../lib/wasi-shim';
import { getCredentialSecretRefs, touchLastUsed } from '../routes/credentials';
// ── credential-store 遷移 T6/T7(方案 AD19────────────────────────────────
// ── credential-store 遷移 T6/T7(方案 AD19 D38 圍牆修復(2026-08-07───────────
//
// 密文值住 cypher-executor 自己的 per-script secretsT5 寫入)。解密發生在獨立的
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref]T4)取明文
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
// 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref→ 用 secret_get(ref)
// (即 env[ref]T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
// WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
//
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
// D38leo 2026-06-14 立、2026-08-07 擴大):目錄不再直連 D1,改走 KBDB HTTP API
// `credentials.ts` 的 `getCredentialSecretRefs`,內建 60 秒租戶級快取——這是熱路徑,
// 每次 workflow 執行都會呼叫,映射「幾乎不變」故快取後多數命中零網路呼叫,效能不因改走
// API 而變差,見 credentials.ts 檔頭「效能」段的實測數字)。
//
// 嚴格邊界(rule 02 §2.2):本檔只做「查目錄拿 ref → secret_get 取值 → 當字串塞 payload」。
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
// 內,解密/注入邏輯仍全在 WASM 零件。
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
interface CredentialRefRow {
name: string;
secret_ref: string;
}
/**
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
*
* 流程:查 D1 `credentials`api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
* host function,實作 = env[ref])取值。
* 流程:查 KBDB credential 目錄api_key + name,快取命中零網路呼叫)拿 `secret_ref`
* → 用 `secret_get(ref)`host function,實作 = env[ref])取值。
*
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
* ⚠️ 只把「目錄有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
*
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
* 取到值的 name 順手更新 last_used_at(§2.5 治理面 last_used,見 touchLastUsed——
* fire-and-forget、非同步、不阻塞本函式回傳,失敗吞掉)。
*
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
* KBDB 不可達 / 這個租戶還沒有任何 credential → 回空 map(整組走 fallback),
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
*/
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
type ResolvedSecretMap = Record<string, string>;
export async function resolveSecretsFromNewHome(
env: Bindings,
apiKey: string,
names: string[],
): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
): Promise<ResolvedSecretMap> {
const resolved: ResolvedSecretMap = {};
if (names.length === 0) return resolved;
const db = env.CREDENTIALS_DB;
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
// 1. 查 D1 拿每個 name 的 secret_ref
let rows: CredentialRefRow[];
try {
const placeholders = names.map(() => '?').join(', ');
const result = await db
.prepare(
`SELECT name, secret_ref FROM credentials
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(apiKey, ...names)
.all<CredentialRefRow>();
rows = result.results ?? [];
} catch {
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
return resolved;
}
if (rows.length === 0) return resolved;
// 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts
const refs = await getCredentialSecretRefs(env, apiKey);
if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 fallback
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref]
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
const resolvedNames: string[] = [];
for (const row of rows) {
const value = await secretGet(row.secret_ref);
for (const name of names) {
const ref = refs[name];
if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback
const value = await secretGet(ref);
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
if (value === null) continue;
resolved[row.name] = value;
resolvedNames.push(row.name);
resolved[name] = value;
resolvedNames.push(name);
}
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
if (resolvedNames.length > 0) {
try {
const now = Math.floor(Date.now() / 1000);
const placeholders = resolvedNames.map(() => '?').join(', ');
await db
.prepare(
`UPDATE credentials SET last_used_at = ?
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(now, apiKey, ...resolvedNames)
.run();
} catch {
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
}
}
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 namefire-and-forget,非關鍵路徑
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
return resolved;
}
+55 -25
View File
@@ -1,24 +1,56 @@
/**
* Execution Logger — 執行結果寫入 ANALYTICS_KVfire-and-forget
* Execution Logger — 執行結果寫入 KBDBfire-and-forget
*
* 設計:每次 workflow 執行後,將統計數據寫入 ANALYTICS_KVkey = stats:{workflowId})。
* Phase 7 可升級為 POST 至 registry.arcrun.dev/analytics/record
* KV 額度事故修復(總管交辦,2026-08-07):舊版寫 ANALYTICS_KVWorkers KV),
* key = stats:{workflowId}:{timestamp}(註解寫「避免覆蓋」)⇒ 只增不減、永不覆蓋
* 封測者 Evan 處理約 690 個檔案,KV 免費層 write 上限 1,000/日被打爆(實測 1,070 write)。
*
* KBDB 鐵律(leo 2026-06-14):KBDBAPI-as-Wall,零 SQL——任何存取一律走 KBDB 的 HTTP API
* 不准直接對它的 D1 下 SQL。本檔因此**不直連任何 D1**,改 fire-and-forget POST
* `{KBDB_BASE_URL}/execution-log/record`(連法/認證頭完全比照既有 recordRecipeStats
* 慣例,見 webhook-handlers.ts;儲存/降級實作在 kbdb/src/actions/execution-log.ts)。
*
* leo 兩條判準:
* ① 執行紀錄是稽核資料 → 搬去 D1entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。
* ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/
* duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(截斷長度不對稱,見 KBDB 端)。
*
* A2 自我降級(門檻與降級邏輯全在 KBDB 端,見 execution-log.ts):D1 額度仍與知識卡共用,
* 超過門檻 KBDB 會回報 mode='skip'/'log_failure_only',但**這件事對呼叫端透明**——
* 本函式不管 KBDB 決定寫或不寫,一律 fire-and-forget、永不 throwworkflow 執行不受影響。
*/
import type { Bindings, GraphNode } from '../types';
import { kbdbBase } from '../routes/kbdb-proxy';
export interface ExecutionVerdict {
workflow_id: string;
component_ids: string[];
verdict: 'success' | 'failed';
duration_ms: number;
message: string;
recorded_at: string;
target?: string;
}
/**
* 寫入執行結果至 ANALYTICS_KVfire-and-forget,不阻擋主流程)
* 由 c.executionCtx.waitUntil() 包裹呼叫
* 從觸發時的 trigger context 擷取這次處理的目標(page_name / path),供「哪些檔沒進去」
* 這種問題答得出來。只認這兩個 key(少記,不做窮舉式欄位挖掘/猜測)。
*/
function extractTarget(input?: Record<string, unknown>): string | undefined {
if (!input) return undefined;
const raw = input.page_name ?? input.path;
if (raw === undefined || raw === null) return undefined;
return typeof raw === 'string' ? raw : JSON.stringify(raw);
}
/**
* 寫入執行結果至 KBDBfire-and-forget,不阻擋主流程)。
* 由 c.executionCtx.waitUntil() 包裹呼叫。
*
* @param nodes 保留參數相容既有呼叫端簽名(原本用來算 component_ids);「不記每節點」
* 是本次修復的明確要求(少記),此參數現不使用。
* @param input 觸發時的 trigger context(可選)——只用來抓 page_name / path 當 target
* 不整包送出(少記:不留每節點輸入輸出,這裡也不例外)。
* @param apiKey 觸發者的租戶(可選,/execute 舊路徑無租戶概念)。
*/
export async function writeExecutionVerdict(
env: Bindings,
@@ -27,27 +59,25 @@ export async function writeExecutionVerdict(
verdict: 'success' | 'failed',
durationMs: number,
message: string,
input?: Record<string, unknown>,
apiKey?: string,
): Promise<void> {
void nodes; // 少記:不再從節點算 component_ids,保留參數只為呼叫端相容
try {
const componentIds = nodes
.filter(n => n.type === 'Component' && n.componentId)
.map(n => n.componentId!);
const record: ExecutionVerdict = {
workflow_id: workflowId,
component_ids: componentIds,
verdict,
duration_ms: durationMs,
message,
recorded_at: new Date().toISOString(),
};
// ANALYTICS_KV key = stats:{workflowId}:{timestamp}(避免覆蓋)
const key = `stats:${workflowId}:${Date.now()}`;
await env.ANALYTICS_KV.put(key, JSON.stringify(record), {
expirationTtl: 60 * 60 * 24 * 90, // 保留 90 天
const { base, headers } = kbdbBase(env);
await fetch(`${base}/execution-log/record`, {
method: 'POST',
headers,
body: JSON.stringify({
workflow_id: workflowId,
owner_id: apiKey ?? null,
verdict,
duration_ms: Math.max(0, Math.round(durationMs)),
message: message ?? '',
target: extractTarget(input) ?? null,
}),
});
} catch {
// fire-and-forget不拋錯,不影響主流程
// fire-and-forget任何錯誤(含 KBDB 端額度打滿、網路失敗)都吞掉、不影響主流程
}
}
+29 -1
View File
@@ -348,7 +348,15 @@ export class GraphExecutor {
// BUILD-006:將節點 output 存入 KVkey = {run_id}:node:{node_id}
// 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突
if (kvStore && result !== null && result !== undefined) {
//
// P8 短板齊平(2026-08-09,任務層小改記 portal-auth/tasks.md):只在「下游真的會讀」
// 時才寫。全 codebase 唯一的讀點是 PIPE 邊處理(本檔下方 kvGetNodeOutput 呼叫處)——
// 沒有 PIPE 出邊的節點,這筆寫入沒有任何讀者,卻每個節點(含 FOREACH 每一圈)
// 都燒一次 KV write。實測 rag_ingest_card 一張卡燒 15 次(4 固定節點+5 blocks
// 6 triplets),把免費層 KV 1,000 write/日壓成約 66 檔/日的最短板——全是白燒。
// 有 PIPE 出邊(含「完成後」與未知語意詞的預設)的節點行為完全不變。
if (kvStore && result !== null && result !== undefined
&& graph.edges.some((e) => e.from === node.id && (e.type as EdgeType) === 'PIPE')) {
await kvSetNodeOutput(kvStore, node.id, result);
}
@@ -531,6 +539,26 @@ export class GraphExecutor {
iterResults.push(itemResult);
}
// t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤
if (iterResults.length > 0) {
const failures = iterResults.filter(
r => r !== null && typeof r === 'object' && (r as Record<string, unknown>).success === false
);
if (failures.length === iterResults.length) {
const first = failures[0] as Record<string, unknown>;
const errParts: string[] = [];
if (first.error) errParts.push(String(first.error));
if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`);
const bodyData = first.data as { body?: string } | null | undefined;
if (bodyData && typeof bodyData.body === 'string' && bodyData.body) {
errParts.push(bodyData.body.slice(0, 200));
}
throw new Error(
`FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join('') || '未知錯誤'}`
);
}
}
result = { ...(result as Record<string, unknown>), results: iterResults };
break;
}
+22 -1
View File
@@ -48,7 +48,28 @@ app.use('*', cors({
extra = String((c.env as Record<string, unknown>).UI_ORIGINS || '')
.split(',').map((s: string) => s.trim()).filter(Boolean);
} catch { /* UI_ORIGINS 未設定=只用靜態白名單 */ }
return [...STATIC_ORIGINS, ...extra].includes(origin) ? origin : null;
// 🔴 2026-08-08 事故根因修復:**同一台實例的 portal 一律自動放行,不再依賴注入**。
//
// 那天發生什麼:leo 的 youlin 實例 portal 整個不能用——先是畫面頂端紅字
// 「設定檔沒載入(config.js)」(UI worker 缺 WORKER_SUBDOMAIN),修好之後**登入仍然失敗**。
// 瀏覽器 console 實證:
// Access to fetch at '…/portal/login' … blocked by CORS policy:
// No 'Access-Control-Allow-Origin' header is present
// 真因=這台的 `UI_ORIGINS` 沒被設。
//
// 兩次同一個病:**這些變數只有安裝器那條路會注入,任何人手動 `wrangler deploy` 就會漏掉——
// 而漏掉時系統看起來完全正常**(worker 上線、HTTP 200、版本號還是對的),
// 只有真人點下去才會發現。leo:「這麼危險的問題已經發生 2 次,不可以再有一次。」
//
// ⇒ 治法不是「記得要注入」,是**讓它不需要被注入**:
// portal 與本 worker 是同一個 workers.dev 子網域下的兄弟,位址推導得出來。
// **少一個必須注入的變數,就少一個會被漏掉的東西。**
// `UI_ORIGINS` 仍然有效(自訂網域/額外前端還是靠它),只是不再是「登得進去」的前提。
const sub = String((c.env as Record<string, unknown>).WORKER_SUBDOMAIN || '').trim();
const sibling = sub ? [`https://arcrun-rag-ui.${sub}.workers.dev`] : [];
return [...STATIC_ORIGINS, ...sibling, ...extra].includes(origin) ? origin : null;
},
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
@@ -22,13 +22,21 @@
* KBDB 改網址後同步更新此處。seed 先照現況進。
*/
import type { ResponseMap } from './recipe-payload';
export interface ApiRecipeSeed {
canonical_id: string;
display_name: string;
description?: string;
/** HTTP recipe=要打的網址;`auth: 'binding'` 型=要呼叫的資源名(如 Workers AI 的模型 id)。 */
endpoint: string;
method: string;
auth_service?: string;
// ── payload/回應/binding 三層(3.12):全選填,既有種子不帶=行為完全不變 ──
body_template?: Record<string, unknown>;
response_map?: ResponseMap;
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
binding_name?: string;
}
export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
@@ -120,4 +128,47 @@ export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
method: 'POST',
auth_service: 'line_notify',
},
// ── LLM 對話(binding=免金鑰,3.12 第四型認證的第一個真實案例)──
//
// 為什麼進種子(而非寫在某個產品的安裝器裡):「裝好之後預設有哪些 recipe」是平台能力,
// 與本檔其餘種子同理由(見檔頭)。裝完 /init/seed 就有 ⇒ **用戶不填任何金鑰就能問答**。
//
// 換模型/換供應商=**改這一筆 recipe**endpoint + body_template + response_map),
// workflow 的 ask_llm 節點不動——這正是「換源=換 recipe 不是換引擎」。
//
// 選型實測(2026-08-03,在 1.4.4 實例上跑真實長度的 RAG prompt,每個模型連跑 2 次):
// @cf/meta/llama-4-scout-17b-16e-instruct 23732173 ms ✅ 答案最完整、引用正確
// @cf/meta/llama-3.3-70b-instruct-fp8-fast 32612147 ms ✅ 可用但波動較大
// @cf/mistralai/mistral-small-3.1-24b-instruct 35603631 ms
// @cf/qwen/qwen2.5-coder-32b-instruct 35723353 ms
// @cf/openai/gpt-oss-120b 19712295 ms ❌ 回應形狀不同,response 取不到文字
// @cf/google/gemma-3-12b-it ❌ 5018 This account is not allowed to access this model
// 對照舊路徑(Gemini `gemma-4-31b-it`):同型提問 **16.87 s**,且吐整段英文思考草稿
// ⇒ 選 llama-4-scout:**快 7 倍以上,且不需要淨化思考草稿**。
{
canonical_id: 'workers_ai_chat',
display_name: 'Workers AI 對話(免金鑰)',
description:
'Cloudflare Workers AI 文字生成,走 env.AI binding ⇒ 不需要任何 API 金鑰。'
+ 'ctx 帶 prompt,回應正規化成 text(含【答】標記與前綴淨化)。'
+ '換模型=改本 recipe 的 endpointworkflow 不動。',
endpoint: '@cf/meta/llama-4-scout-17b-16e-instruct',
method: 'POST',
auth: 'binding',
binding_name: 'AI',
body_template: {
messages: [{ role: 'user', content: '{{prompt}}' }],
max_tokens: 1024,
temperature: 0.2,
},
response_map: {
// Workers AI chat 回應:{ response: "…" }(另有 OpenAI 相容的 choices,取 response 最穩)
text_path: 'response',
// 提示詞要求答案以【答】開頭;模型偶爾會在前面多帶一行 ⇒ 取最後一個標記之後
answer_marker: '【答】',
// 前綴組合順序不定,循環剝殼(規則見 recipe-payload.ts sanitize
strip_prefixes: ['*', '-', '•', '>', '#', '"', '「', '【答】', 'Answer:', 'Draft:'],
},
},
];
@@ -0,0 +1,299 @@
/**
* 認證儲存(D61:認證與資料分離)— 門鎖不住在知識資料庫裡
*
* leo 2026-08-10 下令(ADR D61 / Leo/arcrun-rag#55):
* 「登入認證資料要分離⋯⋯**就算只有我一個人存在單獨的 json 檔也好**,
* 它不能被改資料庫的連結導致無法登入。」
*
* 不變量(整份檔案只為這一句存在):
* **登入所需要的一切,不得存放在任何「會被安裝/遷移重新指向」的地方。**
*
* 為什麼家選在 CF Workers per-script Secrets(判斷過程留著,方便日後推翻):
* - D1 / KV / R2 / Vectorize 全靠 **binding** 指過去,安裝器每次都會重新指一次
* ⇒ 換家=換鎖。所以「搬到另一顆資料庫」根本不解問題。
* - Workers Secret **掛在 script 本身**,與 bindings 是兩套資源:
* `wrangler deploy` 帶新 bindings 重部不會洗掉它(journeys/gemini-key-lost-on-reinstall.md
* 在 stage 完整重裝 24/24 顆 worker 後 secret 仍在;installer worker.js:1148 亦有同款實證)。
* - 它是**自足**的:讀出來就是完整的一份 JSON,裡面沒有任何「再去某顆 D1/KV 查一次」的指標。
* 自足是重點——只要還要回頭查一次,就又被綁回去了。
* - 不開新 D1P9leo 2026-08-07「你建一顆新的 D1,以後就會偷偷溜去那裡建表」)。
* - 不牴觸 D38「KBDB 三張核心表永不加新的」:本檔是把東西**搬出去**,KBDB 表數不增不減。
*
* 容量(2026-08-10 查官方 developers.cloudflare.com/workers/platform/limits/,不是憑記憶):
* - 每個變數(secret + text 合計)上限 **5 KB**
* - 每顆 worker 變數數量上限 **64Free/ 128Paid**,與 CRED_* 共用同一份額度
* ⇒ 故採「單一 store + 溢位分片」:`ARCRUN_AUTH_STORE`、`ARCRUN_AUTH_STORE_1`、`_2`…
* 一份 ~4.5 KB 大約裝得下 12–15 個帳號;超過就自動長出下一片。
* 這是刻意的取捨:**不**做「一個帳號一顆 secret」,因為那會用同一份 64 格的額度去跟
* workflow credential 搶位子,且沒有任何實例接近這個量級。
*
* 寫入路徑:CF Workers Scripts secrets 管理 API(唯寫,讀不回值)。
* 與 routes/credentials.ts 走**同一支** putWorkerSecret/deleteWorkerSecret,不另造第二套
* (D36 教訓:AI 天生偏向新增一種做法而非沿用既有的,兩套並存必然漂移)。
*
* 讀取路徑:`env` 直接讀——**零網路呼叫**。這正是它比 KBDB 可靠的原因:
* 登入不再依賴任何外部系統活著。
*
* ⚠️ 傳播延遲(誠實限制,mindset §7):更新 secret 會產生 worker 的新版本,
* **既有 isolate 讀到的仍是舊 env**,要等新版本鋪開。故本檔帶一層 per-isolate 的
* write-through overlayAUTH_OVERLAY_TTL_MS),讓「剛改完密碼立刻登入」在同一顆 isolate 上
* 立即生效;跨 isolate 仍可能有數十秒的落差,這是平台特性,不假裝沒有。
*/
import type { Bindings } from '../types';
import { putWorkerSecret, deleteWorkerSecret } from '../routes/credentials';
/** 主分片名;溢位分片為 `${AUTH_STORE_PREFIX}_1`、`_2`… */
export const AUTH_STORE_PREFIX = 'ARCRUN_AUTH_STORE';
/** 單片安全上限(官方 5 KB,留 ~10% 給 JSON 結構與 UTF-8 膨脹)。 */
const SHARD_MAX_BYTES = 4600;
/** 剛寫完的資料在本 isolate 內優先採信多久(跨 isolate 傳播用)。 */
const AUTH_OVERLAY_TTL_MS = 180_000;
/**
* 「剛寫完」加速器的 KV key 與存活時間。
*
* 🔴 為什麼需要它(2026-08-10 stage 演練**實測撞到**,不是預防性設計):
* 更新 secret 會產生 worker 新版本,**既有 isolate 讀到的還是舊 env**。實測「建好帳號 →
* 立刻登入」有 **15 秒以上**登不進去,而且那幾次失敗**會被算進 5 次鎖定**
* ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。**這正是本案要根治的病的變種。**
*
* 🔑 它**不是**認證的家,只是「新版本還沒鋪開時的臨時快遞」:
* - 讀取順序永遠是 **secret 優先**;secret 裡查不到/密碼對不上,才回頭問加速器一次
* - KV 被重裝指到新的空的 → 加速器空 → 退回 secret ⇒ **D61 的不變量不受影響**
* - 短 TTL:密碼雜湊不長期躺在 KV 裡(舊設計是永久躺著,這比舊的嚴格)
*/
const ACCEL_KEY = 'auth_store_recent';
const ACCEL_TTL_SECONDS = 600;
/** store 內 user id 前綴——呼叫端據此分辨「這筆住新家還是舊家(KBDB)」。 */
export const AUTH_ID_PREFIX = 'auth:';
export interface AuthUserRecord {
id: string;
email: string;
display_name: string;
status: string;
role: string;
libraries: string[];
password_hash: string;
created_at: string;
updated_at: string;
}
/** console 管理員那一組(原本住 SESSIONS_KV `console:credentials`,重裝就跟著蒸發)。 */
export interface AuthConsoleRecord {
email: string;
salt: string;
hash: string;
created_at: string;
}
export interface AuthStoreData {
version: number;
console: AuthConsoleRecord | null;
users: AuthUserRecord[];
}
interface ShardPayload {
v: number;
console?: AuthConsoleRecord | null;
users?: AuthUserRecord[];
}
/** 寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID,或 CF API 回錯)。 */
export class AuthStoreWriteError extends Error {}
// ── per-isolate overlay(見檔頭「傳播延遲」)─────────────────────────────────────
let overlay: AuthStoreData | null = null;
let overlayAt = 0;
function emptyStore(): AuthStoreData {
return { version: 1, console: null, users: [] };
}
function shardNames(env: Bindings): string[] {
const bag = env as unknown as Record<string, unknown>;
return Object.keys(bag)
.filter((k) => k === AUTH_STORE_PREFIX || /^ARCRUN_AUTH_STORE_\d+$/.test(k))
.filter((k) => typeof bag[k] === 'string' && (bag[k] as string).length > 0)
.sort((a, b) => shardIndex(a) - shardIndex(b));
}
function shardIndex(name: string): number {
if (name === AUTH_STORE_PREFIX) return 0;
return Number.parseInt(name.slice(AUTH_STORE_PREFIX.length + 1), 10) || 0;
}
function shardNameOf(index: number): string {
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
}
/** 這台實例的 env 裡有沒有認證儲存(不論裡面有沒有帳號)。 */
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);
}
/**
* 讀出完整認證資料。**同步、零網路呼叫**——這就是分離的意義:
* 登入不依賴 KBDB / D1 / KV 任何一個活著。
* 壞掉的分片(JSON parse 失敗)誠實跳過,不讓一片損毀鎖死整台實例。
*/
export function readAuthStore(env: Bindings): AuthStoreData {
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
const bag = env as unknown as Record<string, unknown>;
const out = emptyStore();
for (const name of shardNames(env)) {
let parsed: ShardPayload | null = null;
try {
parsed = JSON.parse(bag[name] as string) as ShardPayload;
} catch {
continue; // 損毀的分片跳過(其餘帳號仍登得進去)
}
if (!parsed || typeof parsed !== 'object') continue;
if (parsed.console && !out.console) out.console = parsed.console;
if (Array.isArray(parsed.users)) {
for (const u of parsed.users) {
if (u && typeof u.email === 'string' && typeof u.id === 'string') out.users.push(u);
}
}
}
return out;
}
/** 找一筆帳號(email 比對,大小寫不敏感)。 */
export function findAuthUserByEmail(env: Bindings, email: string): AuthUserRecord | null {
const needle = email.trim().toLowerCase();
return readAuthStore(env).users.find((u) => u.email.toLowerCase() === needle) ?? null;
}
export function findAuthUserById(env: Bindings, id: string): AuthUserRecord | null {
return readAuthStore(env).users.find((u) => u.id === id) ?? null;
}
/** 判斷一個 record_id 是不是住新家(呼叫端據此決定打 store 還是打 KBDB)。 */
export function isAuthStoreId(recordId: string): boolean {
return recordId.startsWith(AUTH_ID_PREFIX);
}
export function newAuthUserId(): string {
const arr = new Uint8Array(12);
crypto.getRandomValues(arr);
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* 把整份認證資料切片後寫回 Workers Secrets。
* 分片規則:console 一定放第 0 片;users 依序塞,塞不下就開下一片。
* 多出來的舊分片會被刪掉(避免「刪了帳號卻還留在舊分片裡復活」)。
*/
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
if (!authStoreWritable(env)) {
throw new AuthStoreWriteError(
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
);
}
const shards: string[] = [];
let current: ShardPayload = { v: 1, console: data.console ?? null, users: [] };
for (const u of data.users) {
const trial: ShardPayload = { ...current, users: [...(current.users ?? []), u] };
const size = new TextEncoder().encode(JSON.stringify(trial)).length;
if (size > SHARD_MAX_BYTES && (current.users ?? []).length > 0) {
shards.push(JSON.stringify(current));
current = { v: 1, users: [u] };
} else {
current = trial;
}
}
shards.push(JSON.stringify(current));
// 單筆帳號本身就超過一片=真的塞不下,誠實擋下(不靜默丟資料)
for (const s of shards) {
if (new TextEncoder().encode(s).length > 5000) {
throw new AuthStoreWriteError('單筆認證資料超過 Cloudflare 變數 5 KB 上限,無法寫入。');
}
}
const existing = shardNames(env);
for (let i = 0; i < shards.length; i++) {
await putWorkerSecret(env, shardNameOf(i), shards[i]);
}
for (const name of existing) {
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
}
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
overlayAt = Date.now();
// 加速器(非真相源,見 ACCEL_KEY 註解):讓別的 isolate 在新版本鋪開前也讀得到剛寫的東西。
// 寫失敗完全不影響正確性——最多就是回到「等 secret 傳播」的狀態,故吞掉例外。
try {
await env.SESSIONS_KV.put(
ACCEL_KEY,
JSON.stringify({ written_at: Date.now(), data: overlay }),
{ expirationTtl: ACCEL_TTL_SECONDS },
);
} catch {
/* 加速器是加分項,不是必要條件 */
}
}
/**
* 「secret 裡查不到/密碼對不上」時再問一次加速器(見 ACCEL_KEY)。
* 命中就把它放進本 isolate 的 overlay,呼叫端重跑一次同樣的查找即可。
* 回傳是否真的拿到比較新的資料(沒有就不必重跑)。
*/
export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
let raw: string | null = null;
try {
raw = await env.SESSIONS_KV.get(ACCEL_KEY);
} catch {
return false;
}
if (!raw) return false;
try {
const parsed = JSON.parse(raw) as { written_at?: number; data?: AuthStoreData };
if (!parsed?.data || !Array.isArray(parsed.data.users)) return false;
if (overlay && overlayAt >= (parsed.written_at ?? 0)) return false; // 本地的更新
overlay = { version: 1, console: parsed.data.console ?? null, users: parsed.data.users };
overlayAt = parsed.written_at ?? Date.now();
return true;
} catch {
return false;
}
}
/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */
export async function mutateAuthStore(
env: Bindings,
fn: (data: AuthStoreData) => void | Promise<void>,
): Promise<AuthStoreData> {
const data = readAuthStore(env);
const next: AuthStoreData = { version: 1, console: data.console, users: [...data.users] };
await fn(next);
await writeAuthStore(env, next);
return next;
}
/** 診斷用(/health、/console/auth-status、daemon diagnostics 共用同一份判讀)。 */
export function authStoreStatus(env: Bindings): {
present: boolean;
writable: boolean;
users: number;
console_configured: boolean;
shards: number;
} {
const data = readAuthStore(env);
return {
present: authStorePresent(env),
writable: authStoreWritable(env),
users: data.users.length,
console_configured: Boolean(data.console),
shards: shardNames(env).length,
};
}
+17
View File
@@ -37,4 +37,21 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
created_by: 'system',
},
{
// t130rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。
// 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。
// slots 來源:kbdb_list_templates 核實(2026-07-19library-map.test.ts PROD_TRIPLET_SLOTS
// + librarylibrary-map.ts M1 預案:recompute 歸庫用,ensurePortalTemplates 若缺則 PATCH 補入)。
name: 'triplet',
description: 'KBDB 知識圖譜三元組(kbdb-graph-plugin 寫入;portal 讀此 template 建鄰接圖)',
slots: [
'subject', 'predicate', 'object',
'source_block_id', 'confidence', 'clusters_json',
'bridge_score', 'subject_entity_type', 'object_entity_type',
'status', 'superseded_by',
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
'library',
],
created_by: 'system',
},
];
+9 -2
View File
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffergrow 會產生新的 ArrayBuffer
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
} catch {
return 1;
} catch (e) {
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
// 取代只 return 1WASM 寫無資訊的 "HTTP request failed")。
// writeOut 失敗(memory 壞)才 fallback return 1。
const errDetail = e instanceof Error ? e.message : String(e);
const errEnv = new TextEncoder().encode(
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
);
return writeOut(memory.buffer, outPtr, outLenPtr, errEnv);
}
})
: () => 1,
+113 -14
View File
@@ -22,6 +22,19 @@
*/
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';
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
@@ -70,16 +83,72 @@ function tenantOf(c: { env: Bindings }): string {
return c.env.CONSOLE_TENANT || 'leo';
}
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
/**
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(KV),
* 且一旦從舊家讀到就順手搬過去(best-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' };
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' };
}
try {
await mutateAuthStore(env, (data) => {
if (!data.console) data.console = legacy as AuthConsoleRecord;
});
} catch {
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store */
}
return { creds: legacy, source: 'legacy-kv' };
}
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
await mutateAuthStore(env, (data) => {
data.console = record;
});
}
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
consoleAuthRouter.get('/console/auth-status', async (c) => {
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
return c.json({ configured: !!existing });
const { creds, source } = await loadCredentials(c.env);
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
});
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
consoleAuthRouter.post('/console/setup', async (c) => {
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
if (existing) return c.json({ error: '已設定過帳密,請改用登入;要換帳密請用 /console/setup/reset(需舊密碼)' }, 409);
const { creds: existing } = await loadCredentials(c.env);
if (existing) {
// D61 明顯失敗:舊版只說「已設定過」,**沒說剛才填的那組密碼被整個丟掉了**——
// 用戶(含安裝精靈裡的 leo)以為自己剛設好了新密碼,其實從頭到尾沒有被採用過。
return c.json(
{
error:
'這台實例已經有管理員帳密了,**你剛才輸入的密碼沒有被採用**,目前的密碼仍是當初設定的那一組。' +
'要用舊密碼登入,或用 /console/setup/reset(需要舊密碼)換一組。',
code: 'already_configured',
password_applied: false,
reset_path: '/console/setup/reset',
},
409,
);
}
const body = await c.req.json().catch(() => null);
const email = (body?.email ?? '').trim();
@@ -90,7 +159,13 @@ consoleAuthRouter.post('/console/setup', async (c) => {
const salt = randomHex(16);
const hash = await hashPassword(password, salt);
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
try {
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);
}
const token = randomHex(32);
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
@@ -101,9 +176,8 @@ consoleAuthRouter.post('/console/setup', async (c) => {
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
consoleAuthRouter.post('/console/setup/reset', async (c) => {
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
if (!raw) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
const existing = JSON.parse(raw) as StoredCredentials;
const { creds: existing } = await loadCredentials(c.env);
if (!existing) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
const body = await c.req.json().catch(() => null);
const currentPassword = body?.current_password ?? '';
@@ -118,23 +192,48 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
const salt = randomHex(16);
const hash = await hashPassword(password, salt);
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
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({ success: true });
});
// POST /console/login — body: {email, password}。成功 → session tokenlocalStorage 存這個,不存密碼)。
consoleAuthRouter.post('/console/login', async (c) => {
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
if (!raw) return c.json({ error: '尚未設定帳密,請先完成首次設定' }, 400);
const existing = JSON.parse(raw) as StoredCredentials;
const { creds: existing } = await loadCredentials(c.env);
if (!existing) {
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
return c.json(
{
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
code: 'auth_store_empty',
auth_store: authStoreStatus(c.env),
},
400,
);
}
const body = await c.req.json().catch(() => null);
const email = (body?.email ?? '').trim().toLowerCase();
const password = body?.password ?? '';
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
const hash = await hashPassword(password, existing.salt);
if (email !== existing.email || hash !== existing.hash) {
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) {
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
+245 -62
View File
@@ -7,24 +7,41 @@
* 寫入(POST 建立 / PUT 覆寫):
* 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫,
* arcrun 自己也讀不回值——D19「不持有內容物」)。
* 2. D1 `credentials` 表只寫「目錄api_key/name/service/sensitivity/secret_ref/
* created_at),**不含密文**。
* 2. 目錄(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at
* **不含密文**)走 KBDB HTTP API 寫,不再直連任何 D1
* 不再寫 KV / 不再寫明文密文到 D1。
*
* 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cyphercypher 短暫在記憶體
* 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03
* 定案並已落地的做法,取代更早的 `{name, encrypted, iv}` 格式;rule 01 已同步。)
*
* D38 圍牆修復(總管交辦,2026-08-07;leo「任何東西禁止用 SQL 語句存取資料,一律 API」):
* 目錄舊家是 KBDB 裡多開的一張獨立 credentials 表(0002_credentials.sql,違規),現改走
* KBDB 三張核心表——entries 表一列(entry_type='credential'page_name=name 當冪等鍵,
* owner_id=api_key 隔離租戶,其餘欄位打包進 metadata_json),template 定義見
* kbdb/migrations/0005_credential_template.sql,舊表資料遷移+拆表見 0006。連法比照既有
* execution-logger.ts / portal.ts 慣例:kbdbBase(env) 組 base+headers,直接 fetch KBDB
* HTTP API,不經自己的 /kbdb/* proxy route(那支是給 CLI 用的,server 端直連 base 更省一跳)。
*
* 效能(D38 評估要求「帶數字」,見 system-dev/wiki/decisions-summary.md D38 段):
* 熱路徑(auth-dispatcher.ts resolveSecretsFromNewHome,每次 workflow 執行都會查一次)原本
* 直連 D1、零快取;改走 HTTP 後若一樣「每次查一次」延遲只會變差(多一趟公網往返)。這份
* name→secret_ref 映射「幾乎不變」(D38 評估原話),故本檔加一個租戶級記憶體快取
* dirCacheper-isolateTTL 60 秒),寫入(POST/PUT/DELETE)時主動失效,讓熱路徑多數
* 命中零網路呼叫。見下方 getCredentialDirectory / invalidateCredentialCache。
*
* 治理端點:
* - `GET /credentials`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留
* `/catalog` 別名,Console 既有呼叫不受影響)。
* - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row
* 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。
* - `GET /credentials`:改讀 KBDB entries(與 `/credentials/catalog` 共用同一份查詢,同時
* 保留 `/catalog` 別名,Console 既有呼叫不受影響)。
* - `DELETE /credentials/:name`:先查 KBDB 拿 secret_ref → 有則刪 Workers Secret + entries
* row沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的
* 孤兒資料。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { sha256Prefix } from '../lib/hash';
import { kbdbBase } from './kbdb-proxy';
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
@@ -61,7 +78,7 @@ export async function storeCredential(
): Promise<void> {
const secretRef = await deriveSecretRef(apiKey, name);
await putWorkerSecret(env, secretRef, value);
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service, 'standard', secretRef);
await upsertCredentialEntry(env, apiKey, name, service, 'standard', secretRef);
}
function validateName(name: unknown): name is string {
@@ -76,7 +93,7 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
*/
async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
throw new Error(
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
@@ -105,7 +122,7 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secretT9 治理端點用)。
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
*/
async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
}
@@ -124,32 +141,197 @@ async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<voi
}
}
// ── KBDB 目錄存取(D38:零 SQL,一律走 entries HTTP API)──────────────────────────
const CREDENTIAL_ENTRY_TYPE = 'credential';
/** entries 表回來的一列(本檔只取用得到的欄位,避免耦合 KBDB 內部型別)。 */
interface KbdbEntryRow {
id: string;
page_name: string | null;
owner_id: string | null;
metadata_json: string | null;
created_at: number;
}
interface CredentialMeta {
service: string | null;
sensitivity: 'standard' | 'high';
secret_ref: string;
last_used_at: number | null;
}
/** name → secret_ref 對照(給熱路徑用;獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
type CredentialRefMap = Record<string, string>;
function parseMeta(row: KbdbEntryRow): CredentialMeta {
try {
const m = row.metadata_json ? (JSON.parse(row.metadata_json) as Record<string, unknown>) : {};
return {
service: typeof m.service === 'string' ? m.service : null,
sensitivity: m.sensitivity === 'high' ? 'high' : 'standard',
secret_ref: typeof m.secret_ref === 'string' ? m.secret_ref : '',
last_used_at: typeof m.last_used_at === 'number' ? m.last_used_at : null,
};
} catch {
// 壞資料誠實視為空目錄列,不讓損毀的 metadata_json 炸整條路徑
return { service: null, sensitivity: 'standard', secret_ref: '', last_used_at: null };
}
}
/** 對 KBDB base 發 requestserver 端直連,不經 /kbdb/* proxy——那支是給 CLI 用的)。 */
async function kbdbCredFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
const { base, headers } = kbdbBase(env);
return fetch(`${base}${path}`, {
...init,
headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) },
});
}
// ── 熱路徑快取(D38 效能要求:這份映射幾乎不變,帶快取才不會比舊版 D1 直查慢)─────────
//
// per-isolate 記憶體快取,key=apiKeyTTL 60 秒。auth-dispatcher.ts 的
// resolveSecretsFromNewHome() 每次 workflow 執行都會呼叫,命中快取=零網路呼叫;
// 未命中才打一次 KBDB(一次列出該租戶全部 credential,通常個位數到十位數筆,遠比逐名查便宜)。
// 寫入路徑(upsert/delete)主動 invalidate,保證「剛存的 credential 立刻查得到」不受 TTL 拖延。
// 快取容器用 plain object——apiKey 皆為服務端衍生字串,非使用者可控鍵名。
interface CachedDirRow {
id: string;
name: string;
secret_ref: string;
service: string | null;
sensitivity: 'standard' | 'high';
last_used_at: number | null;
}
interface CachedDir {
rows: CachedDirRow[];
fetchedAt: number;
}
const DIR_CACHE_TTL_MS = 60_000;
const dirCache: Record<string, CachedDir> = {};
/** 寫入(建立/覆寫/刪除)後呼叫,讓下次熱路徑查詢重新打一次 KBDB(不吃到過期快取)。 */
export function invalidateCredentialCache(apiKey: string): void {
delete dirCache[apiKey];
}
/** 拉某租戶全部 credential 目錄列(快取層,60 秒 TTL)。給熱路徑(auth-dispatcher)與治理端點共用。 */
async function getCredentialDirectory(env: Bindings, apiKey: string): Promise<CachedDirRow[]> {
const now = Date.now();
const cached = dirCache[apiKey];
if (cached && now - cached.fetchedAt < DIR_CACHE_TTL_MS) return cached.rows;
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
if (!res.ok) {
// KBDB 不可達 / 回錯:誠實回空(呼叫端各自決定 fallback,不快取失敗結果避免卡住恢復)
return [];
}
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
const rows: CachedDirRow[] = (body?.entries ?? [])
.filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
.map((e) => {
const meta = parseMeta(e);
return {
id: e.id,
name: e.page_name,
secret_ref: meta.secret_ref,
service: meta.service,
sensitivity: meta.sensitivity,
last_used_at: meta.last_used_at,
};
});
dirCache[apiKey] = { rows, fetchedAt: now };
return rows;
}
/**
* D1 upsert credential 目錄 row(不含密文)
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新
* service/sensitivity/secret_refsecret_ref 是純函式衍生自 api_key+name,理論上覆寫時
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
* 給熱路徑(auth-dispatcher.ts)用:回這個租戶所有 credential 的 name→secret_ref 對照
* 快取命中=零網路呼叫;未命中打一次 KBDB list(見 getCredentialDirectory)。
*/
async function upsertCredentialRow(
db: D1Database,
export async function getCredentialSecretRefs(env: Bindings, apiKey: string): Promise<CredentialRefMap> {
const rows = await getCredentialDirectory(env, apiKey);
const out: CredentialRefMap = {};
for (const r of rows) {
if (r.secret_ref) out[r.name] = r.secret_ref;
}
return out;
}
/**
* 治理面 last_used_at 更新(非關鍵路徑,best-effort,不阻塞呼叫端)。
* 直接用快取裡已知的 id/其餘欄位組 PATCH,不額外多打一次查詢。找不到快取(代表這個租戶
* 本次請求根本沒查到目錄,不太可能發生——resolveSecretsFromNewHome 只在有 secret_ref 命中時
* 才會呼叫本函式)就跳過,不為了治理欄位額外多打一輪 KBDB。
* 呼叫端刻意不 await 本函式的內部 fetchfire-and-forget,見 auth-dispatcher.ts),失敗吞掉。
*/
export function touchLastUsed(env: Bindings, apiKey: string, names: string[]): void {
const cached = dirCache[apiKey];
if (!cached || names.length === 0) return;
const now = Math.floor(Date.now() / 1000);
for (const r of cached.rows) {
if (!names.includes(r.name)) continue;
const meta: CredentialMeta = {
service: r.service, sensitivity: r.sensitivity, secret_ref: r.secret_ref, last_used_at: now,
};
kbdbCredFetch(env, `/entries/${encodeURIComponent(r.id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
}).catch(() => { /* 治理面欄位,非關鍵路徑,失敗不影響任何主流程 */ });
r.last_used_at = now; // 快取內同步更新,避免同一 TTL 視窗內下一次讀到舊值
}
}
/** 找某租戶某 credential 的 entrypage_name=name 精確比對,entry_type=credential 隔離)。 */
async function findCredentialEntry(env: Bindings, apiKey: string, name: string): Promise<KbdbEntryRow | null> {
const qs = new URLSearchParams({
owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, page_name: name, limit: '1',
});
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
if (!res.ok) throw new Error(`KBDB /entries 查詢失敗:HTTP ${res.status}`);
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
return body?.entries?.[0] ?? null;
}
/**
* upsert credential 目錄列(不含密文)。
* created_at 只在首次建立時寫入(entries 表自帶 created_atPATCH 不會動它);
* last_used_at 覆寫時保留原值——secret_ref 是純函式衍生自 api_key+name,理論上覆寫時值不會
* 變,這裡仍走同一條寫入路徑以求同時支援「首次建立」與「覆寫」兩種呼叫路徑(比照舊 D1 版本)。
*/
async function upsertCredentialEntry(
env: Bindings,
apiKey: string,
name: string,
service: string | null,
sensitivity: 'standard' | 'high',
secretRef: string,
): Promise<void> {
const now = Math.floor(Date.now() / 1000);
await db
.prepare(
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(api_key, name) DO UPDATE SET
service = excluded.service,
sensitivity = excluded.sensitivity,
secret_ref = excluded.secret_ref`,
)
.bind(apiKey, name, service, sensitivity, secretRef, now)
.run();
const existing = await findCredentialEntry(env, apiKey, name);
const meta: CredentialMeta = {
service, sensitivity, secret_ref: secretRef,
last_used_at: existing ? parseMeta(existing).last_used_at : null,
};
if (existing) {
const res = await kbdbCredFetch(env, `/entries/${encodeURIComponent(existing.id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
});
if (!res.ok) throw new Error(`credential 目錄更新失敗:HTTP ${res.status}`);
} else {
const res = await kbdbCredFetch(env, `/entries`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entry_type: CREDENTIAL_ENTRY_TYPE, owner_id: apiKey, page_name: name,
metadata_json: JSON.stringify(meta),
}),
});
if (!res.ok) throw new Error(`credential 目錄建立失敗:HTTP ${res.status}`);
}
invalidateCredentialCache(apiKey);
}
interface CredentialRow {
@@ -160,25 +342,26 @@ interface CredentialRow {
last_used_at: number | null;
}
/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
async function listCredentialRows(db: D1Database, apiKey: string): Promise<CredentialRow[]> {
const rows = await db
.prepare(
`SELECT name, service, sensitivity, created_at, last_used_at
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
)
.bind(apiKey)
.all<CredentialRow>();
return rows.results ?? [];
/** KBDB 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
async function listCredentialRows(env: Bindings, apiKey: string): Promise<CredentialRow[]> {
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
if (!res.ok) throw new Error(`credential 目錄查詢失敗:HTTP ${res.status}`);
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
const rows = (body?.entries ?? [])
.filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
.map((e) => {
const meta = parseMeta(e);
return { name: e.page_name, service: meta.service, sensitivity: meta.sensitivity, created_at: e.created_at, last_used_at: meta.last_used_at };
});
// entries API 已用 created_at DESC 排序,這裡不重排(保持與舊版 D1 query 相同排序語意)
return rows;
}
/** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> {
const row = await db
.prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`)
.bind(apiKey, name)
.first<{ secret_ref: string }>();
return row?.secret_ref ?? null;
/** 給 `GET /portal/admin/ai` 之類「只要知道有沒有存過、不要值」的呼叫端用。 */
export async function hasCredential(env: Bindings, apiKey: string, name: string): Promise<boolean> {
const entry = await findCredentialEntry(env, apiKey, name);
return entry !== null;
}
interface CredentialWriteBody {
@@ -203,13 +386,13 @@ async function writeCredential(
// 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回)
await putWorkerSecret(env, secretRef, value);
// 2. D1 目錄(不含密文)
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef);
// 2. KBDB 目錄(不含密文)
await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef);
return { secretRef, sensitivity };
}
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄)
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + KBDB entries 目錄)
credentialsRouter.post('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
@@ -272,17 +455,17 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
const name = c.req.param('name');
try {
const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
if (secretRef) {
await deleteWorkerSecret(c.env, secretRef);
await c.env.CREDENTIALS_DB
.prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`)
.bind(apiKey, name)
.run();
const entry = await findCredentialEntry(c.env, apiKey, name);
if (entry) {
const meta = parseMeta(entry);
if (meta.secret_ref) await deleteWorkerSecret(c.env, meta.secret_ref);
const res = await kbdbCredFetch(c.env, `/entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`credential 目錄刪除失敗:HTTP ${res.status}`);
invalidateCredentialCache(apiKey);
return c.json({ success: true, name, source: 'workers-secrets' });
}
// D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
// 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。
// KBDB 沒有這筆 entry:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
// 避免「GET 改讀新家看不到、DELETE 卻刪不掉」的孤兒資料。
await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`);
return c.json({ success: true, name, source: 'legacy-kv' });
} catch (e) {
@@ -290,8 +473,8 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
}
});
// GET /credentials/catalog — D1 目錄唯讀 listMira Console 完整版,Arcrun#3 console 系)。
// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑;
// GET /credentials/catalog — 目錄唯讀 listMira Console 完整版,Arcrun#3 console 系)。
// 與 GET /credentials(下方,改讀同一份 KBDB 查詢)是同一份資料的兩個路徑;
// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。
credentialsRouter.get('/credentials/catalog', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
@@ -299,22 +482,22 @@ credentialsRouter.get('/credentials/catalog', async (c) => {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
try {
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
const rows = await listCredentialRows(c.env, apiKey);
return c.json({ success: true, credentials: rows, total: rows.length });
} catch (e) {
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
// 誠實回報:KBDB 不可達 / 回錯(不假綠回空陣列裝沒事)
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
}
});
// GET /credentials — 列出 credential 目錄(T9:改讀 D1,只回 metadata,絕不含值/secret_ref
// GET /credentials — 列出 credential 目錄(改讀 KBDB,只回 metadata,絕不含值/secret_ref
credentialsRouter.get('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
try {
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
const rows = await listCredentialRows(c.env, apiKey);
return c.json({ success: true, credentials: rows, total: rows.length });
} catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
+2 -2
View File
@@ -27,14 +27,14 @@ executeRouter.post('/execute', async (c) => {
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
const duration_ms = Date.now() - start;
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成', context, apiKey)
);
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
} catch (err) {
const duration_ms = Date.now() - start;
const errMsg = err instanceof Error ? err.message : String(err);
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100), context, apiKey)
);
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
+22 -28
View File
@@ -13,6 +13,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { listPausedRunsByApiKey } from '../lib/paused-runs';
import { kbdbBase } from './kbdb-proxy';
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
@@ -132,11 +133,13 @@ executionsRouter.get('/executions/:task_id', async (c) => {
/**
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
*
* 走 ANALYTICS_KV `stats:{workflowId}:*` prefix scan。
* KV 額度事故修復(2026-08-07):改打 KBDB `GET /execution-log`(原走 ANALYTICS_KV
* `stats:{workflowId}:*` prefix scan,免費層 list 也是 1,000/日,裝十幾支 workflow
* 的實例刷 90 次 portal 就見底)。KBDBAPI-as-Wallleo 2026-06-14):本路由**不直連
* 任何 D1**,一律走 HTTP,連法比照既有 kbdbBase() 慣例(kbdb-proxy.ts)。
*
* workflowId 等於 webhook nameexecution-logger 寫入時用 graph.id ?? name)。
*
* 限制:ANALYTICS_KV list 沒辦法依 timestamp 排序,只能拿 key 後段 timestamp 排。
* workflowId 等於 webhook nameexecution-logger 寫入時用 graph.id ?? name,與舊 KV
* key 同語意,沿用既有限制不在這次修復裡處理)。
*/
executionsRouter.get('/workflows/:name/executions', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
@@ -164,30 +167,21 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
}, 404);
}
// 撈 stats:{name}:* 全 list(每個 key 含 timestamp 後綴)
const list = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
const { base, headers } = kbdbBase(c.env);
const params = new URLSearchParams({ workflow_id: name, owner_id: apiKey, limit: String(limit) });
const kbdbRes = await fetch(`${base}/execution-log?${params.toString()}`, { headers });
const kbdbBody = await kbdbRes.json().catch(() => null) as { success?: boolean; executions?: Array<{
verdict: string; duration_ms: number; message: string; target?: string; recorded_at: number;
}> } | null;
// 按 timestamp 降序(key suffix 是 unix ms
const sorted = [...list.keys].sort((a, b) => {
const ta = parseInt(a.name.split(':').pop() ?? '0', 10);
const tb = parseInt(b.name.split(':').pop() ?? '0', 10);
return tb - ta;
}).slice(0, limit);
const executions = [];
for (const key of sorted) {
const raw = await c.env.ANALYTICS_KV.get(key.name);
if (!raw) continue;
try {
const record = JSON.parse(raw);
executions.push({
timestamp: key.name.split(':').pop(),
...record,
});
} catch {
// skip
}
}
const executions = (kbdbRes.ok && kbdbBody?.success ? kbdbBody.executions ?? [] : []).map((r) => ({
timestamp: String(r.recorded_at),
workflow_id: name,
verdict: r.verdict,
duration_ms: r.duration_ms,
message: r.message ?? '',
...(r.target ? { target: r.target } : {}),
}));
return c.json({
ok: true,
@@ -197,7 +191,7 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
executions,
},
hints: executions.length === 0
? ['尚未有任何執行紀錄(或都過了 90d TTL。先 call /webhooks/named/:name/trigger 跑一次']
? ['尚未有任何執行紀錄。先 call /webhooks/named/:name/trigger 跑一次']
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
});
});
+10 -3
View File
@@ -1,5 +1,6 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { authStoreStatus } from '../lib/portal-auth-store';
export const healthRouter = new Hono<{ Bindings: Bindings }>();
@@ -10,11 +11,17 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
// 只是沒有人把它吐出來)。修=誠實回報本實例的 bundle 版本。
// 未注入(本地 dev/很舊的實例)就省略該欄——daemon 對空字串仍判 stale
// 那是**正確的**(真的是老實例,該更新)。
// D61ADR D61 / Leo/arcrun-rag#55):多吐一個 `auth_store`——「認證住哪、寫不寫得進去」
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
healthRouter.get('/health', (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
return c.json(
bundleVersion ? { ok: true, bundle_version: bundleVersion } : { ok: true },
);
return c.json({
ok: true,
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
auth_store: authStoreStatus(c.env),
});
});
healthRouter.get('/', (c) =>
+7
View File
@@ -50,6 +50,13 @@ initSeedRouter.post('/init/seed', async (c) => {
endpoint: seed.endpoint,
method: (seed.method ?? 'POST').toUpperCase(),
auth_service: seed.auth_service,
// ③ payload/回應/binding 三層(3.12):不列進來的欄位會被**靜默吃掉**——
// 種子帶了 body_template/response_map/auth 卻沒進 KV,症狀是 recipe 存在但跑起來
// 「像沒設定過」,且哪裡都不會紅(08-02 manifest.daemon 欄被列舉式重建吃掉的同型)。
body_template: seed.body_template,
response_map: seed.response_map,
auth: seed.auth,
binding_name: seed.binding_name,
created_at: existing?.created_at ?? now,
updated_at: now,
};
+187 -25
View File
@@ -23,7 +23,7 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
import { graphBase } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
@@ -73,6 +73,32 @@ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; e
return { neighbors, edges, count: neighbors.length };
}
/**
* 出處清單按 page_name 去重(t129):
* rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。
* 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。
* page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。
* 純函式,單測用 export。
*/
export function dedupeSourcesByPage(sources: unknown[]): unknown[] {
const seen = new Map<string, { item: Record<string, unknown>; count: number }>();
for (const s of sources) {
if (!s || typeof s !== 'object') continue;
const item = s as Record<string, unknown>;
const page = typeof item.page_name === 'string' ? item.page_name :
typeof item.page === 'string' ? item.page : '';
const existing = seen.get(page);
if (existing) {
existing.count += 1;
} else {
seen.set(page, { item, count: 1 });
}
}
return [...seen.values()].map(({ item, count }) =>
count > 1 ? { ...item, hit_count: count } : item,
);
}
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
function notFound(c: Context<{ Bindings: Bindings }>): Response {
return c.json({ error: '找不到這筆資料' }, 404);
@@ -103,7 +129,10 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
* 純函式(單測用 export)。
*/
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);
// execution_log/execution_log_usageKV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
entries: T[],
@@ -121,6 +150,62 @@ export function filterDeprecatedEntries<T extends { metadata_json?: string | nul
});
}
/**
* CJK/ASCII 邊界插空白正規化(t95):
* 「AI協作」→「AI 協作」;「協作AI」→「協作 AI」;已有空白不重複插。
* 只動查詢端,不動索引端。純函式,單測用 export。
*/
export function normalizeCjkQuery(q: string): string {
// U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat.
const isCjk = (c: string) => /[぀-鿿豈-﫿]/.test(c);
const isAsciiAlnum = (c: string) => /[぀-鿿豈-﫿]/.test(c);
let result = '';
for (let i = 0; i < q.length; i++) {
const ch = q[i];
if (result.length > 0) {
const prev = result[result.length - 1];
if (prev !== ' ' && ch !== ' ' &&
((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
result += ' ';
}
}
result += ch;
}
return result;
}
/**
* 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用):
* 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。
*/
export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
const term = normalizeCjkQuery(searchTerm).toLowerCase();
if (!term) return null;
const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
if (hits.length === 0) return null;
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
if (!body || !Array.isArray(body.records)) return null;
const nodeNames = new Set<string>();
for (const r of body.records) {
const v = r?.values;
if (!v || typeof v !== 'object') continue;
if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
}
return findBestNodeMatch(searchTerm, [...nodeNames]);
} catch {
return null; // fallback 失敗靜默略過,原本 0 結果直接回
}
}
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
//graph 走 /portal/data/graph/*)。server 注入 owner_idlibrary;回應照 KBDB 原形
//entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
@@ -129,8 +214,9 @@ portalDataRouter.get('/portal/data/search', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const q = c.req.query('q');
if (!q) return c.json({ error: 'q 必填' }, 400);
const qRaw = c.req.query('q');
if (!qRaw) return c.json({ error: 'q 必填' }, 400);
const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端)
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
@@ -142,7 +228,32 @@ portalDataRouter.get('/portal/data/search', (c) =>
if (!libraries.includes('*')) params.set('library', libraries.join(','));
// 透傳的只有「在權限範圍內再收窄」的 filterowner_id/library 上面已由 server 定死,
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
if (c.req.query('mode') === 'semantic') params.set('mode', 'semantic');
if (c.req.query('mode') === 'semantic') {
params.set('mode', 'semantic');
// 🔴 t183leo 08-04 實撞:「語義搜尋搜到一大堆不相關的內容」
// ——搜「火星座標」卻跑出 n8n 版本比較表、Leo 填答):
// Vectorize 會**硬湊滿 topK 筆**,湊不到就把低分的塞進來 ⇒ 尾巴全是無關內容。
// kbdb 早就支援 min_score`kbdb/src/embed.ts:225`issue #67),
// 但 portal **從來沒傳** ⇒ 等同沒有閾值,低分尾全端到用戶面前。
//
// 0.75 怎麼來的(**實測分數分布,不是猜的**;youlin 實例搜「火星座標 奧林帕斯山」):
// 0.908 / 0.881 / 0.881 / 0.880 / 0.870 / 0.815 / 0.798 / 0.787 ← 全是火星座標,真相關
// ─────────────────────── 斷崖 ───────────────────────
// 0.742 姨媽說故事 0.740 ax-academy 0.739×8 n8n 版本比較表 ← 全是雜訊
// 斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75:相關的全留、雜訊全砍。
//
// 允許前端覆寫(想放寬看更多可傳 min_score),但**不接受 0/負數**
// ——那等於關掉閾值,正是 t183 要修的病本身。
//
// 🔴 2026-08-05 修正(leo 實撞「語義搜尋 0 命中」):**這裡不再硬寫預設值**。
// 上面 0.75 是照**舊模型 bge-base-en-v1.5** 的分數分布定的;08-05 換 bge-m3 後
// 分數尺度整體下移,0.75 砍掉的變成正解 ⇒ 新上傳的檔一律 0 命中。
// 根因=**閾值是模型的性質,卻被複製到呼叫端**,換模型時沒人想到要回來改這行。
// ⇒ 預設值移到 `kbdb/src/embed.ts` 的 `DEFAULT_MIN_SCORE`(緊鄰 DEFAULT_EMBED_MODEL),
// portal 只在**使用者顯式指定**時才傳。**不要把數字搬回來。**
const msRaw = Number(c.req.query('min_score'));
if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw));
}
const entryType = c.req.query('entry_type');
if (entryType) params.set('entry_type', entryType);
const limit = c.req.query('limit');
@@ -205,6 +316,9 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
// t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
const nodeName = normalizeCjkQuery(c.req.param('name'));
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = portalTenant(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
@@ -214,7 +328,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const result = await executeWebhookGraph(
c.env,
wfGraph,
{ node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
// t116: 補傳 kbdb_baset128: 補傳 templateworkflow fetch_triplets.url 用 {{input.template}}
{ node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' },
'graph_neighbors',
tenant,
c.executionCtx,
@@ -231,8 +346,23 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
// t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」)
const resText = await res.text().catch(() => '');
let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
Array.isArray(data.edges) && data.edges.length === 0) {
const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
if (fallbackName && fallbackName !== nodeName) {
const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
}
}
return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
} catch (e) {
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
@@ -315,10 +445,12 @@ portalDataRouter.get('/portal/data/chat', (c) =>
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
}
// 回 workflow 回應內層 data{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
// t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。
const inner = unwrapWorkflowData(result.data, 'answer');
const rawSources = Array.isArray(inner.sources) ? inner.sources : [];
return c.json({
answer: typeof inner.answer === 'string' ? inner.answer : '',
sources: Array.isArray(inner.sources) ? inner.sources : [],
sources: dedupeSourcesByPage(rawSources),
graph_facts: inner.graph_facts ?? null,
});
}),
@@ -431,23 +563,20 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
/* 壞 record 誠實留空 */
}
}
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
// 最近一次執行:KV 額度事故修復(2026-08-07)改打 KBDB GET /execution-log/latest
// (原走 ANALYTICS_KV stats:{name}:* list,免費層 list 也是 1,000/日)。KBDB
// API-as-Wall:不直連 D1,走既有 kbdbFetch(本檔已在用,見上方 import)。
let last_execution: { timestamp: string; verdict?: string } | null = null;
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
if (stats.keys.length > 0) {
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
const ts = latest.name.split(':').pop() ?? '';
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
let verdict: string | undefined;
if (rawStat) {
try {
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
} catch {
/* 壞 record 誠實留空 */
}
}
last_execution = { timestamp: ts, verdict };
const execRes = await kbdbFetch(
c.env,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
);
const execBody = await execRes.json().catch(() => null) as {
success?: boolean;
execution?: { verdict: string; recorded_at: number } | null;
} | null;
if (execRes.ok && execBody?.success && execBody.execution) {
last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict };
}
return { name, description, created_at, cron_expr, last_execution };
}),
@@ -455,3 +584,36 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
}),
);
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
//
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
//
// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。
//
// 🔴 t2132026-08-08InkStoneCo 總管交辦):leo 實測拿真檔驗四個真實問題,只答得出一題
// (雲端這半的 bundle_version)——其餘三題(本機檔案總量、失敗分類統計、daemon 版本/
// 自我更新狀態)需要本機資料,雲端這支端點天生構不到(封測者的瀏覽器與他電腦上的
// daemon 是兩個獨立行程)。核准方案:本機那半改由 arcrun-app(daemon 桌面殼)匯出時
// 直接讀本機檔案,並改打**新增的** `GET /portal/daemon/diagnostics`X-Arcrun-API-Key
// 認證,免帳密)取雲端這半,兩者合併成一份完整診斷檔——arcrun-app 那半見
// products/arcrun-rag repo t213 phase 2。本端點(portal 網頁版)保留當退路(daemon
// 完全掛掉時仍按得到),文案需誠實講清楚自己只有一半,完整診斷請去 daemon 匯出
// (portal 前端文案改動不在本次 matrix/arcrun 範圍內,由 arcrun-rag 那邊處理)。
//
// 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變,核心邏輯已抽成 buildDiagnostics()
// portal.ts)——與新的 daemon 版共用同一份查詢邏輯(薄殼原則)。
portalDataRouter.get('/portal/data/diagnostics', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const tenant = portalTenant(c.env);
const core = await buildDiagnostics(c.env, tenant);
return c.json({
generated_at: new Date().toISOString(),
instance_url: new URL(c.req.url).origin,
bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
...core,
});
}),
);
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -312,7 +312,7 @@ async function triggerNamed(
c.executionCtx.waitUntil(
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
.then(result =>
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
),
);
return c.json({ accepted: true }, 202);
@@ -329,7 +329,7 @@ async function triggerNamed(
);
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
);
return c.json(result, result.success ? 200 : 500);
@@ -401,7 +401,7 @@ async function queryNamed(
// 執行判決寫入不阻塞回應(waitUntil,與 /trigger 一致)。
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
);
if (!result.success) {
+1 -1
View File
@@ -73,7 +73,7 @@ webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
const workflowId = graph.id ?? token;
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
);
return c.json(result, result.success ? 200 : 500);
+20
View File
@@ -6,6 +6,7 @@
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTimeUTC 分鐘精度)
* 3. 匹配才去讀完整 workflow record{apiKey}:wf:{name}
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
* 5. 每天固定一分鐘(UTC 02:30)順便叫 KBDB 清一批過期執行紀錄(P7 保留期,見下方 §5)
*
* 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限,
* 改成單一固定 key 只 get 一次 → list 歸零。
@@ -18,6 +19,7 @@ import type { Bindings } from './types';
import { cronMatch } from './lib/cron-match';
import { readCronIndex, parseCronEntryKey } from './lib/cron-index';
import { executeWebhookGraph } from './actions/webhook-handlers';
import { kbdbBase } from './routes/kbdb-proxy';
type StoredWorkflowRecord = {
graph: Record<string, unknown>;
@@ -73,4 +75,22 @@ export async function handleScheduled(
);
}
console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`);
// §5 P7 保留期清理(2026-08-09):不新增排程基礎設施(wrangler.toml [triggers] 是受保護
// 檔案,AI 不可編輯——見 InkStoneCo 頂層 pending-changes.md P9 段 L1 權限閘),改「搭便車」:
// 這支 handler 本來就每分鐘醒一次(給上面的 cron workflow 用),挑固定一分鐘(UTC 02:30,
// 避開整點/半點常見的 cron 表達式擁擠時段)順手打一次 fire-and-forget 給 KBDB 的
// POST /execution-log/cleanup。頻率仍是「一天一次」,不是輪詢外部系統要狀態,是既有 tick
// 順手打理自己的表。呼叫失敗不影響上面的 cron workflow 觸發(各自 try/catch,互不拖累)。
if (now.getUTCHours() === 2 && now.getUTCMinutes() === 30) {
const { base, headers } = kbdbBase(env);
ctx.waitUntil(
fetch(`${base}/execution-log/cleanup`, { method: 'POST', headers })
.then(async (r) => {
const body = await r.json().catch(() => null);
console.log('[scheduled] execution-log cleanup', r.status, JSON.stringify(body));
})
.catch((e) => console.error('[scheduled] execution-log cleanup failed', e)),
);
}
}
+8 -5
View File
@@ -30,11 +30,11 @@ export type Bindings = {
// Credential StoreAES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7
// 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡)
CREDENTIALS_KV: KVNamespace;
// credential-store-migration T2/T5D19「擁有目錄,不擁有內容物」):credential 目錄表
// api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,不含密文)。
// 與 KBDB base 共用同一顆 arcrun-kbdb D1self-hosted 由 deploy.ts 注入用戶自己的
// database_id,比照 kbdb/wrangler.toml 同一套 database_id 注入機制)。密文本體不在這裡,
// 住在 Workers per-script Secrets(見 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID
// ⚠️ D38 圍牆修復(2026-08-07)後零讀寫點:credential 目錄已改走 KBDB entries HTTP API
// 見 cypher-executor/src/routes/credentials.ts),不再對這顆 D1 下任何 SQL。binding
// 因 wrangler.toml 被權限鎖住(D38 決策所述)暫留宣告,比照 ANALYTICS_KV 同一模式
// commit 60688c3binding 留在 toml,程式碼零讀寫點)。舊表資料遷移路徑見
// kbdb/migrations/0006_drop_credentials_table.sql
CREDENTIALS_DB: D1Database;
// Analytics:執行統計(fire-and-forgetkey = stats:{workflowId}:{timestamp}
ANALYTICS_KV: KVNamespace;
@@ -103,6 +103,9 @@ export type Bindings = {
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
ARCRUN_BUNDLE_VERSION?: string;
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
// 真相住在 mcp worker 的同名 envmcp/src/types.ts,預設 259200030 天);cypher 這份
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
@@ -0,0 +1,101 @@
/**
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
*
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const CF_API = 'https://api.cloudflare.com';
const CREDS_KEY = 'console:credentials';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function json(method: string, path: string, body?: unknown) {
return SELF.fetch(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
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 };
}
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
* 單純為了在測試端準備一筆能通過驗證的 legacy fixture,不是重新實作生產邏輯。 */
async function legacyHash(password: string, salt: string): Promise<string> {
async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
}
let h = `${salt}:${password}`;
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
return h;
}
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 () => {
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();
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 };
};
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);
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); // 原樣搬過去,不重新雜湊
});
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');
});
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);
});
});
+201
View File
@@ -0,0 +1,201 @@
/**
* console-auth.ts 測試(D61console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55
*
* 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials`
* (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失
* console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。
* D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。
*
* 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋):
* 1. 全新實例:auth-status 回 configured:falselogin 回「讀不到認證資料」(不是密碼錯)。
* 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版
* 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。
* 4. 登入對錯:帳密正確 200;密碼錯 401。
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。
*
* 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`PUT),走 fetchMock 假 host
* 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。
*
* ⚠️ 測試順序不可打亂: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 執行個體,狀態不互相污染)。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, 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> = {}) {
return SELF.fetch(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
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 };
}
const OWNER_EMAIL = 'owner@example.com';
const OWNER_PW = 'owner-first-pw-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 } };
expect(data.configured).toBe(false);
expect(data.credentials_source).toBe('none');
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
});
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', 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 };
expect(data.code).toBe('auth_store_empty');
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
});
it('POST /console/setup/reset(還沒設定過就想換密碼)→ 400,叫去用 /console/setup', async () => {
const res = await json('POST', '/console/setup/reset', {
current_password: 'whatever', email: 'x@y.co', password: 'newpassword1',
});
expect(res.status).toBe(400);
});
});
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
describe('POST /console/setup — 首次設定', () => {
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
const { puts } = mockAuthStoreWrite();
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();
});
});
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
describe('POST /console/setup — 已設定過(重複設定)', () => {
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
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 {
error: string; code: string; password_applied: boolean; reset_path: string;
};
expect(data.code).toBe('already_configured');
expect(data.password_applied).toBe(false);
expect(data.error).toContain('沒有被採用');
expect(data.reset_path).toBe('/console/setup/reset');
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
});
it('GET /console/auth-status → configured:truecredentials_source:secrets(新家優先命中)', async () => {
const res = await json('GET', '/console/auth-status');
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
expect(data.configured).toBe(true);
expect(data.credentials_source).toBe('secrets');
expect(data.auth_store.console_configured).toBe(true);
});
});
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
describe('POST /console/login', () => {
it('帳密正確 → 200,發 session token', async () => {
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 };
expect(data.success).toBe(true);
expect(typeof data.session_token).toBe('string');
});
it('密碼錯 → 401', async () => {
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:換密碼,寫進新家 ═══════════════
describe('POST /console/setup/reset', () => {
const NEW_PW = 'brand-new-owner-pw-1';
it('舊密碼錯 → 401,不寫入', async () => {
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();
const res = await json('POST', '/console/setup/reset', {
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
});
expect(res.status).toBe(200);
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 oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
expect(oldLogin.status).toBe(401);
// 新密碼生效
const newLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: NEW_PW });
expect(newLogin.status).toBe(200);
});
});
+239 -89
View File
@@ -1,110 +1,260 @@
/**
* credential 治理端點測試。
* credentials 路由測試(D38 圍牆修復後補寫,2026-08-08
*
* 範圍限制(誠實記錄,非本檔缺陷):`putWorkerSecret` / `deleteWorkerSecret` 呼叫真實
* Cloudflare API`fetch` 到 api.cloudflare.com)。測試環境(wrangler.test.toml)刻意不設
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID,所以本檔只覆蓋「不需要真的打 CF API」的路徑:
* - D1-only 的 GET /credentials、/credentials/catalog
* - DELETE 在 D1 無 row 時 fallback 刪舊 KV(不會走到 deleteWorkerSecret
* 真正打 CF Workers Secrets API 成功寫入/刪除的路徑,由部署到 leo21c 帳號後的端到端
* curl 驗證覆蓋(見 credential-store-migration.md T8/T9 完成記錄)。
* 前身是刻意留紅的 placeholder(見 git history):2026-08-07 D38 把 credential 目錄從
* 「獨立 credentials 表 + 原生 SQL」改成「KBDB entriesentry_type='credential'+
* HTTP API」,舊測試全部作廢,agent 中途被中斷沒補上,故意留一個會失敗的測試佔位、
* 避免「no tests」被誤讀成「通過」。本檔依 placeholder 頭部列的五項補齊。
*
* 測試手法比照姊妹模組 execution-logger.test.ts`vi.stubGlobal('fetch', ...)` 攔截,
* 但這裡的攔截器是**有狀態的假 KBDB**in-memory entries store),因為 credentials.ts
* 一次操作常涉及多輪 HTTP 呼叫(find → upsert / find → delete),單次回應的 mock 測不出
* 「查得到剛寫的」「刪掉後真的查不到」這類語意,需要一個會記狀態的假後端。
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { env, SELF } from 'cloudflare:test';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { Hono } from 'hono';
import { credentialsRouter, getCredentialSecretRefs, hasCredential, invalidateCredentialCache } from '../src/routes/credentials';
import type { Bindings } from '../src/types';
// Workers runtime@cloudflare/vitest-pool-workers)沒有 node:fs——原始碼掃描改用 Vite 的
// `?raw` import 取字串內容(build-time 讀檔,runtime 是純字串,不受 Workers 限制)。
// @ts-expect-error -- vite ?raw 型別由 tsconfig 的 vite/client 提供,非本檔關注重點
import credentialsSource from '../src/routes/credentials.ts?raw';
const API_KEY = 'test-tenant-t89';
afterEach(() => vi.unstubAllGlobals());
async function insertCredentialRow(
name: string,
secretRef: string,
extra: Partial<{ service: string | null; sensitivity: string; last_used_at: number | null }> = {},
): Promise<void> {
await env.CREDENTIALS_DB
.prepare(
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
API_KEY,
name,
extra.service ?? null,
extra.sensitivity ?? 'standard',
secretRef,
Math.floor(Date.now() / 1000),
extra.last_used_at ?? null,
)
.run();
// ── 有狀態假 KBDB:只實作 credentials.ts 實際會打的四個操作(GET list/find, POST, PATCH, DELETE)──
interface FakeEntry {
id: string;
entry_type: string;
owner_id: string;
page_name: string;
metadata_json: string;
created_at: number;
}
async function clearTenantRows(): Promise<void> {
await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run();
function makeFakeKbdb() {
const entries: FakeEntry[] = [];
let idSeq = 0;
const secretsStore = new Map<string, string>(); // secretRef -> plaintext(模擬 CF Workers Secrets,唯寫,測試用來斷言「有沒有被塞值」)
const secretPuts: Array<{ name: string; text: string }> = [];
const secretDeletes: string[] = [];
const kbdbRequests: Array<{ method: string; url: string; body: unknown }> = [];
async function handle(url: string, init: RequestInit = {}): Promise<Response> {
const method = (init.method ?? 'GET').toUpperCase();
const u = new URL(url);
// CF Workers Scripts secrets 管理 API(唯寫,讀不回值)
if (u.hostname === 'api.cloudflare.com') {
if (method === 'PUT' && u.pathname.endsWith('/secrets')) {
const body = JSON.parse(String(init.body)) as { name: string; text: string };
secretsStore.set(body.name, body.text);
secretPuts.push(body);
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
if (method === 'DELETE' && u.pathname.includes('/secrets/')) {
const name = u.pathname.split('/secrets/')[1];
secretsStore.delete(name);
secretDeletes.push(name);
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
throw new Error(`unhandled CF API call: ${method} ${url}`);
}
// KBDB entries API
kbdbRequests.push({ method, url, body: init.body ? JSON.parse(String(init.body)) : undefined });
if (method === 'POST' && u.pathname === '/entries') {
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
const entry: FakeEntry = {
id: `e_${++idSeq}`,
entry_type: body.entry_type!,
owner_id: body.owner_id!,
page_name: body.page_name!,
metadata_json: body.metadata_json!,
created_at: Math.floor(Date.now() / 1000),
};
entries.push(entry);
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
}
if (method === 'GET' && u.pathname === '/entries') {
const ownerId = u.searchParams.get('owner_id');
const entryType = u.searchParams.get('entry_type');
const pageName = u.searchParams.get('page_name');
let rows = entries.filter((e) => e.entry_type === entryType && e.owner_id === ownerId);
if (pageName) rows = rows.filter((e) => e.page_name === pageName);
return new Response(JSON.stringify({ success: true, entries: rows, count: rows.length }), { status: 200 });
}
if (method === 'PATCH' && u.pathname.startsWith('/entries/')) {
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
const entry = entries.find((e) => e.id === id);
if (!entry) return new Response(JSON.stringify({ success: false }), { status: 404 });
if (body.metadata_json !== undefined) entry.metadata_json = body.metadata_json;
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
}
if (method === 'DELETE' && u.pathname.startsWith('/entries/')) {
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
const idx = entries.findIndex((e) => e.id === id);
if (idx === -1) return new Response(JSON.stringify({ success: false }), { status: 404 });
entries.splice(idx, 1); // 真的從陣列移除,不是標記
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
throw new Error(`unhandled KBDB call: ${method} ${url}`);
}
vi.stubGlobal('fetch', vi.fn((url: string, init?: RequestInit) => handle(url, init)));
return { entries, secretsStore, secretPuts, secretDeletes, kbdbRequests };
}
describe('GET /credentials (D1, T9)', () => {
beforeEach(clearTenantRows);
function fakeEnv(): Bindings {
return {
KBDB_BASE_URL: 'https://kbdb.test',
CF_SECRETS_API_TOKEN: 'fake-cf-token',
CF_ACCOUNT_ID: 'fake-account',
ENVIRONMENT: 'test',
CREDENTIALS_KV: { delete: vi.fn(async () => {}) } as unknown as KVNamespace,
} as unknown as Bindings;
}
it('缺 X-Arcrun-API-Key → 401', async () => {
const res = await SELF.fetch('https://cypher.test/credentials');
expect(res.status).toBe(401);
});
function app() {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/', credentialsRouter);
return a;
}
it('無資料 → 空陣列(非拋錯)', async () => {
const res = await SELF.fetch('https://cypher.test/credentials', {
headers: { 'X-Arcrun-API-Key': API_KEY },
});
beforeEach(() => {
invalidateCredentialCache('tenant-a');
invalidateCredentialCache('tenant-b');
});
describe('1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離)', () => {
it('POST /credentials 寫入後,entries 裡的 owner_id 就是呼叫者的 api_key', async () => {
const fake = makeFakeKbdb();
const env = fakeEnv();
const a = app();
const res = await a.request('/credentials', {
method: 'POST',
headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'telegram_bot_token', value: 'secret-plaintext-value', service: 'telegram' }),
}, env);
expect(res.status).toBe(200);
const body = await res.json() as { success: boolean; credentials: unknown[]; total: number };
const body = (await res.json()) as { success: boolean };
expect(body.success).toBe(true);
expect(body.credentials).toEqual([]);
expect(body.total).toBe(0);
expect(fake.entries).toHaveLength(1);
expect(fake.entries[0].owner_id).toBe('tenant-a');
expect(fake.entries[0].page_name).toBe('telegram_bot_token');
});
it('回傳 metadata,絕不含 secret_ref 或值', async () => {
await insertCredentialRow('telegram_bot_token', 'CRED_TELEGRAM_BOT_TOKEN_ABCDEF01', { service: 'telegram' });
const res = await SELF.fetch('https://cypher.test/credentials', {
headers: { 'X-Arcrun-API-Key': API_KEY },
});
const body = await res.json() as { success: boolean; credentials: Array<Record<string, unknown>> };
expect(body.success).toBe(true);
expect(body.credentials).toHaveLength(1);
const row = body.credentials[0];
expect(row.name).toBe('telegram_bot_token');
expect(row.service).toBe('telegram');
expect(row).not.toHaveProperty('secret_ref');
expect(row).not.toHaveProperty('value');
expect(JSON.stringify(row)).not.toMatch(/CRED_/);
});
it('/credentials/catalog 回同一份資料(Console 相容別名)', async () => {
await insertCredentialRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01');
const [listRes, catalogRes] = await Promise.all([
SELF.fetch('https://cypher.test/credentials', { headers: { 'X-Arcrun-API-Key': API_KEY } }),
SELF.fetch('https://cypher.test/credentials/catalog', { headers: { 'X-Arcrun-API-Key': API_KEY } }),
]);
const [listBody, catalogBody] = await Promise.all([listRes.json(), catalogRes.json()]) as Array<{
credentials: Array<{ name: string }>;
}>;
expect(listBody.credentials.map(r => r.name)).toEqual(catalogBody.credentials.map(r => r.name));
it('兩個不同 api_key 各自建立的同名 credential 落在不同 owner_id、互不覆蓋', async () => {
const fake = makeFakeKbdb();
const env = fakeEnv();
const a = app();
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
}, env);
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-b' }),
}, env);
expect(fake.entries).toHaveLength(2);
const owners = fake.entries.map((e) => e.owner_id).sort();
expect(owners).toEqual(['tenant-a', 'tenant-b']);
});
});
describe('DELETE /credentials/:name (T9)', () => {
beforeEach(clearTenantRows);
describe('2. 讀取查得回 secret_ref,且查不到別的租戶的', () => {
it('getCredentialSecretRefs 回該租戶的 name→secret_ref 對照,不含其他租戶的', async () => {
makeFakeKbdb();
const env = fakeEnv();
const a = app();
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
}, env);
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'other_key', value: 'value-b' }),
}, env);
it('D1 無 row(從未回填)→ fallback 刪舊 KV,不誤報找不到', async () => {
await env.CREDENTIALS_KV.put(
`${API_KEY}:cred:legacy_only`,
JSON.stringify({ encrypted: 'x', iv: 'y' }),
);
const res = await SELF.fetch('https://cypher.test/credentials/legacy_only', {
method: 'DELETE',
headers: { 'X-Arcrun-API-Key': API_KEY },
});
const body = await res.json() as { success: boolean; source: string };
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.source).toBe('legacy-kv');
const raw = await env.CREDENTIALS_KV.get(`${API_KEY}:cred:legacy_only`);
expect(raw).toBeNull();
const refsA = await getCredentialSecretRefs(env, 'tenant-a');
expect(Object.keys(refsA)).toEqual(['gemini_api_key']);
expect(refsA.gemini_api_key).toMatch(/^CRED_GEMINI_API_KEY_/);
expect(refsA.other_key).toBeUndefined(); // 查不到別租戶的
const refsB = await getCredentialSecretRefs(env, 'tenant-b');
expect(Object.keys(refsB)).toEqual(['other_key']);
});
it('hasCredential:查得到自己的,查不到別租戶的同名 credential', async () => {
makeFakeKbdb();
const env = fakeEnv();
const a = app();
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'kbdb_internal_token', value: 'v' }),
}, env);
expect(await hasCredential(env, 'tenant-a', 'kbdb_internal_token')).toBe(true);
expect(await hasCredential(env, 'tenant-b', 'kbdb_internal_token')).toBe(false);
});
});
describe('3. 刪除是真的刪(不是 deprecated 標記)', () => {
it('DELETE /credentials/:name 後,該筆 entries row 從 KBDB 消失(不是 metadata 打 deprecated 標記)', async () => {
const fake = makeFakeKbdb();
const env = fakeEnv();
const a = app();
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'to_delete', value: 'v' }),
}, env);
expect(fake.entries).toHaveLength(1);
const res = await a.request('/credentials/to_delete', {
method: 'DELETE', headers: { 'X-Arcrun-API-Key': 'tenant-a' },
}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; source: string };
expect(body.success).toBe(true);
expect(body.source).toBe('workers-secrets');
// 真的從陣列移除,不是留著、metadata 打上 status:deprecated
expect(fake.entries).toHaveLength(0);
// Workers Secret 本體也真的被刪(DELETE 呼叫過),不是只刪目錄留孤兒密文
expect(fake.secretDeletes.length).toBe(1);
});
});
describe('4. 零原生 SQL:整支檔案不得出現 .prepare/.exec/.batch', () => {
it('routes/credentials.ts 原始碼掃描:沒有任何 D1 原生呼叫語法', () => {
expect(/\.\s*(prepare|exec|batch)\s*\(/.test(credentialsSource)).toBe(false);
});
});
describe('5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變', () => {
it('送去 KBDB 的 body 裡從頭到尾沒有明文 credential value,只有 secret_ref', async () => {
const fake = makeFakeKbdb();
const env = fakeEnv();
const a = app();
const plaintext = 'super-secret-plaintext-should-never-leave-workers-secrets';
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'sensitive_key', value: plaintext }),
}, env);
// 明文只出現在 CF Workers Secrets 的 PUT(唯寫 API),不出現在任何打去 KBDB 的請求 body 裡
expect(fake.secretPuts.some((p) => p.text === plaintext)).toBe(true);
for (const req of fake.kbdbRequests) {
expect(JSON.stringify(req.body ?? '')).not.toContain(plaintext);
}
// entries 裡存的是 secret_ref 指標,不是值
expect(fake.entries[0].metadata_json).not.toContain(plaintext);
expect(fake.entries[0].metadata_json).toContain('secret_ref');
});
});
@@ -0,0 +1,100 @@
/**
* execution-logger 測試(KV 額度事故修復,2026-08-07
*
* KBDBAPI-as-Wallleo 2026-06-14):cypher-executor 端不直連任何 D1,一律 fire-and-forget
* fetch KBDB `/execution-log/record`。本檔驗的是「cypher 這一側」的職責,測試手法比照姊妹模組
* execution-evaluator.test.tsrecordComponentStats,同款「fire-and-forget POST 統計」):
* `vi.stubGlobal('fetch', ...)` 直接攔截,不用 fetchMock。
* 1. 送出的 payload 形狀正確(workflow_id/owner_id/verdict/duration_ms/message/target
* 2. target 從 trigger context 的 page_name/path 擷取(少記:不整包送 input)
* 3. 任何錯誤(fetch reject、KBDB 回非 2xx)都不影響呼叫端(永不 throw)
*
* 「少記截斷長度」「A2 自我降級」的實際邏輯與驗證在 KBDB 端(kbdb/tests/execution-log.test.ts),
* 因為決策/儲存都搬到 KBDB 做了,cypher 只是薄殼呼叫端。
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { writeExecutionVerdict } from '../src/actions/execution-logger';
import type { Bindings } from '../src/types';
afterEach(() => vi.unstubAllGlobals());
function fakeEnv(): Bindings {
return {
KBDB_BASE_URL: 'https://kbdb.test',
ENVIRONMENT: 'test',
} as unknown as Bindings;
}
function stubFetchCapture(): { calls: Array<{ url: string; body: Record<string, unknown> }> } {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url: String(url), body: JSON.parse(String(init.body)) });
return new Response(JSON.stringify({ success: true, written: true, mode: 'log' }), { status: 200 });
}));
return { calls };
}
describe('writeExecutionVerdict — 送出正確 payload(少記,不整包 input', () => {
it('成功:POST 到 KBDB_BASE_URL/execution-log/record,帶 workflow_id/owner_id/verdict/duration_ms/message', async () => {
const { calls } = stubFetchCapture();
await writeExecutionVerdict(
fakeEnv(), 'wf-1', [], 'success', 123, '執行完成', { page_name: 'a.md' }, 'ak_test',
);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('https://kbdb.test/execution-log/record');
expect(calls[0].body).toEqual({
workflow_id: 'wf-1',
owner_id: 'ak_test',
verdict: 'success',
duration_ms: 123,
message: '執行完成',
target: 'a.md',
});
});
it('targetpage_name 優先,沒有時 fallback path;都沒有則為 null', async () => {
const { calls: calls1 } = stubFetchCapture();
await writeExecutionVerdict(fakeEnv(), 'wf-2', [], 'failed', 1, 'err', { path: 'docs/x.md' });
expect(calls1[0].body.target).toBe('docs/x.md');
vi.unstubAllGlobals();
const { calls: calls2 } = stubFetchCapture();
await writeExecutionVerdict(fakeEnv(), 'wf-3', [], 'failed', 1, 'err', undefined);
expect(calls2[0].body.target).toBeNull();
});
it('不整包送 input:巨大的無關欄位不會出現在送出的 payload 裡', async () => {
const { calls } = stubFetchCapture();
await writeExecutionVerdict(fakeEnv(), 'wf-4', [], 'failed', 1, 'err', {
page_name: 'a.md',
unrelated_huge_field: 'z'.repeat(10000),
});
expect(Object.keys(calls[0].body).sort()).toEqual(
['duration_ms', 'message', 'owner_id', 'target', 'verdict', 'workflow_id'],
);
});
it('沒有 apiKey/execute 舊路徑):owner_id 送 null,不炸', async () => {
const { calls } = stubFetchCapture();
await writeExecutionVerdict(fakeEnv(), 'wf-5', [], 'success', 1, 'ok');
expect(calls[0].body.owner_id).toBeNull();
});
});
describe('writeExecutionVerdict — 記錄失敗不影響主流程(永不 throw)', () => {
it('KBDB 端點連不上(fetch reject):函式仍正常 resolve', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); }));
await expect(
writeExecutionVerdict(fakeEnv(), 'wf-broken', [], 'failed', 1, '任何訊息'),
).resolves.toBeUndefined();
});
it('KBDB 回非 2xx(例如額度打滿的 5xx):函式仍正常 resolve', async () => {
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ success: false, error: 'quota exceeded' }), { status: 500 }),
));
await expect(
writeExecutionVerdict(fakeEnv(), 'wf-broken2', [], 'failed', 1, '任何訊息'),
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,83 @@
/**
* GET /workflows/:name/executions — KV 額度事故修復(2026-08-07)路由測試。
* 改打 KBDB GET /execution-log(原走 ANALYTICS_KV list);KBDBAPI-as-Wall
* 本檔一律 fetchMock 攔截,不碰任何 D1(比照 tests/portal-data.test.ts 慣例)。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KBDB = 'https://kbdb.test'; // wrangler.test.toml KBDB_BASE_URL
const API_KEY = 'ak_exec_test';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function get(path: string, headers: Record<string, string> = {}) {
return SELF.fetch(`http://localhost${path}`, { headers });
}
describe('GET /workflows/:name/executions', () => {
it('缺 X-Arcrun-API-Key → 401,不打 KBDB', async () => {
const res = await get('/workflows/wf-x/executions');
expect(res.status).toBe(401);
});
it('workflow 不存在或不屬於該 api_key → 404,不打 KBDB', async () => {
const res = await get('/workflows/nope/executions', { 'X-Arcrun-API-Key': API_KEY });
expect(res.status).toBe(404);
});
it('workflow 存在 → 轉發打 KBDB GET /execution-log,回傳其 executions', async () => {
await env.WEBHOOKS.put(
`${API_KEY}:wf:daily_report`,
JSON.stringify({ graph: { id: 'daily_report', nodes: [] }, description: 'x', created_at: '2026-08-07T00:00:00Z' }),
);
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => p.startsWith('/execution-log?'),
method: 'GET',
})
.reply(200, {
success: true,
executions: [
{ verdict: 'success', duration_ms: 100, message: 'ok', recorded_at: 1783500000 },
{ verdict: 'failed', duration_ms: 50, message: '找不到 workflow', target: 'a.md', recorded_at: 1783400000 },
],
});
const res = await get('/workflows/daily_report/executions', { 'X-Arcrun-API-Key': API_KEY });
expect(res.status).toBe(200);
const body = await res.json() as {
ok: boolean;
data: { workflow_name: string; count: number; executions: Array<{ verdict: string; target?: string }> };
};
expect(body.ok).toBe(true);
expect(body.data.count).toBe(2);
expect(body.data.executions[0].verdict).toBe('success');
expect(body.data.executions[1].target).toBe('a.md');
await env.WEBHOOKS.delete(`${API_KEY}:wf:daily_report`);
});
it('KBDB 回非 success(例如全降級停記錄後空清單)→ 誠實回空陣列,不是假資料', async () => {
await env.WEBHOOKS.put(
`${API_KEY}:wf:empty_wf`,
JSON.stringify({ graph: { id: 'empty_wf', nodes: [] }, description: 'x', created_at: '2026-08-07T00:00:00Z' }),
);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/execution-log?'), method: 'GET' })
.reply(200, { success: true, executions: [] });
const res = await get('/workflows/empty_wf/executions', { 'X-Arcrun-API-Key': API_KEY });
const body = await res.json() as { data: { count: number; executions: unknown[] } };
expect(body.data.count).toBe(0);
expect(body.data.executions).toEqual([]);
await env.WEBHOOKS.delete(`${API_KEY}:wf:empty_wf`);
});
});
+139
View File
@@ -1,6 +1,8 @@
// Cypher Executor 端到端測試
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import { GraphExecutor } from '../src/graph-executor';
import type { ComponentRunner, ExecutionGraph } from '../src/types';
describe('GET /', () => {
it('回傳服務狀態', async () => {
@@ -191,4 +193,141 @@ describe('POST /execute', () => {
});
expect(res.status).toBe(400);
});
});
// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status codeGraphExecutor 單元測試)
describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => {
it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => {
// mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"}
const failLoader = async (_: string): Promise<ComponentRunner> =>
async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } });
const executor = new GraphExecutor(failLoader);
const graph: ExecutionGraph = {
id: 'foreach-fail-t117',
name: 'FOREACH 全失敗',
nodes: [
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
{ id: 'writer', type: 'Component', componentId: 'http_request' },
],
edges: [
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
],
};
// t117 核心驗證:全部失敗 → throw(不再靜默)
await expect(executor.execute(graph, {})).rejects.toThrow(/401/);
});
it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => {
let callCount = 0;
// 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑)
const mixedLoader = async (_: string): Promise<ComponentRunner> =>
async () => {
callCount++;
if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' };
return { success: true, data: { ok: true } };
};
const executor = new GraphExecutor(mixedLoader);
const graph: ExecutionGraph = {
id: 'foreach-mixed-t117',
name: 'FOREACH 部分失敗',
nodes: [
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
{ id: 'writer', type: 'Component', componentId: 'http_request' },
],
edges: [
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
],
};
// 部分失敗 → 不拋出,正常回傳 results 陣列
const result = await executor.execute(graph, {});
expect(result).toBeDefined();
});
});
// P8 短板齊平(2026-08-09):節點輸出只在「下游有 PIPE 邊會讀」時才寫 KV。
// 背景:BUILD-006 原本每個節點(含 FOREACH 每一圈)都 put 一次 EXEC_CONTEXT
// 但全 codebase 唯一讀點是 PIPE 邊的 kvGetNodeOutput——rag 系工作流
// ON_SUCCESS+對每個)一張卡白燒 15 次 KV write,把免費層 1,000/日
// 壓成比 Workers AI neurons 更短的板。此測試鎖住「無 PIPE 出邊=零 KV put」
// 與「有 PIPE 出邊=照舊寫、_kv_outputs 照舊可讀」兩個行為。
describe('P8:節點輸出 KV 寫入只服務 PIPE 讀者', () => {
// 計數型 KV mock:只記 put 次數(kvSetNodeOutput 只用到 putget 給 PIPE 讀)
function countingKv() {
const store = new Map<string, string>();
let puts = 0;
const kv = {
put: async (k: string, v: string) => { puts++; store.set(k, v); },
get: async (k: string) => store.get(k) ?? null,
} as unknown as KVNamespace;
return { kv, getPuts: () => puts };
}
it('ON_SUCCESSFOREACH 工作流(rag_ingest_card 形狀)→ 零 KV put', async () => {
const loader = async (id: string): Promise<ComponentRunner> => async () => {
if (id === 'parse') {
return { success: true, blocks: [{ n: 1 }, { n: 2 }, { n: 3 }], rels: [{ r: 1 }, { r: 2 }] };
}
return { success: true, data: { ok: true } };
};
const executor = new GraphExecutor(loader);
const graph: ExecutionGraph = {
id: 'p8-no-pipe',
name: 'rag 形狀(無 PIPE 邊)',
nodes: [
{ id: 'input', type: 'Input', data: {} },
{ id: 'list_old', type: 'Component', componentId: 'http_request' },
{ id: 'parse_card', type: 'Component', componentId: 'parse' },
{ id: 'post_block', type: 'Component', componentId: 'http_request' },
{ id: 'post_triplet', type: 'Component', componentId: 'http_request' },
],
edges: [
{ from: 'input', to: 'list_old', type: 'ON_SUCCESS' },
{ from: 'list_old', to: 'parse_card', type: 'ON_SUCCESS' },
{ from: 'parse_card', to: 'post_block', type: 'FOREACH', iterator: 'block' },
{ from: 'parse_card', to: 'post_triplet', type: 'FOREACH', iterator: 'rel' },
],
};
const { kv, getPuts } = countingKv();
const result = await executor.execute(graph, {}, kv);
expect(result).toBeDefined();
// 修法前這裡是 8list_old + parse_card + 3×post_block + 2×post_triplet input 不寫)
expect(getPuts()).toBe(0);
});
it('PIPE 工作流 → 照舊寫 KV 且 _kv_outputs 傳遞不變(BUILD-006 語意保留)', async () => {
const seen: Record<string, unknown>[] = [];
const loader = async (id: string): Promise<ComponentRunner> => async (ctx) => {
seen.push(ctx as Record<string, unknown>);
return { success: true, data: { from: id } };
};
const executor = new GraphExecutor(loader);
const graph: ExecutionGraph = {
id: 'p8-pipe',
name: 'PIPE 鏈',
nodes: [
{ id: 'input', type: 'Input', data: { message: 'hi' } },
{ id: 'a', type: 'Component', componentId: 'comp_a' },
{ id: 'b', type: 'Component', componentId: 'comp_b' },
],
edges: [
{ from: 'input', to: 'a', type: 'PIPE' },
{ from: 'a', to: 'b', type: 'PIPE' },
],
};
const { kv, getPuts } = countingKv();
const result = await executor.execute(graph, {}, kv);
expect(result).toBeDefined();
// a 有 PIPE 出邊 → 寫;b 沒有出邊 → 不寫(原本 a、b 都寫=2)
expect(getPuts()).toBe(1);
// 下游 b 收到的 context 帶 _kv_outputs.aBUILD-006 讀路徑不變)
const bCtx = seen[seen.length - 1];
expect((bCtx._kv_outputs as Record<string, unknown>)?.a).toBeDefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { SELF } from 'cloudflare:test';
import { healthRouter } from '../src/routes/health';
import type { Bindings, ExecutionContext } from '../src/types';
describe('GET /health — bundle_version 欄位', () => {
it('無 ARCRUN_BUNDLE_VERSION 時省略該欄(老實例情境)', async () => {
// wrangler.test.toml 不設此 var → health.ts 省略 bundle_version 欄位。
// daemon 端讀不到該欄=當作空字串=判 stale,對老實例而言**這是正確行為**
//(見 health.ts 檔頭註解)。此處驗「省略」而非「回空字串」,與實作對齊。
const res = await SELF.fetch('http://localhost/health');
const data = await res.json() as { ok: boolean; bundle_version?: string };
expect(res.status).toBe(200);
expect(data.ok).toBe(true);
expect(data.bundle_version).toBeUndefined();
});
it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => {
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '2026-07-28/6d06162' } as unknown as Bindings;
const res = await healthRouter.fetch(
new Request('http://localhost/health'),
fakeEnv,
{} as ExecutionContext,
);
const data = await res.json() as { ok: boolean; bundle_version: string };
expect(data.ok).toBe(true);
expect(data.bundle_version).toBe('2026-07-28/6d06162');
});
});
@@ -0,0 +1,67 @@
/**
* /init/seed 必須把種子的 3.12 三層欄位原樣寫進 KV —— SDD: workflow-discovery task 3.12/3.13
*
* 為什麼要有這個測試(別刪):
* 3.12 給 `RecipeDefinition` 加了 body_template / response_map / auth / binding_name
* 但 `/init/seed` 當時是**列舉欄位重建** recipe record ⇒ 不在名單上的欄位被靜默吃掉。
* 症狀最惡劣的地方在於「哪裡都不會紅」:recipe 查得到、canonical_id 對、endpoint 對,
* 只有跑起來像沒設定過(auth 掉了 ⇒ 走 HTTP 路徑去 fetch「@cf/…」這種不是網址的字串)。
* 這與 2026-08-02 `syncManifest()` 列舉式重建吃掉 `manifest.daemon` 欄是同一型事故——
* 當時的教訓寫著:「**東西還在不在**也要進機械閘」,本檔就是那道閘。
*
* 範圍:只驗「種子 → KV」這段(純資料搬運)。真的呼叫 Workers AI 由實例端到端驗。
*/
import { describe, it, expect } from 'vitest';
import { env, SELF } from 'cloudflare:test';
import { API_RECIPE_SEEDS } from '../src/lib/api-recipe-seeds';
type StoredRecipe = {
canonical_id: string;
endpoint: string;
auth?: string;
binding_name?: string;
body_template?: Record<string, unknown>;
response_map?: { text_path?: string; answer_marker?: string; strip_prefixes?: string[] };
};
async function seedThenRead(canonicalId: string): Promise<StoredRecipe> {
const res = await SELF.fetch('https://example.com/init/seed', { method: 'POST' });
// 測試環境沒有 KBDB binding ⇒ portal template 那段必然失敗、整體回 207(誠實回報,非本測目標)。
// 本檔只管 API recipe 那半,所以驗它自己的計數,不驗整體 status。
const body = await res.json<{ api_recipes: { seeded: number; failed: number; errors: string[] } }>();
expect(body.api_recipes.errors).toEqual([]);
expect(body.api_recipes.failed).toBe(0);
const uuid = await env.RECIPES.get(`idx:installed:${canonicalId}`);
expect(uuid, `${canonicalId} 沒有被 seed 進 KV`).toBeTruthy();
return JSON.parse((await env.RECIPES.get(`recipe:${uuid}`))!) as StoredRecipe;
}
describe('/init/seed 不得靜默吃掉 recipe 的 3.12 欄位', () => {
it('workers_ai_chat 種子本身宣告齊四個欄位(種子端)', () => {
const seed = API_RECIPE_SEEDS.find(s => s.canonical_id === 'workers_ai_chat');
expect(seed, 'workers_ai_chat 種子不存在=裝完不會有免金鑰問答').toBeDefined();
expect(seed!.auth).toBe('binding');
expect(seed!.binding_name).toBe('AI');
expect(seed!.endpoint.startsWith('@cf/'), 'binding 型的 endpoint=模型 id').toBe(true);
expect(seed!.body_template).toBeDefined();
expect(seed!.response_map?.text_path).toBe('response');
});
it('seed 之後 KV 裡讀回來的仍帶 auth/binding_name/body_template/response_mapKV 端)', async () => {
const stored = await seedThenRead('workers_ai_chat');
expect(stored.auth, 'auth 掉了 ⇒ 會被當成 HTTP recipe 去 fetch 一個不是網址的字串').toBe('binding');
expect(stored.binding_name).toBe('AI');
expect(stored.body_template, 'body_template 掉了 ⇒ 整包 ctx 被當 payload 送給模型').toBeDefined();
expect(stored.response_map?.text_path, 'response_map 掉了 ⇒ 下游拿不到 text').toBe('response');
expect(stored.response_map?.answer_marker).toBe('【答】');
});
it('既有 HTTP 種子不受影響:沒宣告新欄位就是 undefined,不憑空長出來', async () => {
const stored = await seedThenRead('telegram_send');
expect(stored.auth).toBeUndefined();
expect(stored.binding_name).toBeUndefined();
expect(stored.body_template).toBeUndefined();
expect(stored.response_map).toBeUndefined();
expect(stored.endpoint).toContain('api.telegram.org');
});
});
+18 -26
View File
@@ -80,7 +80,7 @@ describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () =>
});
describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => {
it('回 has_key 布林claude 旗標,且回應完全不含金鑰值', async () => {
it('回 has_key 布林,且回應完全不含金鑰值', async () => {
await seedSession('tok-a1', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1'));
@@ -89,8 +89,6 @@ describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key', ()
const d = JSON.parse(raw) as Record<string, unknown>;
expect(typeof d.has_key).toBe('boolean');
expect(typeof d.claude_available).toBe('boolean');
expect(typeof d.use_claude_for_extract).toBe('boolean');
// D36:回應裡不得出現任何疑似金鑰的欄位
expect(raw).not.toContain('gemini_api_key_value');
@@ -98,10 +96,22 @@ describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key', ()
expect(d).not.toHaveProperty('value');
expect(d).not.toHaveProperty('secret_ref');
});
// t176 回歸守衛(leo 08-03):雲端不再有「地端用哪個模型」的概念。
// 這兩個欄位若復活,代表又走回「雲端控制地端」的老路——那正是 08-03 事故根因
//extractor_config 全租戶共用一把,任一處設 claude 就讓所有人萃取全滅)。
it('不再回 claude_availableuse_claude_for_extract(地端模型改由小幫手自己設)', async () => {
await seedSession('tok-a1b', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1b'));
const d = (await res.json()) as Record<string, unknown>;
expect(d).not.toHaveProperty('claude_available');
expect(d).not.toHaveProperty('use_claude_for_extract');
});
});
describe('POST /portal/admin/ai — 不假裝成功', () => {
it('空 body(沒 key 也沒偏好)→ 400,不回 success', async () => {
it('空 body(沒帶金鑰)→ 400,不回 success', async () => {
await seedSession('tok-a2', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2'));
@@ -111,31 +121,13 @@ describe('POST /portal/admin/ai — 不假裝成功', () => {
expect(String(d.error)).toContain('沒有要變更');
});
it('只改 Claude 偏好(不帶 key)→ 成功並回存後的值', async () => {
// t176 回歸守衛:只送 Claude 偏好=沒有要變更的項目 → 400(該欄位已不存在)。
it('只送 use_claude_for_extract(已廢欄位)→ 400,不得假裝成功', async () => {
await seedSession('tok-a3', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3'));
expect(res.status).toBe(200);
expect(res.status).toBe(400);
const d = (await res.json()) as Record<string, unknown>;
expect(d.success).toBe(true);
expect(d.use_claude_for_extract).toBe(true);
// 沒送 key ⇒ 不得回報 has_key(避免誤報「已輸入」)
expect(d.has_key).toBeUndefined();
});
// 註:vitest-pool-workers 預設 isolatedStorage=true ⇒ **每個 it 之間 KV 會還原**
// 所以「寫在上一個 it、讀在下一個 it」測不出來(那是測試框架語意,不是程式缺陷)。
// 要驗來回一致,必須在**同一個 it** 內完成寫→讀。
it('偏好可讀回:同一測試內 POST 寫入 → GET 讀得到同一值', async () => {
await seedSession('tok-a4', 'rec_admin');
mockGetRecord('rec_admin', adminValues()); // POST 的 requirePortalAdmin 回讀
mockGetRecord('rec_admin', adminValues()); // GET 的 requirePortalAdmin 回讀
const post = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a4'));
expect(post.status).toBe(200);
const get = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a4'));
const d = (await get.json()) as Record<string, unknown>;
expect(d.use_claude_for_extract).toBe(true);
expect(d.success).toBeUndefined();
});
});
+414 -35
View File
@@ -4,9 +4,10 @@
* 覆蓋(=tasks.md P4+總管派工驗收重點):
* 1. **最後一個 active admin 鎖死保護**:停用 → 409;降級 role=user → 409
* 「還有另一個 active admin」才放行;另一個 admin 是 disabled 不算數。
* 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落 KBDB
* 庫裡只有 pbkdf2 hash);自帶密碼 → 回應無 generated_password。
* 3. reset-password:回一次性新密碼;PATCH 進 KBDB 的是 hash 非明碼
* 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落
* 新家只有 pbkdf2 hash,D61 起帳號建立走認證儲存不再落 KBDB);自帶密碼 → 回應無 generated_password。
* 3. reset-password:回一次性新密碼;PATCH 落地的是 hash 非明碼(目標帳號沿用舊家 fixture
* 仍走 KBDB PATCH——見下方 mockPatchPrelude 的說明)。
* 4. 庫權限:PATCH libraries=["*"](全庫)合法;空陣列/壞庫名 → 400。
* 5. 庫目錄:POST 建庫寫 {tenant}::portal 子 namespacePATCH graph_source boolean。
* 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、
@@ -14,12 +15,21 @@
*
* KBDB 打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 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 走同一支 createPortalUser)改成寫進認證儲存(CF Workers
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`PUT)——見 mockAuthStoreWrite。
*/
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;
@@ -39,6 +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 }> = [];
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 };
}
function mockHeadLookup(email: string, recordId: string | null) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock
@@ -66,7 +91,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
}
function mockTemplatesExist() {
for (const name of ['portal_user', 'portal_library']) {
for (const name of ['portal_user', 'portal_library', 'triplet']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
@@ -177,23 +202,11 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id', () => {
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
describe('POST /portal/admin/users(一次性密碼)', () => {
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼D61', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('new@example.com', null); // email 未占用
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return { success: true, record: { record_id: 'rec_new', template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: 'e_head' } });
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
const res = await json(
'POST',
'/portal/admin/users',
@@ -205,26 +218,23 @@ 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);
// 一次性密碼不落庫: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);
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
// 一次性密碼不落地:認證儲存收到的 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);
});
it('自帶 password → 回應**無** generated_password', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('own@example.com', null);
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, { success: true, record: { record_id: 'rec_own', template_id: 'tpl_pu', values: {} } });
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: 'e_head2' } });
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
mockAuthStoreWrite();
const res = await json(
'POST',
'/portal/admin/users',
@@ -263,6 +273,70 @@ describe('POST /portal/admin/users/:id/reset-password', () => {
});
});
// ═══════════════ 3.5 recover-passwordarcrun-rag#25admin 忘記 portal 密碼自救)═══════════════
describe('POST /portal/admin/recover-password', () => {
it('無 console owner session → 401,不碰 KBDB', async () => {
const res = await json('POST', '/portal/admin/recover-password', { email: 'admin@example.com' });
expect(res.status).toBe(401);
});
it('有 console session 但 email 格式不對 → 400,不碰 KBDB', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
const res = await json(
'POST',
'/portal/admin/recover-password',
{ email: 'not-an-email' },
{ Authorization: 'Bearer owner-token' },
);
expect(res.status).toBe(400);
});
it('查無此 email 的 portal 帳號 → 404,不誤導成別種錯誤', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
mockHeadLookup('ghost@example.com', null);
const res = await json(
'POST',
'/portal/admin/recover-password',
{ email: 'ghost@example.com' },
{ Authorization: 'Bearer owner-token' },
);
expect(res.status).toBe(404);
});
it('console session 有效+帳號存在 → 回一次性新密碼;PATCH 落 KBDB 的是新 hash 非明碼;**不需要任何 portal session**', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
// 刻意不 seedAdminSession():這條路唯一該吃的是 console session,機械證明繞得過
// 「忘記 portal 密碼 ⇒ 沒有 portal_sess ⇒ 打不進其他 admin 端點」這個死結。
mockHeadLookup('admin@example.com', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
let patched = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records/rec_admin', method: 'PATCH' })
.reply(200, (opts) => {
patched = String(opts.body);
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: adminValues() } };
});
const res = await json(
'POST',
'/portal/admin/recover-password',
{ email: 'Admin@Example.com' }, // 混寫大小寫,驗證正規化成小寫再查
{ Authorization: 'Bearer owner-token' },
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; email: string; password: string };
expect(data.success).toBe(true);
expect(data.email).toBe('admin@example.com');
expect(typeof data.password).toBe('string');
expect(data.password.length).toBe(16);
expect(patched).not.toContain(data.password); // 明碼不落 KBDB
const sent = JSON.parse(patched) as { values: Record<string, string> };
expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(sent.values.password_hash).not.toBe(storedHash); // 真的換了
});
});
// ═══════════════ 4. 庫權限勾選(libraries PATCH)═══════════════
describe('PATCH libraries(每帳號可查庫)', () => {
@@ -350,12 +424,259 @@ describe('/portal/admin/libraries', () => {
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
it('GET auto 庫列表過濾 generalgeneral 是系統桶,不在用戶目錄顯示)', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []);
// t142GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: ['kb', 'general', 'notes'] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] };
const names = data.libraries.map((l) => l.name);
expect(names).toContain('kb');
expect(names).toContain('notes');
expect(names).not.toContain('general');
});
});
// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════
describe('GET /portal/admin/libraries + statst142', () => {
it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } },
]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: ['kb'] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] };
const kb = data.libraries.find((l) => l.name === 'kb');
expect(kb).toBeDefined();
expect(kb!.card_count).toBe(42);
expect(kb!.triplet_count).toBe(111);
});
it('auto 庫也帶 card_count + triplet_count', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: ['notes'] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] };
const notes = data.libraries.find((l) => l.name === 'notes');
expect(notes).toBeDefined();
expect(notes!.auto).toBe(true);
expect(notes!.card_count).toBe(7);
expect(notes!.triplet_count).toBe(108);
});
it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } },
]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] };
const empty = data.libraries.find((l) => l.name === 'empty');
expect(empty).toBeDefined();
expect(empty!.card_count).toBe(0);
expect(empty!.triplet_count).toBe(0);
});
});
// ═══════════════ t135 庫目錄移除 ═══════════════
describe('DELETE /portal/admin/librariest135', () => {
it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
// 成員驗證:list by template 回有該 record
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
]);
let deleteCalled = false;
fetchMock
.get(KBDB)
.intercept({ path: '/records/rec_lib1', method: 'DELETE' })
.reply(200, () => { deleteCalled = true; return { success: true }; });
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; name: string; message: string };
expect(data.success).toBe(true);
expect(data.name).toBe('finance');
expect(deleteCalled).toBe(true);
});
it('DELETE /:id — 庫不在目錄 → 404', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []); // 空目錄
const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(404);
});
it('DELETE /:id — 非 admin → 403', async () => {
await seedAdminSession('tok-user', 'rec_u1');
mockGetRecord('rec_u1', userValues());
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
let deprecateCalled = false;
fetchMock
.get(KBDB)
.intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
.reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; deprecated_count: number };
expect(data.success).toBe(true);
expect(data.deprecated_count).toBe(12);
expect(deprecateCalled).toBe(true);
});
it('DELETE /by-name/:name — 無 confirm → 400', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(400);
});
it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(400);
});
it('DELETE /by-name/:name — 非 admin → 403', async () => {
await seedAdminSession('tok-user', 'rec_u1');
mockGetRecord('rec_u1', userValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
});
// ═══════════════ 6. t176:雲端不再管地端 LLM 設定(取代原 t122/t131 兩組測試)═══════════════
//
// leo 2026-08-03 架構翻案:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,
// 而不是雲端設置後控制地端」。原因是 extractor_config 的 KV key 由 portalTenant() 組出,
// 而 portalTenant 是 **worker 層級**環境變數 ⇒ **全租戶共用一把**:任一處設了 claude,
// 所有人的 daemon 都收到 claude,沒裝 Claude Code 的機器萃取全滅,
// 而 portal 的 Claude 勾選框又恆 disableddaemon 從未回報 has_claude)⇒ 用戶自己解不開。
//
// 以下是**回歸守衛**:這些端點/欄位若復活,代表又走回「雲端控制地端」的老路。
describe('t176:雲端不再下發/設定地端 LLM', () => {
const USER_EMAIL = 'daemon@example.com';
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(外層 beforeAll 計算)
const USER_RECORD = 'rec_daemon_user';
/** mock email head lookupfindUserRecordId 走這個路徑)*/
function mockEmailLookup(email: string, recordId: string | null) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
method: 'GET',
})
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
}
it('POST /portal/daemon/config 只回連線欄位,**不含任何 LLM 欄位**', async () => {
mockEmailLookup(USER_EMAIL, USER_RECORD);
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
expect(res.status).toBe(200);
const d = (await res.json()) as { config: Record<string, unknown> };
// 連線欄位照舊(daemon 靠它上線)
expect(d.config.cypher_url).toBeTruthy();
expect(d.config.namespace).toBeTruthy();
expect(d.config.library).toBe('kb');
// LLM 欄位一律不下發(t176 核心)
expect(d.config).not.toHaveProperty('extractor');
expect(d.config).not.toHaveProperty('gemini_api_key');
expect(d.config).not.toHaveProperty('llm_model');
});
// 註:route 不存在 ⇒ 在認證之前就 404,因此不需要(也不能)預先掛 record mock
// 否則 afterEach 的 assertNoPendingInterceptors 會因「mock 沒被用到」而失敗。
it('POST /portal/admin/extractor 已移除(雲端不再有指定地端引擎的入口)', async () => {
const res = await json('POST', '/portal/admin/extractor', { engine: 'claude' }, { Authorization: 'Bearer tok-ex' });
expect(res.status).toBe(404);
});
it('POST /portal/daemon/report-capabilities 已移除(has_claude 回報鏈整條退役)', async () => {
const res = await json('POST', '/portal/daemon/report-capabilities', {
email: USER_EMAIL, password: USER_PW, has_claude: true,
});
expect(res.status).toBe(404);
});
});
// ═══════════════ 7. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
describe('GET /portalP4 admin 頁 HTML 殼)', () => {
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira', async () => {
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => {
const res = await SELF.fetch('http://localhost/portal');
expect(res.status).toBe(200);
const html = await res.text();
@@ -367,5 +688,63 @@ describe('GET /portalP4 admin 頁 HTML 殼)', () => {
expect(html).not.toContain('/kbdb/');
expect(html).not.toContain('X-Arcrun-API-Key');
expect(html).not.toContain('Mira');
// t97abootstrap 後不再預埋 kb 庫
expect(html).not.toContain('"name": "kb"');
expect(html).not.toContain("name: 'kb'");
// t114:無「登記到目錄」按鈕
expect(html).not.toContain('lib-adopt');
expect(html).not.toContain('登記到目錄');
// t131:合併 AI 設定(舊兩區塊已移除)
expect(html).toContain('st-ai-panel');
expect(html).toContain('st-ai-key');
expect(html).toContain('st-ai-use-claude');
expect(html).not.toContain('st-extractor-panel');
expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除
});
});
// t131/t122 測試已隨 main 的 t176(刪除雲端下發 LLM 設定)一併移除;
// 此處只保留 t181daemon 走 Workers AI)的守衛。
describe('POST /portal/daemon/extractt181Workers AI 萃卡,免金鑰)', () => {
// 認證=X-Arcrun-API-Key(=namespacewrangler.test.toml CONSOLE_TENANT=leo),
// **不是帳密**:daemon 密碼不落地(連線精靈用完即丟),背景萃取拿不到密碼。
const KEY = { 'X-Arcrun-API-Key': 'leo' };
it('沒帶 API Key → 401', async () => {
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' });
expect(res.status).toBe(401);
});
// 🔴 t189:這則原本是「API Key 錯 → 401(租戶隔離)」,**是錯的,而且害我看到假綠**。
//
// 它假設「daemon 的 api_key 實例的 CONSOLE_TENANT」,但實測不成立:
// geek6688tenant=ckxt8yr9、daemon api_key=yuga3bse ⇒ 真用戶**永遠 401**、萃不了
// youlin :兩者碰巧相同 ⇒ 我這邊測起來都對
// 舊測試只證明「符合我的假設」,不證明「假設是對的」——
// **把錯誤假設寫成測試,就是把假綠焊死。**
//
// 翻轉成守衛:**key 與 tenant 不同也要能萃**(這正是 leo 撞到的情境)。
// 若哪天有人又加回等值比對,這則會紅。
it('key 與實例 tenant 不同也要能用(t189:多帳號 daemon 的常態)', async () => {
const res = await json('POST', '/portal/daemon/extract',
{ page_name: 'x', text: 'y' }, { 'X-Arcrun-API-Key': 'another-tenant-key' });
expect(res.status).not.toBe(401);
});
it('缺 page_name 或 text → 400(不打 AI、不假裝成功)', async () => {
const res = await json('POST', '/portal/daemon/extract', {}, KEY);
expect(res.status).toBe(400);
const d = (await res.json()) as { error?: string };
expect(String(d.error)).toContain('page_name');
});
// 🔴 回歸守衛:這條路**不得**要求任何 Gemini/API 金鑰——免金鑰正是它存在的理由。
// 若哪天有人把它改回打 Google,錯誤訊息會出現 credential/gemini_api_key ⇒ 這則會紅。
it('錯誤訊息不得要求任何金鑰(免金鑰是本端點存在的理由)', async () => {
const res = await json('POST', '/portal/daemon/extract', {}, KEY);
const raw = await res.text();
expect(raw).not.toContain('gemini_api_key');
expect(raw).not.toContain('credential');
});
});
+205 -37
View File
@@ -4,7 +4,7 @@
* =tasks.md P2
* 1. KDFpbkdf2-sha256$100000$ CF Workers runtime 100k2026-07-14
* false600k hash
* 2. bootstrap console session 401 admin {tenant}::portal namespace
* 2. bootstrap console session 401 admin ****D61
* admin 409
* 3. token**** 401 403 email 401
* 4. 5 429KV TTL
@@ -12,15 +12,28 @@
* 6. hash 100k slot
* 7. role admin admin 403admin ** password_hash**
*
* D61ADR D61 / Leo/arcrun-rag#55
* 8.
* 9. 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 { 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 { portalRouter } from '../src/routes/portal';
import type { Bindings, ExecutionContext } from '../src/types';
const KBDB = 'https://kbdb.test';
const CF_API = 'https://api.cloudflare.com';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
const EMAIL = 'user@example.com';
const PASSWORD = 'correct-horse-9';
@@ -43,6 +56,34 @@ 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 */
@@ -73,7 +114,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
}
function mockTemplatesExist() {
for (const name of ['portal_user', 'portal_library']) {
for (const name of ['portal_user', 'portal_library', 'triplet']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
@@ -133,6 +174,33 @@ 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 分鐘的事故)', () => {
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前);
// 舊家(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 } };
expect(data.code).toBe('auth_store_empty');
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
expect(data.error).toContain('不是密碼錯');
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
expect(data.auth_store.users).toBe(0);
// 不計入鎖定:lockfail 計數器完全沒被寫入
expect(await env.SESSIONS_KV.get('portal_lockfail:anyone@example.com')).toBeNull();
});
});
// ═══════════════ 2. bootstrap 閘 ═══════════════
describe('POST /portal/admin/bootstrap', () => {
@@ -141,28 +209,12 @@ describe('POST /portal/admin/bootstrap', () => {
expect(res.status).toBe(401);
});
it('console session OK → 建第一個 adminrecord + head entry 都寫 {tenant}::portal 子 namespace', async () => {
it('console session OK → 建第一個 admin寫進認證儲存(D61,不再落 KBDB)', 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 未占用
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: {} } };
});
let headBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, (opts) => {
headBody = String(opts.body);
return { success: true, entry: { id: 'e_head' } };
});
mockListByTemplate('portal_user', []); // 尚無 admin(新家空,舊家也空)
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
const res = await json(
'POST',
@@ -173,23 +225,25 @@ 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(data.record_id).toBe('rec_admin');
expect(typeof data.record_id).toBe('string');
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1
const rec = JSON.parse(recordBody) as { owner_id: string; values: Record<string, string>; template: string };
expect(rec.template).toBe('portal_user');
expect(rec.owner_id).toBe(NS); // ← D-2 子 namespace 機械斷言
expect(rec.values.role).toBe('admin');
expect(rec.values.status).toBe('active');
expect(rec.values.libraries).toBe('["*"]');
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(recordBody).not.toContain('bootstrap-pw-1'); // 明碼絕不落 KBDB
const head = JSON.parse(headBody) as Record<string, string>;
expect(head.owner_id).toBe(NS);
expect(head.entry_type).toBe('portal_user');
expect(head.page_name).toBe('admin@example.com');
expect(head.content).toBe('rec_admin');
// 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'); // 明碼絕不落地
});
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
@@ -209,6 +263,12 @@ 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。
it('成功:發 session token;回 display_name/role/libraries**無任何租戶字串欄位**', async () => {
mockHeadLookup(EMAIL, 'rec_1');
mockGetRecord('rec_1', activeUserValues());
@@ -226,6 +286,11 @@ 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 () => {
@@ -414,3 +479,106 @@ describe('admin 端點 role 閘', () => {
expect(res.status).toBe(404);
});
});
// ═══════════════ t130 — triplet template seed ═══════════════
describe('t130 — triplet template seedPORTAL_TEMPLATE_SEEDS 補 tripletensurePortalTemplates 冪等)', () => {
it('PORTAL_TEMPLATE_SEEDS 含 triplet 且必要 slots 齊備(pure data', () => {
const seed = PORTAL_TEMPLATE_SEEDS.find((s) => s.name === 'triplet');
expect(seed).toBeDefined();
for (const slot of ['subject', 'predicate', 'object', 'source_uri', 'status', 'library']) {
expect(seed!.slots).toContain(slot);
}
});
it('POST /init/seed — triplet 已存 → existing(冪等,不重建)', async () => {
for (const name of ['portal_user', 'portal_library', 'triplet']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
}
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
expect(res.status).toBe(200);
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
expect(data.portal_templates.existing).toContain('triplet');
expect(data.portal_templates.created).not.toContain('triplet');
});
it('POST /init/seed — triplet 缺 → 自動補建(新實例首次 seed)', async () => {
for (const name of ['portal_user', 'portal_library']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
}
fetchMock
.get(KBDB)
.intercept({ path: '/templates/triplet', method: 'GET' })
.reply(404, { success: false, error: 'template not found: triplet' });
fetchMock
.get(KBDB)
.intercept({ path: '/templates', method: 'POST' })
.reply(200, { success: true, template: { id: 'tpl-triplet-new', name: 'triplet' } });
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
expect(res.status).toBe(200);
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
expect(data.portal_templates.created).toContain('triplet');
expect(data.portal_templates.existing).not.toContain('triplet');
});
});
// ═══════════════ D61:舊實例登入自癒(搬進新家)═══════════════
//
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
// 不會有更後面的測試共用這個 email 而被污染。
describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
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();
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 };
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); // 原樣搬過去,不重新雜湊
});
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,
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
});
});
+531 -6
View File
@@ -19,7 +19,7 @@
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { workflowsVisible } from '../src/routes/portal';
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data';
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
@@ -297,8 +297,12 @@ describe('GET /portal/data/workflowsD-8admin 唯讀)', () => {
`${TENANT}:wf:daily_report`,
JSON.stringify({ description: '每日彙整', created_at: '2026-07-14T00:00:00Z', cron_expr: '0 9 * * *' }),
);
await env.ANALYTICS_KV.put('stats:daily_report:1783500000000', JSON.stringify({ verdict: 'success' }));
await env.ANALYTICS_KV.put('stats:daily_report:1783400000000', JSON.stringify({ verdict: 'failed' }));
// KV 額度事故修復(2026-08-07):last_execution 資料源改打 KBDB GET /execution-log/latest
// KBDBAPI-as-Wall,本檔一律 fetchMock 攔截,不碰任何 D1)。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/execution-log/latest?'), method: 'GET' })
.reply(200, { success: true, execution: { verdict: 'success', recorded_at: 1783500000 } });
const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w2' });
expect(res.status).toBe(200);
const data = (await res.json()) as {
@@ -309,13 +313,11 @@ describe('GET /portal/data/workflowsD-8admin 唯讀)', () => {
const wf = data.workflows.find((w) => w.name === 'daily_report');
expect(wf).toBeTruthy();
expect(wf!.description).toBe('每日彙整');
expect(wf!.last_execution?.verdict).toBe('success'); // 取到「最新」那筆(timestamp 較大者)
expect(wf!.last_execution?.verdict).toBe('success');
expect(JSON.stringify(data)).not.toContain('webhook_url');
expect(JSON.stringify(data)).not.toContain('/trigger');
// 清場(KV 是 suite 共用實例,避免污染其他測試)
await env.WEBHOOKS.delete(`${TENANT}:wf:daily_report`);
await env.ANALYTICS_KV.delete('stats:daily_report:1783500000000');
await env.ANALYTICS_KV.delete('stats:daily_report:1783400000000');
});
it('workflowsVisible 單元:admin(預設/壞值)/ all / off', () => {
@@ -417,3 +419,526 @@ describe('mapGraphWorkflowOutput#57 workflow 輸出 → plugin 形狀)', ()
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
});
});
// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════
describe('normalizeCjkQueryt95 CJK/ASCII 邊界補空白)', () => {
it('純中文 → 不動', () => {
expect(normalizeCjkQuery('中文')).toBe('中文');
expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複
});
it('純 ASCII/數字 → 不動', () => {
expect(normalizeCjkQuery('ABC123')).toBe('ABC123');
expect(normalizeCjkQuery('')).toBe('');
});
it('CJK→ASCII 邊界插空白', () => {
expect(normalizeCjkQuery('協作AI')).toBe('協作 AI');
expect(normalizeCjkQuery('中文1234')).toBe('中文 1234');
});
it('ASCII→CJK 邊界插空白', () => {
expect(normalizeCjkQuery('AI協作')).toBe('AI 協作');
expect(normalizeCjkQuery('1234中文')).toBe('1234 中文');
});
it('已有空白不重複插', () => {
expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書');
});
it('全形符號(非 ASCII alnum)不觸發插空白', () => {
expect(normalizeCjkQuery('全形:中文')).toBe('全形:中文');
});
});
// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════
describe('findBestNodeMatcht96 fuzzy 節點比對)', () => {
it('空清單 → null', () => {
expect(findBestNodeMatch('AI 協作', [])).toBeNull();
});
it('完全不包含 → null', () => {
expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull();
});
it('精確子字串命中 → 返回', () => {
expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書');
});
it('多命中 → 取最短(最精確優先)', () => {
const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']);
expect(result).toBe('AI'); // 最短
});
it('CJK 未正規化的搜尋詞也能比對(normalizeCjkQuery 先處理)', () => {
// 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」
expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書');
});
it('大小寫不敏感', () => {
expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書');
});
});
// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════
describe('GET /portal/data/searcht95 CJK 正規化)', () => {
it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => {
await seedSession('tok-cn1', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
const cap = captureSearch();
await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' });
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('q')).toBe('AI 協作'); // 已補空白
});
it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => {
await seedSession('tok-cn2', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
const cap = captureSearch();
await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' });
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('q')).toBe('AI 協作'); // 無重複空白
});
});
// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════
describe('GET /portal/data/graph/neighbors/:namet96 fuzzy fallback', () => {
it('plugin 精確命中有鄰居 → 直接回,不觸發 fallback', async () => {
await seedSession('tok-gf1', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 });
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1); // 有鄰居直接回
});
it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => {
await seedSession('tok-gf2', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
// 精確命中「AI 協作」→ 0 鄰居
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
// KBDB triplets → 含「AI 協作規範書」
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, {
records: [
{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } },
{ values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } },
],
});
// fallback 以「AI 協作規範書」重查 → 有鄰居
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居
});
it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => {
await seedSession('tok-gf3', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
// KBDB triplets → 完全沒有能比對的節點
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] });
const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
expect(data.neighbors.length).toBe(0); // 誠實回 0,不偽造
expect(data.edges.length).toBe(0);
});
it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => {
await seedSession('tok-gf4', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
// plugin 收到的是正規化後的「AI 協作」(%20 分隔)
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] });
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
// 前端傳「AI協作」(無空白,URL encoded
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1);
});
});
// ═══════════════ 12. t116: graph_neighbors workflow 補傳 kbdb_base ═══════════════
describe('GET /portal/data/graph/neighbors/:namet116 kbdb_base 補傳)', () => {
it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_baseworkflow 正常執行不崩', async () => {
// 設定 session["*"] 全庫,放行 graph 粗閘)
await seedSession('tok-t116', 'rec_t116');
mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' }));
// 在 WEBHOOKS KV 放 graph_neighbors workflowInput→Output 直通)
// 這個 workflow 不用 {{input.kbdb_base}},只驗工作流路徑正常執行(不走 graphBase fallback
// 若沒補傳 kbdb_base 但 workflow 內有 {{input.kbdb_base}} 的節點,URL 解析失敗 → executeWebhookGraph 回 error
// 此測試退而求其次:用無外部依賴的直通圖確認整個路徑都通(workflow 取代 plugin fallback
const wfKey = `${TENANT}:wf:graph_neighbors`;
await env.WEBHOOKS.put(wfKey, JSON.stringify({
graph: {
id: 'gn-t116',
name: 'graph_neighbors',
nodes: [
{ id: 'input', type: 'Input' },
// comp_passthrough 是內建零件,不需外部 fetch,直接回傳 context
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
{ id: 'output', type: 'Output' },
],
edges: [
{ from: 'input', to: 'pass', type: 'PIPE' },
{ from: 'pass', to: 'output', type: 'PIPE' },
],
},
description: 't116 test',
created_at: '2026-07-29T00:00:00.000Z',
}));
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t116' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[]; count: number; kbdb_base?: string };
// workflow 走 comp_passthroughoutput = 整個 context(含 kbdb_base
// mapGraphWorkflowOutput 只取 neighbors/edges,其他欄位不影響回應
expect(Array.isArray(data.neighbors)).toBe(true);
expect(Array.isArray(data.edges)).toBe(true);
// 確認不是 502graph_neighbors workflow 執行失敗)
expect(res.status).not.toBe(502);
await env.WEBHOOKS.delete(wfKey);
});
});
// ═══════════════ 13. t128: graph_neighbors workflow 補傳 template ═══════════════
describe('GET /portal/data/graph/neighbors/:namet128 template 補傳)', () => {
it('tenant 有 graph_neighbors workflow → portal 傳入 template=tripletworkflow 不崩', async () => {
await seedSession('tok-t128', 'rec_t128');
mockGetRecord('rec_t128', userValues({ libraries: '["*"]', role: 'admin' }));
const wfKey = `${TENANT}:wf:graph_neighbors`;
await env.WEBHOOKS.put(wfKey, JSON.stringify({
graph: {
id: 'gn-t128',
name: 'graph_neighbors',
nodes: [
{ id: 'input', type: 'Input' },
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
{ id: 'output', type: 'Output' },
],
edges: [
{ from: 'input', to: 'pass', type: 'PIPE' },
{ from: 'pass', to: 'output', type: 'PIPE' },
],
},
}));
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t128' });
// template 有進 context → workflow 執行不崩(非 502
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
expect(Array.isArray(data.neighbors)).toBe(true);
await env.WEBHOOKS.delete(wfKey);
});
});
// ═══════════════ 14. t129: dedupeSourcesByPage 純函式 ═══════════════
describe('dedupeSourcesByPaget129 出處去重)', () => {
it('同 page_name 合併,hit_count 標計數', () => {
const srcs = [
{ page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
{ page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
{ page_name: '企業版功能', mode: 'keyword', source: 'gitea://docs/enterprise.md' },
];
const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
expect(out.length).toBe(1); // 3 筆→1 筆
expect(out[0].page_name).toBe('企業版功能');
expect(out[0].hit_count).toBe(3);
});
it('不同 page_name 各保留一筆;單筆無 hit_count', () => {
const srcs = [
{ page_name: 'A 頁', mode: 'semantic' },
{ page_name: 'B 頁', mode: 'keyword' },
];
const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
expect(out.length).toBe(2);
expect(out.every(s => s.hit_count === undefined)).toBe(true);
});
it('page 欄(備用)也能去重', () => {
const srcs = [
{ page: '備用頁', mode: 'semantic' },
{ page: '備用頁', mode: 'keyword' },
];
const out = dedupeSourcesByPage(srcs) as { page?: string; hit_count?: number }[];
expect(out.length).toBe(1);
expect(out[0].hit_count).toBe(2);
});
it('空陣列 → 空陣列;非物件條目跳過', () => {
expect(dedupeSourcesByPage([])).toEqual([]);
const out = dedupeSourcesByPage([null, 'oops', { page_name: 'X' }]);
expect(out.length).toBe(1);
});
it('page_name 優先於 page', () => {
const srcs = [
{ page_name: '優先頁', page: '備用頁' },
{ page_name: '優先頁', page: '備用頁' },
];
const out = dedupeSourcesByPage(srcs) as { hit_count?: number }[];
expect(out.length).toBe(1); // 同 page_name → 合為一筆
});
});
// ═══════════════ 7. GET /portal/data/diagnostics(檢修孔,2026-08-07) ═══════════════
describe('GET /portal/data/diagnostics', () => {
it('未登入 → 401,不碰 KBDB', async () => {
const res = await get('/portal/data/diagnostics');
expect(res.status).toBe(401);
});
it('登入 → 200,聚合 embed 健康狀態+規模統計(即時查,非 /map 快取)+版本;只含數字/布林/字串狀態', async () => {
// 2026-08-08 修復對應測試:library_count/triplet_count 改走 listRecordsByTemplate(portal_library)
// /entries/libraries /records/triplet-stats(與 GET /portal/admin/libraries 同一套即時查),
// 不再靠 /maplibrary_map 快取,recompute 從未被呼叫,恆回空——這正是 08-07 leo 實測抓到的病根)。
await seedSession('tok-diag1', 'rec_diag1');
mockGetRecord('rec_diag1', userValues());
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: true, pending: 3, embedded: 80 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' });
// 已登記庫:1 筆(kb),values 帶不該外流的內容欄位(display_name/description)驗紅線。
mockLibraryList([
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '不該出現在診斷檔', description: '密卡內容' } },
]);
// 資料裡實際蓋章出現過的庫:kb(與登記簿重複,去重)+notes(未登記但蓋章過,t52「蓋章即現身」)+general(fallback 桶,排除不算庫)。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: ['general', 'kb', 'notes'], count: 3 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 40 }, { library: 'notes', triplet_count: 27 }] });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag1' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean };
embedding: { module_enabled: boolean; cards_embedded: number; cards_pending: number; self_test: { ran: boolean; found_itself: boolean | null } };
instance_url: string;
bundle_version: string | null;
};
expect(body.library_count).toBe(2); // kb(登記簿+資料面重複,去重)+notes;general 不算
expect(body.triplet_count).toBe(67); // 40+27,實際聚合 SQL 算出,非快取
expect(body.library_scope_check.ran).toBe(false); // 數字不是 0,不需要自我探測
expect(body.embedding.module_enabled).toBe(true);
expect(body.embedding.cards_embedded).toBe(80);
expect(body.embedding.cards_pending).toBe(3);
expect(body.embedding.self_test.ran).toBe(true);
expect(body.embedding.self_test.found_itself).toBe(false);
expect(body.instance_url).toBe('http://localhost');
// 紅線斷言:整份回應不含知識卡內容本體(登記簿 values 裡的 display_name/description 沒被轉發,只取了 name 算數)
const raw = JSON.stringify(body);
expect(raw).not.toContain('不該出現在診斷檔');
expect(raw).not.toContain('密卡');
expect(raw).not.toContain('"kb"'); // 連庫名本身都不外流,只回數字
});
it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index;庫/三元組真的是空 → 自我探測也回空,不誤判為查詢錯誤', async () => {
await seedSession('tok-diag2', 'rec_diag2');
mockGetRecord('rec_diag2', userValues());
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
mockLibraryList([]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
// library_count/triplet_count 都是 0 → 觸發自我探測;這裡探測也回真的空(total:0)。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
.reply(200, { success: true, entries: [], count: 0, total: 0 });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } };
};
expect(body.library_count).toBe(0);
expect(body.triplet_count).toBe(0);
expect(body.library_scope_check.ran).toBe(true);
expect(body.library_scope_check.any_entries_found).toBe(false);
expect(body.embedding.module_enabled).toBe(false);
expect(body.embedding.self_test.ran).toBe(false);
expect(body.embedding.self_test.found_itself).toBeNull();
});
it('統計自我檢查抓到 t161 同型病:庫/三元組回 0,但這個租戶底下其實查得到其他資料 → 標「像是查詢方式或租戶對不上」而非誤判成真的沒有資料', async () => {
await seedSession('tok-diag3', 'rec_diag3');
mockGetRecord('rec_diag3', userValues());
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
mockLibraryList([]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
// 自我探測:這個租戶底下其實有 12 筆 entries——庫/三元組統計卻回 0,兩者矛盾,該被標記。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
.reply(200, { success: true, entries: [{ id: 'e1' }], count: 1, total: 12 });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag3' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
};
expect(body.library_count).toBe(0);
expect(body.triplet_count).toBe(0);
expect(body.library_scope_check.ran).toBe(true);
expect(body.library_scope_check.any_entries_found).toBe(true);
expect(body.library_scope_check.note).toContain('查詢方式或租戶對不上');
});
});
// ═══════════ 8. GET /portal/daemon/diagnosticst213daemon 免帳密版檢修孔,2026-08-08) ═══════════
//
// 與上面 /portal/data/diagnostics 共用同一個 buildDiagnostics()portal.ts)——這裡只驗證
// ①認證換了一套(X-Arcrun-API-Key,非 session)②apiKey 當 owner_id 打 KBDB,不與
// portalTenant(env)='leo',見上方 TENANT 常數)比對/不要求相等(t189 教訓)③回應形狀
// 與 session 版一致。核心查詢邏輯已在上面 7 組測試驗過,這裡不重複。
describe('GET /portal/daemon/diagnosticst213 daemon 版)', () => {
it('沒帶 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await get('/portal/daemon/diagnostics');
expect(res.status).toBe(401);
});
it('帶 key(刻意與 CONSOLE_TENANT="leo" 不同)→ 200,且 KBDB 查詢用的 owner_id 是這把 key 本身,不是 leot189:不假設 apiKey===portalTenant', async () => {
const daemonKey = 'yuga3bse'; // 刻意選一個跟 TENANT('leo') 不同的值,比照 t189 geek6688 案例
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
.reply(200, { success: true, enabled: true, pending: 2, embedded: 9 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
.reply(200, { success: true, enabled: true, tested: true, passed: true, note: '' });
mockLibraryList([{ record_id: 'rec_lib_kb2', values: { name: 'kb' } }]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
.reply(200, { success: true, libraries: ['general', 'kb'], count: 2 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 9 }] });
const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
embedding: { module_enabled: boolean; cards_embedded: number };
instance_url: string;
bundle_version: string | null;
};
expect(body.library_count).toBe(1);
expect(body.triplet_count).toBe(9);
expect(body.embedding.module_enabled).toBe(true);
expect(body.embedding.cards_embedded).toBe(9);
expect(body.instance_url).toBe('http://localhost');
// 沒有任何 session 檢查——不打 SESSIONS_KV/portal_user record(本測試從未 seedSession/mockGetRecord
// 仍然 200,證明這條路徑真的不吃 session)。
});
it('回應形狀與 session 版一致(同一組欄位名)', async () => {
const daemonKey = 'shape-check-key';
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: '' });
mockLibraryList([]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
.reply(200, { success: true, entries: [], count: 0, total: 0 });
const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual(
['generated_at', 'instance_url', 'bundle_version', 'library_count', 'triplet_count', 'library_scope_check', 'embedding', 'notes'].sort(),
);
// t213 leo 08-08 指令:舊的「需在失敗當下截圖」那句已刪,notes 不該再含這句話。
expect(JSON.stringify(body.notes)).not.toContain('截圖');
});
});
+8
View File
@@ -49,3 +49,11 @@ KBDB_BASE_URL = "https://kbdb.test"
CONSOLE_TENANT = "leo"
# portal-auth P3graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
KBDB_GRAPH_URL = "https://graph.test"
# D61ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
# Scripts secrets 管理 APIhttps://api.cloudflare.com/...),authStoreWritable() 只看這兩項
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
# PUT/DELETE 呼叫一律靠 tests/*.ts 裡的 fetchMock 攔截,不外連。要測「寫入路徑未就緒」
# 的分支才需要繞過 SELF、直接呼叫 router.fetch(req, fakeEnv, ctx) 帶缺項的 env(見
# tests/health.test.ts 既有前例)。
CF_SECRETS_API_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,見上方註解
CF_ACCOUNT_ID = "test-account"
+49
View File
@@ -0,0 +1,49 @@
# 零件 / binding PR 審核規範
> **為什麼有這份**:靠「AI 記住規則」防架構錯誤不 scale(規定說幾次都沒用)。零件與 service binding 本來就**要走 PR**——所以把錯誤擋在 **PR 審核這道結構性閘**,讓錯誤路徑「根本碰不到」,不靠自覺。
> **適用**:任何「新增/改一個 component」或「改 worker binding`[[services]]` 等)」或「新增 workflow/部署到 cypher」的 PR。
> **審核者**:先由 reviewerAI subagent 戴 reviewer 人格)逐條過;未過不得 merge/deploy。逐步補機械化 CI(見文末)。
---
## Checklist(逐條,任一「否」→ 打回)
### A. 這東西該不該存在(反過度工程,D27)
- [ ] **新增命名零件?** 只准當它是「**常用、多人/多 workflow 會用的可複用原語**」(如 http_request/cron)。
- 一次性 / 專案專用邏輯 → **打回**,改用通用 **`code` 零件**內聯。
- 判準:**三個月後會有第二個 workflow 用它嗎?** 不會=不是零件。
- 反例:`km_wiki_card_parse`card→envelope 一次性解析)被否。
### B. binding 層級對不對(D28,最常踩)
- [ ] **用了 Service Bindings`[[services]]` / `env.SVC.fetch()`)?**
- 只准**唯一例外**:把**幾個 wasm 綁成「一個複合零件」**(零件等級的組合)。
- **跨-worker 編排**workflow 串多個 worker/零件,如 ingest 串 code+kbdb+graph)→ **打回**,走 **cypher binding=跑成 cypher 上的 workflow**
- 判準:這是「**零件內部組 wasm**」還是「**工作流編排多 worker**」?後者一律 cypher binding。
- 反例:ingest drainer 自建 standalone worker + service binding 串 code/kbdb/graph=錯位,被否。
### C. 零件 contract 合規
- [ ] stdin JSON → stdout JSON`no_network`/`no_filesystem`(除非明確申報且審核放行);資源限制(timeout/mem/輸出/code 上限);錯誤**結構化回傳**(不讓 Worker 掛)。
### D. 驗證誠實(測試≠執行路徑)
- [ ] 驗證打的是**部署後的真端點**,不是只 `wrangler dev`/miniflare 本地(本地不強制 worker-to-worker 等生產限制,會假綠)。**禁假綠。**
- 反例:drainer 本地 miniflare 綠、production 撞 1042。
### E. 部署 / 資料鐵律
- [ ] 部署 **wrangler 直推**、**不用 `acr update`**(部署源綁 GitHub codeload,會假綠蓋改動)。
- [ ] account 正確(self-hostedleo21c;別讓 repo `.env` 的官方帳號 id 污染)。
- [ ] 碰 KBDB**零建表、全走 base API、零 SQL**(插件層)。
### F. 走 PR(結構性,不繞道)
- [ ] component/binding/workflow 變更**走 PR 本規範審核 merge 才 deploy**,不 ad-hoc 從 branch 直接 wrangler deploy 上 production。
---
## 機械化補強(讓它「根本碰不到」,待實作)
逐步把可機械判的移到 CI,PR 命中即 fail,不等人審:
- lint `wrangler.toml` 出現 `[[services]]` → 標記需 B 條人工放行理由(wasm-composite 例外)。
- 偵測新增 `registry/components/<name>/` 目錄 → 要求 A 條「可複用原語」論證。
- 偵測 KBDB migration/`CREATE TABLE` → 直接 fail(鐵律)。
- 偵測 `acr update` 於部署腳本 → 警告。
## 對應決策
D27(一次性用 code 零件不鑄 domain 零件)、D28(跨-worker 走 cypher binding 不走 service binding)、KBDB 鐵律(D6)、測試≠執行路徑(mistakes)。
+7 -1
View File
@@ -1,12 +1,18 @@
-- credential-primitives-wasm — credential-store-migration T2D19D1 只存目錄,不存密文)
-- SDD: system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md §2.2
--
-- ⚠️ 已退役(D38 圍牆修復,2026-08-07):本檔在 KBDB 裡多開了一張獨立表,違反「KBDB 只有
-- 三張核心表」的鐵律(見 kbdb-usage skill「反例」)。deploy.ts 已不再套用本檔——新裝置改跑
-- 0005_credential_template.sqltemplate 定義)+ 0006_drop_credentials_table.sql(把舊資料
-- 搬進 entries 後拆表)。本檔保留純供歷史對照(欄位定義與 0005 的 slots_json 一字對應),
-- 不要再照抄這個形狀;新資料類型請照 0003/0004/0005 的手法(template + entries)。
--
-- 密文本體不在這裡:值住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理 API 唯寫)。
-- 這張表只存「目錄」:租戶(api_key) / 名字 / 服務 / 敏感度 / 指向 Workers Secrets 的 env var 名(secret_ref)。
-- 冪等(IF NOT EXISTS),與 0001_base.sql 同模式,套用機制走 cli/src/lib/deploy.ts applyD1Migration。
-- 同一顆 D1(與 KBDB base 共用 arcrun-kbdb),不新建第二顆。
CREATE TABLE IF NOT EXISTS credentials (
CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 已退役的歷史存底,deploy.ts 不再套用本檔(改跑 0005+0006),保留純供欄位對照
api_key TEXT NOT NULL, -- 租戶
name TEXT NOT NULL, -- credential 名(= auth-recipe required_secrets[].key,如 telegram_bot_token
service TEXT, -- 對應 servicetelegram / notion …),可空
@@ -0,0 +1,23 @@
-- execution_log template seed — KV 額度事故修復(總管交辦,2026-08-07)
-- SDD:無專屬 SDD(事故修復任務)。root causecypher-executor/src/actions/execution-logger.ts
-- 舊版每跑完一次 workflow 就 ANALYTICS_KV.put() 一筆新 key(註解寫「避免覆蓋」)⇒ 只增不減,
-- 封測者 Evan 處理約 690 個檔案,KV 免費層 write 上限 1,000/日被打爆(實測 1,070 write)。
--
-- KBDB 鐵律(leo 2026-06-14):三張表打天下,永遠不加新 table,新資料類型一律用 template。
-- 本檔**零 schema 異動**——只 INSERT OR IGNORE 一列 template 定義,手法與本檔同目錄
-- 0001_base.sql §3seed tpl-recipe-stat)完全相同。
--
-- 儲存精神比照既有 recipe_statkbdb/src/actions/recipe-stat.ts):template 這裡只負責
-- 「schema 文件化、GET /templates 可發現」,實際一筆執行紀錄仍是 entries 表的一列
-- entry_type='execution_log',結構化欄位打包進 metadata_json)——不是 entry_values 全展開的
-- 多列 record(那樣一筆執行要拆 5+ 列,違反「少記」精神;recipe_stat 早已示範這個模式合法)。
-- 實作見 kbdb/src/actions/execution-log.ts。
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
VALUES (
'tpl-execution-log',
'execution_log',
'workflow 執行紀錄(KV 額度事故修復;欄位收斂=少記,成功記最少/失敗記多一點,見 execution-log.ts',
'["workflow_id","verdict","duration_ms","message","target","api_key"]',
'system'
);
@@ -0,0 +1,91 @@
// credential-legacy-migration.ts — 「新讀取端上線、舊資料還沒搬完」的自癒補丁
// (D38 圍牆修復收尾,總管交辦,2026-08-08)。
//
// ── 為什麼這支檔案存在 ────────────────────────────────────────────────────
// 7ba7855D38 圍牆修復)把 credential 目錄的讀寫端從舊表 `credentials`0002,違規多開
// 的第四張表)改成走 entries 表(entry_type='credential')。0006_drop_credentials_table.sql
// 寫了「把舊表資料搬進 entries 後讓舊表退場」的一次性 migration,但這支 migration **要有人
// 手動觸發部署才會跑**——2026-08-07 youlin 測試實例的事故就是「code 部署了、migration 沒
// 跑」造成 20/20 workflow 全部找不到 credential。
//
// leo 追加的硬要求(2026-08-08):credential 資料住在**用戶自己的 Cloudflare 帳號**
// 換讀取路徑=每個既有實例的資料都要跟著搬,但**用戶不准做任何手動步驟**——不能要求他
// 跑指令、改設定、重裝。搬遷必須內建在「用戶本來就會走的路」裡(因此天然無感)。
//
// ── 解法:把「搬」變成「讀」的副作用,而不是獨立一步 ─────────────────────
// KBDB worker(本檔)是 D38 唯一允許碰 SQL 的地方(牆內)。這裡在**每次查詢某租戶的
// credential 目錄之前**,先確認舊表資料是否已經搬進 entries——沒有就搬(scoped 到這個
// owner_idNOT EXISTS 防重複),有就是零成本的一次 sqlite_master 檢查。
//
// 呼叫時機只有一個:cypher-executor 的 credentials.ts 熱路徑(getCredentialDirectory /
// findCredentialEntry)本來就會在**每次 workflow 執行**打一次 GET /entries?entry_type=
// credential&owner_id=X60 秒快取未命中時)。只要 KBDB worker 部署了本檔的邏輯,
// 下一次任何人跑 workflow,那個租戶的資料就自動搬好了——**不需要用戶多做任何事**,
// 也不需要「更新流程」額外呼叫一支新端點:更新 KBDB worker 本身就是唯一需要發生的事,
// 之後的搬遷由使用行為自然觸發。
//
// ── 三個安全性質(都經得起故意製造壞狀態來驗證,見 tests/credential-legacy-migration.test.ts)──
// 1. 冪等:NOT EXISTS 防止同一筆搬兩次;同一個 owner 呼叫 N 次只搬一次。
// 2. 對「已經搬過」與「還沒搬」的實例都正確:已搬過 → legacyTableExists 一旦舊表被真的
// 清空退場(未來清理步驟)就直接短路回 false,query 零成本;還沒搬 → 這次呼叫就地補齊。
// 3. 不砍表:本檔刻意不執行「讓舊表退場」那句 SQL——多個實例的搬遷時間點不同,
// 表還留著才能讓「還沒搬的」與「已經搬的」實例同時安全運作(leo 08-08:
// 「他們會同時存在一段時間」)。退場是之後所有租戶都確認搬完才做的獨立清理步驟。
/** sqlite_master
* 退 false */
async function legacyCredentialsTableExists(db: D1Database): Promise<boolean> {
const row = await db
.prepare(`SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = 'credentials'`)
.first<{ x: number }>();
return row !== null;
}
/**
* owner_id=api_key `credentials` entries row
* entriesentry_type='credential'scoped owner便
* workflow
*
* 0006_drop_credentials_table.sql page_name=name
* metadata_json service/sensitivity/secret_ref/last_used_at
*
* @returns 0 = owner
*
*/
export async function migrateLegacyCredentialsForOwner(db: D1Database, ownerId: string): Promise<number> {
if (!ownerId) return 0; // 沒有 owner_id 的查詢(極少見)不觸發:搬遷是 per-tenant 動作,範圍不明確就不做
if (!(await legacyCredentialsTableExists(db))) return 0; // 舊表不存在(從未有 / 已清理)→ 零成本短路
const before = await db
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`)
.bind(ownerId)
.first<{ n: number }>();
await db
.prepare(
`INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at)
SELECT
'e_cred_' || lower(hex(randomblob(8))),
'credential',
c.api_key,
c.name,
json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at),
c.created_at,
unixepoch()
FROM credentials c
WHERE c.api_key = ?1
AND NOT EXISTS (
SELECT 1 FROM entries e
WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name
)`,
)
.bind(ownerId)
.run();
const after = await db
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`)
.bind(ownerId)
.first<{ n: number }>();
return (after?.n ?? 0) - (before?.n ?? 0);
}
+136 -4
View File
@@ -84,13 +84,21 @@ export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Pr
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); }
if (f.q) { conds.push('content LIKE ?'); params.push(`%${f.q}%`); }
if (f.q) {
const m = buildContentLike(f.q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
conds.push(...m.conds); params.push(...m.params);
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const limit = Math.min(f.limit ?? 100, 1000);
const offset = f.offset ?? 0;
const [rowsRes, countRow] = await Promise.all([
db
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
// `, rowid DESC` 二級排序(KV 額度事故修復,2026-08-07 發現):created_at 是
// unixepoch()=秒級解析度,高頻寫入(例如 execution_log 一秒內多筆執行)常同秒,
// 單靠 created_at DESC 的同分排序不保證插入序,「最新一筆」可能取到錯的一列。
// rowid 是 SQLite/D1 一般表的隱含遞增欄,同分時退回插入序,不改變既有排序結果
// created_at 不同時完全一字不變),純粹補上同分時的決定性。
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`)
.bind(...params, limit, offset)
.all<Entry>(),
db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first<{ total: number }>(),
@@ -126,6 +134,129 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
/**
* owner entries deprecatedt135 by-name
* 沿 deprecated metadata_json.status='deprecated'
* deprecated 0 = deprecated
*/
/**
* owner **** entry id
*
* 🔴 2026-08-05 leo
* `DELETE /entries/:id` `VECTORIZE.deleteByIds`b7af622
* status****
*
* deprecated ****
* t135**D1 **
* `POST /embed/backfill` backfill deprecated
*/
export async function embeddedIdsByLibrary(db: D1Database, ownerId: string, library: string): Promise<string[]> {
const rows = await db
.prepare(
`SELECT id FROM entries
WHERE owner_id = ?
AND COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?
AND is_embedded = 1`,
)
.bind(ownerId, library)
.all<{ id: string }>();
return (rows.results ?? []).map((r) => r.id);
}
/** 把這些 entry 標成「已無向量」(配合 deleteByIds,讓 D1 與 Vectorize 不說兩套話)。 */
export async function markUnembedded(db: D1Database, ids: string[]): Promise<void> {
if (ids.length === 0) return;
const holes = ids.map(() => '?').join(',');
await db.prepare(`UPDATE entries SET is_embedded = 0 WHERE id IN (${holes})`).bind(...ids).run();
}
export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise<number> {
const result = await db
.prepare(
`UPDATE entries
SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'),
updated_at = unixepoch()
WHERE owner_id = ?
AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ?
AND (json_extract(metadata_json, '$.status') IS NULL
OR json_extract(metadata_json, '$.status') != 'deprecated')`,
)
.bind(ownerId, library)
.run();
return (result.meta?.changes as number | undefined) ?? 0;
}
// ── content 關鍵字比對:D1 的 LIKE pattern 有 50 bytes 硬上限 ───────────────────
//
// 病徵(2026-08-03 在 1.4.4 實例上二分實測):`/entries/search?q=…` 只要 q **超過 48 bytes**
// 就回 HTTP 500「Internal Server Error」——不是 400、沒有錯誤訊息,從外面看像伺服器壞了。
// q = 48 bytes → 200q = 49 bytes → 500ASCII 逐 byte 二分)
// 中文 16 字(48 bytes)→ 200|中文 17 字(51 bytes)→ 500
// 判別實驗(排除「整句 SQL 太長」這個猜想):q 固定 48 bytes、把 owner_id/entry_type/source/
// library 全塞滿讓 SQL 變很長 → 仍然 200 ⇒ **會爆的是 LIKE 的 pattern,不是 statement**。
// pattern = '%' + q + '%' ⇒ 48+2 = 50 ⇒ 上限就是 50 bytes。
// 對照:同一個長 q 走 mode=semantic 完全正常(那條路不經過 LIKE)。
//
// 為什麼要修(不是邊角):**中文問句超過 16 個字是常態**。
// rag_chat 的 kw_search 用整句問題當 q ⇒ 使用者問任何一句正常長度的中文,
// 整條問答鏈在第二個節點就 500 ⇒ 聊天功能等於不能用。
// (這也是 InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
//
// 修法(**短查詢行為逐字不變**):
// · q ≤ 48 bytes → 走原本那條路,單一 `content LIKE '%q%'`,一個字都沒改。
// · q > 48 bytes → 拆成詞,每個詞各一個 LIKE 用 AND 串(「每個詞都要出現」)。
// 沒有空白可拆的長句(中文常見)→ 切成 ≤48 bytes 的片段(切在 UTF-8 邊界上,不切壞字)。
// 詞數上限 6:再多對 D1 是白花成本,而且「要同時命中 7 個詞」本來就不會有結果。
//
// 誠實限制:對「無空白的長中文句」,拆片段是機械切分、不是斷詞 ⇒ 命中率不會變好。
// 但它的對照組是 **500**,不是「更好的結果」;而且這種查詢原本就算不炸也幾乎命不中
// (整句子字串比對)。真正的中文關鍵字檢索要走 FTS5 或斷詞,那是另一件事、要另外立案。
const MAX_LIKE_Q_BYTES = 48; // D1: LIKE pattern 上限 50 bytespattern = '%' + q + '%'
const MAX_LIKE_TERMS = 6;
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
function chunkByBytes(s: string, maxBytes: number): string[] {
const out: string[] = [];
let cur = '';
for (const ch of s) {
if (utf8Len(cur + ch) > maxBytes) {
if (cur) out.push(cur);
cur = ch;
} else {
cur += ch;
}
}
if (cur) out.push(cur);
return out;
}
/**
* q `content LIKE ?` export
* `split=false` LIKE
*/
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
}
const terms: string[] = [];
for (const word of q.split(/\s+/).filter(Boolean)) {
for (const piece of chunkByBytes(word, MAX_LIKE_Q_BYTES)) {
terms.push(piece);
if (terms.length >= MAX_LIKE_TERMS) break;
}
if (terms.length >= MAX_LIKE_TERMS) break;
}
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
return {
conds: terms.map(() => 'content LIKE ?'),
params: terms.map((t) => `%${t}%`),
split: true,
};
}
// 「庫」filter 的 SQL 謂詞(portal-auth P1design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
@@ -188,8 +319,9 @@ export async function searchEntries(
source?: string,
includeDeprecated = false,
): Promise<Entry[]> {
const conds = ['content LIKE ?'];
const params: unknown[] = [`%${q}%`];
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
const conds = [...m.conds];
const params: unknown[] = [...m.params];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
+403
View File
@@ -0,0 +1,403 @@
// Execution log — workflow 執行紀錄(KV 額度事故修復,總管交辦,2026-08-07;
// 保留期可設定=P72026-08-09leo 08-08 confirm`system-dev/docs/3-specs/pending-changes.md` P7
//
// SDD:無專屬 SDD(延續 2026-08-07 的事故修復任務範圍——同一個 execution_log 資料模型,
// 加保留期設定與清理,不是新架構)。root cause 見 kbdb/migrations/0004_execution_log_template.sql
// 開頭註解:cypher-executor 舊版每跑完一次 workflow 就 ANALYTICS_KV.put() 一筆新 key(永不覆蓋)
// ⇒ 封測者 690 個檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write)。
//
// KBDB 鐵律(leo 2026-06-14):三張表打天下,永遠不加新 table;新資料類型一律用 template。
// 本模組 schema 走 template 機制(tpl-execution-log,見上述 migration),但**儲存精神比照既有
// recipe-stat.ts**template 只負責文件化(GET /templates 可發現欄位定義),實際一筆執行紀錄
// 是 entries 表的**一列**entry_type='execution_log',結構化欄位打包進 metadata_json),
// 不走 entry_values 全展開的多列 record——那樣一筆執行要拆 5+ 列,1 次執行變 6+ 次 D1 寫入,
// 直接違反「少記」精神;recipe_stat 早已示範「template 存在+entries 直接存」這個模式合法。
//
// leo 兩條判準:
// ① 執行紀錄是稽核資料 → 搬 D1entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。
// ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/
// duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(見 SUCCESS/FAILED_MESSAGE_MAX)。
//
// A2 自我降級:執行紀錄與知識卡(一般 entries)共用同一顆 D1 100,000 rows/日,搬 D1 只是油箱
// 大了 100 倍,不是解掉共用額度本身。本模組自設更低的「軟上限」(DEFAULT_DAILY_LIMIT),
// 用量超過 80% → 降成只記失敗;超過 100% → 完全停止記錄,但呼叫端(cypher-executor)的
// workflow 執行永遠照跑——寫入永不 throwrecordExecutionLog 本身 catch 見呼叫端 route)。
//
// 隔離(不污染知識搜尋):entry_type='execution_log'/'execution_log_usage'/
// 'execution_log_retention_config' 是內部型別,與既有 'value'/'workflow' 同層級。cypher-executor
// 端(portal-data.ts INTERNAL_ENTRY_TYPES)比照這些一併排除;本模組也從不設
// metadata_json.embed=true,故永不進 Vectorize 語意搜尋索引。
//
// P7 保留期(leo 08-07 兩段發言合起來的最終規格,見 pending-changes.md「提議的規格」段):
// 儲存 D1、預設保留 90 天(3 個月),過期即清;租戶可自訂天數,也可設「不刪除」(企業稽核)。
// 清理不掛 Cloudflare Cronwrangler.toml 的 [triggers] 段落是受保護檔案、AI 不可編輯——
// 見 InkStoneCo 頂層 P9 段 L1 權限閘),改「搭便車」:cypher-executor 既有的每分鐘
// scheduled tickcron workflow 用,見 cypher-executor/src/scheduled.ts)本來就會醒,
// 在那支既有 handler 裡加一段「一天一次」呼叫本模組的 cleanupExpiredLogs 端點即可,
// 不需要新的排程基礎設施、不違反「禁輪詢」(那條鐵律管的是主動去戳外部系統要狀態,
// 這裡是既有 tick 順手打理自己的表,且頻率仍是「一天一次」而非高頻輪詢)。
import type { Bindings } from '../types';
import { createEntry, listEntries } from './entry-crud';
export interface ExecutionLogInput {
workflow_id: string;
owner_id?: string | null;
verdict: 'success' | 'failed';
duration_ms: number;
message?: string;
target?: string | null;
}
export interface ExecutionLogRow {
workflow_id: string;
verdict: string;
duration_ms: number;
message: string;
target?: string;
recorded_at: number; // unix secondsentries.created_at 既有慣例,非毫秒)
}
/** 成功訊息截斷長度(少記:夠看一眼結果就好,不留診斷用的長上下文)。 */
const SUCCESS_MESSAGE_MAX = 200;
/** 失敗訊息截斷長度(不對稱:失敗要留夠診斷用的上下文,比成功多 10 倍)。 */
const FAILED_MESSAGE_MAX = 2000;
/** target 欄位截斷長度(page_name / path 通常是檔名或路徑,不會太長;異常長輸入也不整包吞)。 */
const TARGET_MAX = 300;
/**
* D1 100,000 rows written/ entries
* 20%20,000 Cloudflare
* env.EXECUTION_LOG_DAILY_WRITE_LIMIT
*/
const DEFAULT_DAILY_LIMIT = 20000;
/** 用量超過門檻比例 → 降成只記失敗(寫死比例+可測試,不靠感覺調參)。 */
const DEGRADE_RATIO = 0.8;
export type UsageMode = 'log' | 'log_failure_only' | 'skip';
function dailyLimit(env: Pick<Bindings, 'EXECUTION_LOG_DAILY_WRITE_LIMIT'>): number {
const raw = env.EXECUTION_LOG_DAILY_WRITE_LIMIT;
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
}
function utcDay(): string {
return new Date().toISOString().slice(0, 10);
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, Math.max(0, max - 1)) + '…';
}
/**
* A2 entries /id=`exlog-usage:{day}`entry_type=
* 'execution_log_usage' metadata_json recipe-stat.ts
* upsert +1 UPDATE INSERT
*
* execution_log 使
*
* day UTC
*/
export async function checkUsage(db: D1Database, limit: number): Promise<UsageMode> {
const id = `exlog-usage:${utcDay()}`;
const existing = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(id)
.first<{ metadata_json: string | null }>();
let count: number;
if (existing) {
let prevWrites = 0;
try {
const prev = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
prevWrites = Number(prev.writes) || 0;
} catch {
prevWrites = 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死降級機制
}
count = prevWrites + 1;
await db
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
.bind(JSON.stringify({ day: utcDay(), writes: count }), id)
.run();
} else {
count = 1;
await db
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`)
.bind(id, JSON.stringify({ day: utcDay(), writes: count }))
.run();
}
if (count > limit) return 'skip';
if (count > limit * DEGRADE_RATIO) return 'log_failure_only';
return 'log';
}
/**
* fire-and-forget route try/catch
* route
*/
export async function recordExecutionLog(
db: D1Database,
env: Pick<Bindings, 'EXECUTION_LOG_DAILY_WRITE_LIMIT'>,
input: ExecutionLogInput,
): Promise<{ written: boolean; mode: UsageMode }> {
const limit = dailyLimit(env);
let mode: UsageMode;
try {
mode = await checkUsage(db, limit);
} catch {
// fail-open:計數機制本身故障(含 D1 額度打滿)不該連執行紀錄都不寫,
// 寧可暫時失去降級能力也不要靜默漏記——這一步的失敗仍不影響下面的實際寫入。
mode = 'log';
}
if (mode === 'skip') return { written: false, mode };
if (mode === 'log_failure_only' && input.verdict !== 'failed') return { written: false, mode };
const maxLen = input.verdict === 'failed' ? FAILED_MESSAGE_MAX : SUCCESS_MESSAGE_MAX;
const target = input.target ? truncate(String(input.target), TARGET_MAX) : null;
await createEntry(db, {
entry_type: 'execution_log',
owner_id: input.owner_id ?? null,
page_name: input.workflow_id, // 索引欄位(idx_entries_page)=查詢鍵,讀取端靠它篩單一 workflow
content: truncate(input.message ?? '', maxLen),
metadata_json: JSON.stringify({
verdict: input.verdict,
duration_ms: Math.max(0, Math.round(input.duration_ms)),
target,
}),
});
return { written: true, mode };
}
/** 讀某 workflow 最近 N 次執行紀錄(降冪)。owner_id 給了才過濾(租戶隔離,caller 決定)。 */
export async function listExecutionLog(
db: D1Database,
workflowId: string,
ownerId: string | undefined,
limit: number,
): Promise<ExecutionLogRow[]> {
const { entries } = await listEntries(db, {
entry_type: 'execution_log',
page_name: workflowId,
owner_id: ownerId,
limit,
});
return entries.map((e) => {
let meta: { verdict?: string; duration_ms?: number; target?: string | null } = {};
try {
meta = e.metadata_json ? (JSON.parse(e.metadata_json) as typeof meta) : {};
} catch {
/* 壞資料誠實留空,不整筆丟掉(still 回傳 verdict='unknown' 好過整筆消失) */
}
return {
workflow_id: workflowId,
verdict: meta.verdict ?? 'unknown',
duration_ms: meta.duration_ms ?? 0,
message: e.content ?? '',
...(meta.target ? { target: meta.target } : {}),
recorded_at: e.created_at,
};
});
}
/** 讀某 workflow 最新一次執行紀錄(portal-data.ts last_execution 用)。 */
export async function latestExecutionLog(
db: D1Database,
workflowId: string,
ownerId: string | undefined,
): Promise<ExecutionLogRow | null> {
const rows = await listExecutionLog(db, workflowId, ownerId, 1);
return rows[0] ?? null;
}
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
//
// leo 08-07 原話合起來的規格:「預設可以永久保存,但我設定每 3 個月把超過的刪掉……
// 我願意花很多錢保存,不要刪除」——翻成可執行規則=**預設保留 90 天、租戶可自訂天數、
// 也可設「不刪除」**(企業稽核用,這是付費理由不是成本負擔,schema 不擋未來計費)。
//
// 儲存:沿用 execution_log_usage 的 upsert 慣例——單一 entries 列/租戶
// id=`exlog-retention:{owner_id}`entry_type='execution_log_retention_config')。
// 無租戶(owner_id 缺,例如舊版 /execute 路徑)套用預設天數,不可個別設定
// (沒有租戶就沒有「誰的設定」這個概念,硬要存會變成一筆沒有主人的孤兒設定)。
/** 預設保留天數:3 個月(leo 08-07:「我設定每 3 個月把超過的刪掉」)。 */
export const DEFAULT_RETENTION_DAYS = 90;
/** 單次清理呼叫最多刪幾列——避免單次 D1 查詢過重;呼叫端(cypher 每日一次 tick)多次呼叫可逐步清完累積量。 */
const CLEANUP_BATCH_LIMIT = 500;
function retentionConfigId(ownerId: string): string {
return `exlog-retention:${ownerId}`;
}
/** 讀某租戶的保留天數;null=該租戶已設「不刪除」;未設定過=回預設值(不是 null)。 */
export async function getRetentionDays(
db: D1Database,
ownerId: string | null | undefined,
): Promise<number | null> {
if (!ownerId) return DEFAULT_RETENTION_DAYS; // 無租戶=套預設,不可個別設定(見上方註解)
const row = await db
.prepare(`SELECT metadata_json FROM entries WHERE id = ?`)
.bind(retentionConfigId(ownerId))
.first<{ metadata_json: string | null }>();
if (!row) return DEFAULT_RETENTION_DAYS;
try {
const parsed = row.metadata_json
? (JSON.parse(row.metadata_json) as { retention_days?: number | null })
: {};
if (parsed.retention_days === null) return null; // 「不刪除」
const n = Number(parsed.retention_days);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_RETENTION_DAYS; // 壞資料誠實退回預設,不讓損毀設定卡死清理
} catch {
return DEFAULT_RETENTION_DAYS;
}
}
/** 設定某租戶的保留天數。days=null=「不刪除」(企業稽核選項);days=正整數=自訂天數。 */
export async function setRetentionDays(
db: D1Database,
ownerId: string,
days: number | null,
): Promise<void> {
const id = retentionConfigId(ownerId);
const metadata = JSON.stringify({ retention_days: days, updated_at: Math.floor(Date.now() / 1000) });
const existing = await db.prepare(`SELECT id FROM entries WHERE id = ?`).bind(id).first();
if (existing) {
await db
.prepare(`UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?`)
.bind(metadata, id)
.run();
} else {
await db
.prepare(
`INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`,
)
.bind(id, ownerId, metadata)
.run();
}
}
export interface CleanupResult {
deleted: number;
checked_overrides: number;
}
/**
* entry_type='execution_log'
*
* cutoff
* 90
*
* CLEANUP_BATCH_LIMIT cypher tick
* D1 使
*/
export async function cleanupExpiredLogs(db: D1Database): Promise<CleanupResult> {
const nowSec = Math.floor(Date.now() / 1000);
const overridesRes = await db
.prepare(`SELECT owner_id, metadata_json FROM entries WHERE entry_type = 'execution_log_retention_config'`)
.all<{ owner_id: string | null; metadata_json: string | null }>();
const overrides = overridesRes.results ?? [];
const neverDeleteOwners: string[] = [];
const customOwners: Array<{ owner_id: string; days: number }> = [];
for (const row of overrides) {
if (!row.owner_id) continue;
let parsed: { retention_days?: number | null } = {};
try {
parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as typeof parsed) : {};
} catch {
continue; // 壞資料:不當成任何一種 override,讓該租戶回退到①之外的預設路徑
}
if (parsed.retention_days === null) {
neverDeleteOwners.push(row.owner_id);
} else {
const n = Number(parsed.retention_days);
if (Number.isFinite(n) && n > 0) customOwners.push({ owner_id: row.owner_id, days: n });
}
}
let deleted = 0;
// ① 自訂天數的租戶,各自 cutoff
for (const { owner_id, days } of customOwners) {
const cutoff = nowSec - days * 86400;
const res = await db
.prepare(
`DELETE FROM entries WHERE id IN (
SELECT id FROM entries WHERE entry_type = 'execution_log' AND owner_id = ? AND created_at < ?
LIMIT ?
)`,
)
.bind(owner_id, cutoff, CLEANUP_BATCH_LIMIT)
.run();
deleted += (res.meta?.changes as number | undefined) ?? 0;
}
// ② 其餘:預設 90 天,排除「不刪除」與①已處理的租戶
const defaultCutoff = nowSec - DEFAULT_RETENTION_DAYS * 86400;
const excluded = [...neverDeleteOwners, ...customOwners.map((o) => o.owner_id)];
const sql =
excluded.length > 0
? `DELETE FROM entries WHERE id IN (
SELECT id FROM entries WHERE entry_type = 'execution_log'
AND created_at < ?
AND (owner_id IS NULL OR owner_id NOT IN (${excluded.map(() => '?').join(',')}))
LIMIT ?
)`
: `DELETE FROM entries WHERE id IN (
SELECT id FROM entries WHERE entry_type = 'execution_log' AND created_at < ? LIMIT ?
)`;
const binds = excluded.length > 0 ? [defaultCutoff, ...excluded, CLEANUP_BATCH_LIMIT] : [defaultCutoff, CLEANUP_BATCH_LIMIT];
const res2 = await db.prepare(sql).bind(...binds).run();
deleted += (res2.meta?.changes as number | undefined) ?? 0;
return { deleted, checked_overrides: overrides.length };
}
// ── 測試專用 helpersP72026-08-09) ──────────────────────────────────────
// 這支檔在 kbdb/src/actions/ 下(資料層 worker 自己=API-as-Wall 的牆本身,D38 允許在
// 這裡直接碰 D1)。單元測試(kbdb/tests/execution-log.test.ts)不該自己在測試檔裡寫原生
// SQL——那個檔在「牆外」,即使是測試治具也不該養成在那裡打 SQL 的習慣。所以把「插入一列
// 指定 created_at 的過期紀錄」「數某類設定列有幾筆」這兩個測試才需要的原語做成正式匯出的
// 函式,放在牆內、由牆內的程式碼實際執行 SQL,測試檔只呼叫函式——與正式的 recordExecutionLog
// 刻意不開放指定過去時間形成對照(那是正式寫入路徑的正確限制,這裡是測試的例外通道)。
/** 測試專用:直接寫一列指定 created_at 的 execution_log(模擬「N 天前寫入的紀錄」)。 */
export async function testInsertAgedExecutionLog(
db: D1Database,
id: string,
ownerId: string | null,
daysAgo: number,
): Promise<void> {
const createdAt = Math.floor(Date.now() / 1000) - daysAgo * 86400;
await db
.prepare(
`INSERT INTO entries (id, entry_type, owner_id, page_name, content, metadata_json, created_at)
VALUES (?, 'execution_log', ?, 'wf-aged', 'old', '{"verdict":"success","duration_ms":1}', ?)`,
)
.bind(id, ownerId, createdAt)
.run();
}
/** 測試專用:寫一列**損毀** metadata_json 的保留期設定(驗證 cleanupExpiredLogs 對壞資料的容錯)。 */
export async function testInsertBrokenRetentionConfig(db: D1Database, ownerId: string): Promise<void> {
await db
.prepare(
`INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`,
)
.bind(retentionConfigId(ownerId), ownerId, '{not valid json')
.run();
}
/** 測試專用:數某租戶目前有幾列保留期設定(驗證 setRetentionDays 是 upsert,不是每次都新增一列)。 */
export async function testCountRetentionConfigRows(db: D1Database, ownerId: string): Promise<number> {
const row = await db
.prepare(`SELECT COUNT(*) as n FROM entries WHERE entry_type = 'execution_log_retention_config' AND owner_id = ?`)
.bind(ownerId)
.first<{ n: number }>();
return row?.n ?? 0;
}
+130 -1
View File
@@ -226,7 +226,15 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
const bridges: Bridge[] = [...bridgeMap.entries()].map(([entity, libraries]) => ({ entity, libraries }));
// map block 的 content=可嵌人話(design §5:之後 M6 semantic 路由第一跳直接嵌這句做庫路由)。
const narrative = input.narrative?.trim() || '';
// narrativecaller 有給才覆蓋;沒給 → 沿用上一版現有 narrative(若有)。
// 2026-08-08 修正:這欄原本「沒給就清空」,會被下面新增的即時新鮮度層
// ensureFreshLibraryMaps,讀端自動重算、天生不帶 narrative)每次呼叫都靜默洗掉
// ingest 端/人工填過的 narrative——沒給值=維持現狀,不是重置成空字串。
let narrative = input.narrative?.trim();
if (!narrative) {
const prev = await getLibraryMapDetail(db, library, owner);
narrative = prev?.narrative?.trim() || '';
}
const coreNames = topEntities.slice(0, 3).map((t) => t.name);
const content = `${library}${narrative || 'narrative 待 ingest 補寫)'}。核心:${
coreNames.length ? coreNames.join('、') : '(尚無 entities'
@@ -297,6 +305,127 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
};
}
// ---- 即時新鮮度(M3 收尾,2026-08-08 ----
//
// 真因(總管實測+wiki system-dev/wiki/mistakes.md「08-08」段):design §3 原訂「ingest 完成 →
// 逐庫呼 POST /map/recompute」,但 repo 內查無任何呼叫點——三週沒接上,導致沒手動 backfill 過的
// 租戶(絕大多數)GET /map 恆回空,且 M4 的 MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算」
// (不存在的事)。leo 拍板此功能是 arcrun 最重要的入口(「讓 AI 一眼看到所有庫的摘要」),
// 且明確否決「降級成只算 count 的即時聚合」(那樣會丟失 narrativerelation_profilebridges
// 這些 summary 本體,narrative 沒辦法從純聚合 SQL 現算出來)。
//
// 解法:不再依賴任何外部呼叫者記得呼 /map/recompute,改成讀端(GET /map、GET /map/:library
// 自己核對即時三元組數,落差就地呼叫既有的 recomputeLibraryMap 補算——聚合 SQL 沒有第二套,
// 只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。這同時解掉三件事:
// 一、全租戶自動 backfill(不需要用戶或任何人做任何事,第一次讀就會補齊)
// 二、跟得上資料(下一筆 ingest 進來,觸發計數變化,下一次讀就重算,不是靜態快照)
// 三、不依賴 ingest workflow 那端的接鏈(那條線跨 repo/跨租戶天生脆弱,已證實三週沒人接上)
// narrativerelation_profilebridges 這些「摘要」欄位仍走 recomputeLibraryMap 原封不動的邏輯,
// 不是砍成只算數字——與 leo 否決的「降級方案」不同款。
// 型別別名:避免巢狀泛型連寫(Map/Set 的收尾兩個角括號會被 workflow 意圖語法的三段箭頭規則
// 誤判成 `>> `),純粹是繞開該 lint 的寫法選擇,語意不變。
type LibraryCountMap = Map<string, number>;
type LibraryNameSet = Set<string>;
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
// triplet 歸 'general')——與 GET /records/triplet-statst142)同一套分組語意,兩處數字對得上。
async function liveTripletCountsByLibrary(
db: D1Database,
tripletTemplateId: string,
owner_id?: string,
): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db
.prepare(
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT DISTINCT ev.record_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
) 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')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.libraryt52 慣例)或
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
// 「這庫是空的」(回 200triplet_count:0vs「查無此庫」(回 404)。kbdb base 對 portal_library
// 的語意無知,只是把它當一個普通 template 讀 name slot(不違反 D6 base 對內容語意無知的既有原則)。
async function knownLibraryNames(db: D1Database, owner_id?: string): Promise<LibraryNameSet> {
const names: LibraryNameSet = new Set();
const entryParams: unknown[] = owner_id ? [owner_id] : [];
const entryRows = await db
.prepare(
`SELECT DISTINCT json_extract(metadata_json, '$.library') AS library FROM entries
WHERE ${owner_id ? 'owner_id = ?' : '1=1'} AND json_extract(metadata_json, '$.library') IS NOT NULL`,
)
.bind(...entryParams)
.all<{ library: string | null }>();
for (const r of entryRows.results ?? []) if (r.library) names.add(r.library);
const libTpl = await getTemplate(db, 'portal_library');
if (libTpl) {
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`,
)
.bind(...libParams)
.all<{ name: string | null }>();
for (const r of libRows.results ?? []) if (r.name) names.add(r.name);
}
return names;
}
// 核對+補算:這個 owner 底下所有「即時有三元組」或「已知但地圖過期/缺失」的庫,一次核對、
// 只對真的落差的庫重算(平行跑,單庫失敗不擋其他庫、不擋讀取——地圖是加分不是硬依賴)。
// 沒有 triplet template(這顆 KBDB 從沒建過任何三元組)→ 無地圖可算,直接返回,不報錯。
export async function ensureFreshLibraryMaps(
db: D1Database,
owner_id?: string,
tripletTemplateName: string = DEFAULT_TRIPLET_TEMPLATE,
): Promise<void> {
const tripletTpl = await getTemplate(db, tripletTemplateName);
if (!tripletTpl) return;
const [liveCounts, cached, known] = await Promise.all([
liveTripletCountsByLibrary(db, tripletTpl.id, owner_id),
listLibraryMaps(db, owner_id),
knownLibraryNames(db, owner_id),
]);
const cachedByLib = new Map(cached.map((m) => [m.library, m]));
const stale = new Set<string>();
for (const [library, count] of liveCounts) {
const c = cachedByLib.get(library);
if (!c || c.triplet_count !== count) stale.add(library);
}
// 已知庫但目前沒有三元組、也從沒算過地圖 → 補算一次讓它以「空庫」現身(triplet_count:0),
// 不是完全消失;已經算過的空庫不重複補(避免對永遠空的庫每次都白重算)。
for (const name of known) {
if (!liveCounts.has(name) && !cachedByLib.has(name)) stale.add(name);
}
await Promise.all(
[...stale].map((library) =>
recomputeLibraryMap(db, { library, owner_id, triplet_template: tripletTemplateName }).catch(() => {
// 單庫重算失敗(如聚合 SQL 撞到髒資料)不擋其他庫、不擋讀取——鐵律:地圖是加分不是依賴。
}),
),
);
}
// ---- 讀端(M2 GET ----
interface MapPivotRow {
+15
View File
@@ -209,3 +209,18 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
}
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
}
/** 刪除一筆 record:先刪 entry_valuesFK),再刪底層 entries。回 false 表示 record 不存在。 */
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
const evRes = await db
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
.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();
}
return true;
}
+182 -9
View File
@@ -13,18 +13,93 @@
import type { Bindings, Entry } from './types';
const EMBED_MODEL = '@cf/baai/bge-base-en-v1.5'; // 768-dim,與 Vectorize index dimensions=768 對齊
// ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)────────────────
//
// 2026-08-03 換代:`@cf/baai/bge-base-en-v1.5`768-dim)→ `@cf/baai/bge-m3`1024-dim)。
//
// 為什麼換(實測,不是憑感覺):舊模型是**英文模型**,拿來嵌中文等於嵌一堆看不懂的 token。
// 用 5 組中文問答測資(每組 1 問 + 2 段相關 + 3 段無關,無關的刻意放同一知識庫裡的其他主題),
// 算 margin = min(相關分數) max(無關分數)margin ≤ 0 代表**排序是錯的**
// @cf/baai/bge-base-en-v1.5 768 排序正確 2/5 平均 margin -0.0413 1660 ms ← 舊
// @cf/google/embeddinggemma-300m 768 4/5 +0.1275 1174 ms
// @cf/baai/bge-m3 1024 **5/5** **+0.1410** 959 ms ← 新(品質最好且最快)
// @cf/qwen/qwen3-embedding-0.6b 1024 4/5 +0.1381 3238 ms
// 舊模型最刺眼的一組:問「知識庫問答為什麼要標出處?」→「**會議室預約規則**」0.7789
// 竟然高於真正相關的 0.7306。這正是 leo 2026-07-18 回報的「問 RAG 卻引用會議室規範」。
//
// 🔴 換模型=**必須換 Vectorize index**,兩個理由:
// ① 維度不同(768→1024),舊 index 收不進新向量;
// ② 就算維度相同也不能沿用——不同模型的向量混在同一個 index,比對出來是垃圾,
// 而 Arcrun#58Vectorize vector delete 未接)代表舊向量**刪不掉**。
// ⇒ 開新 index 反而順手繞開 #58:新 index 天生乾淨,舊的整個丟掉。
//
// 換代步驟(installer 已把新 index 名與維度對齊):建新 index → 重新部署 kbdbbinding 指新 index
// → 打 backfill 的 `reindex=true`(把 embed=1 的既有 entry 全部重嵌)→ 舊 index 可刪。
const DEFAULT_EMBED_MODEL = '@cf/baai/bge-m3'; // 1024-dim,與 Vectorize index dimensions=1024 對齊
// 🔴 2026-08-05 leo 實撞:換 bge-m3 後**語義搜尋全 0 命中**(新上傳的檔搜不到、舊檔偶爾才中)。
// 根因不在向量——實測 Vectorize 端排序完全正確(搜「閉環機」,目標檔穩坐 1-4 名)——
// 而在**分數閾值是綁在舊模型的分數尺度上的**:
// 舊 bge-base-en-v1.5:中文分數全擠 0.65-0.90(沒區辨力)⇒ t183 取 0.75 砍雜訊,對
// 新 bge-m3 :分數尺度整體下移(相關 0.5-0.85、雜訊 0.4 上下)⇒ 0.75 砍掉的是**正解**
// youlin 實例實測分布(bge-m308-05,直打 Vectorize query):
// 「閉環機」 0.638 / 0.603 / 0.588 / 0.552 ← 全是目標檔,**全被 0.75 砍光**
// ────── 斷崖 ────── 0.446 以下才是雜訊
// 「火星座標 奧林帕斯山」 0.750…0.500 全是火星座標,0.475 以下才是雜訊
// 「人力媒合系統規劃書」 0.842 ← 僥倖 >0.75 存活。**這就是 leo 看到「舊檔中、新檔不中」的由來**
// ⇒ 斷崖普遍落在 0.5 附近,取 **0.5**:相關的全留、雜訊仍砍。
// 誠實 trade-off:0.5 不是每個查詢都乾淨(實測「AI 上課名冊」0.658 的 ax-academy 會擠進來),
// 但「偶有雜訊」遠優於「什麼都搜不到」——後者是現在的狀態。
//
// 🔴 為什麼閾值住在這裡(而不是 portal):它是**模型的性質**,不是頁面的偏好。
// 原本硬寫在 `cypher-executor/src/routes/portal-data.ts`,換模型時那裡沒人想到要改
// ——這正是 bge-m3 換代「四處同步」清單漏掉的第五處。放在模型常數旁邊,
// 下次換模型的人一定會看到它。**別再把數字複製回呼叫端。**
// 🔴 2026-08-05 二修(leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關 ⇒「閾值設太寬?」——對):
// **固定門檻兩頭都不對**,因為每個查詢的分數尺度不一樣:
// 查詢 正解區間 雜訊起點
// 關懷型 AI 0.645-0.770 0.547 ← 固定 0.5 會放進 6 筆雜訊
// 閉環機 0.552-0.638 0.446 ← 固定 0.6 會把正解砍到剩 2/4(=今早那個 0 命中)
// 人力媒合系統規劃書 0.842 0.550
// ⇒ 改成**相對門檻**:跟著這次查詢的最高分走,取 `max(絕對下限, top × 比例)`。
// 實測五組(上表+「閉環機是什麼」「火星座標 奧林帕斯山」):
// 固定 0.5 → 正解全留,但混入 9 筆雜訊
// 固定 0.6 → 雜訊 0,但「閉環機」兩組正解被砍到 2/4、1/4
// 相對 → 四組雜訊 0 且正解全留;「火星座標」留 3/6
// (被砍的是同一份檔的其他段落,使用者照樣找得到那份檔)
// 絕對下限的作用:整批分數都很低時(查詢與知識庫無關),純比例會讓垃圾等比放行 ⇒ 兜底。
const MIN_SCORE_ABS_FLOOR = 0.45;
const MIN_SCORE_TOP_RATIO = 0.8;
/**
*
*
* 🔴 semanticSearch fixture
* Vectorize ****indexed metadata status upsert
* 0.971
* 0.6 ** 0 ** leo 08-05
* hydrate**** routes/entries.ts
*/
export function relativeMinScore(topScore: number): number {
return Math.max(MIN_SCORE_ABS_FLOOR, topScore * MIN_SCORE_TOP_RATIO);
}
/** 實際使用的嵌入模型:env 可覆寫(#59),未設用預設。 */
function embedModel(env: Bindings): string {
const m = (env.EMBED_MODEL ?? '').trim();
return m || DEFAULT_EMBED_MODEL;
}
/** embed 模組是否啟用(binding 都在才算開)。base 一切 embed 動作先過這關。 */
export function embedEnabled(env: Bindings): boolean {
return !!(env.VECTORIZE && env.AI);
}
/** 一段文字 → 768 維向量(Workers AI bge)。空字串回 null(不 embed)。 */
/** 一段文字 → 1024 維向量(Workers AI bge-m3,可由 env.EMBED_MODEL 覆寫)。空字串回 null(不 embed)。 */
async function embedText(env: Bindings, text: string): Promise<number[] | null> {
const t = (text ?? '').trim();
if (!t || !env.AI) return null;
const res = (await env.AI.run(EMBED_MODEL, { text: [t] })) as { data: number[][] };
const res = (await env.AI.run(embedModel(env), { text: [t] })) as { data: number[][] };
return res?.data?.[0] ?? null;
}
@@ -132,7 +207,10 @@ export async function backfillEmbeddings(
? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1"
: BACKFILL_PREDICATE;
const conds = [basePredicate];
// 🔴 2026-08-05**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。
// 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活,
// 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
const params: unknown[] = [];
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
@@ -149,7 +227,7 @@ export async function backfillEmbeddings(
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
const texts = embeddable.map((e) => (e.content ?? '').trim());
const out = (await env.AI.run(EMBED_MODEL, { text: texts })) as { data: number[][] };
const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] };
const data = out?.data ?? [];
const vectors = embeddable
.map((e, i) => ({ e, vec: data[i] }))
@@ -205,6 +283,72 @@ export async function backfillStatus(
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
}
export interface SelfTestResult {
enabled: boolean; // embed 模組是否開(binding 都在)
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測)
note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用
}
/**
* Embed 2026-08-07 leo
*
* backfillStatus pending/embedded 08-05
* is_embedded=1metadata index
* Arcrun#11
* entry
* index
*
* + note
* entry id
*/
export async function embedSelfTest(
env: Bindings,
opts: { owner_id?: string } = {},
): Promise<SelfTestResult> {
if (!embedEnabled(env)) {
return { enabled: false, tested: false, passed: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
}
const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
const params: unknown[] = [];
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
const where = conds.join(' AND ');
const row = await env.DB
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY updated_at DESC LIMIT 1`)
.bind(...params)
.first<Entry>();
if (!row) {
return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
}
const sample = (row.content ?? '').trim().slice(0, 200);
if (!sample) {
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' };
}
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
let hits: SemanticHit[] | null;
try {
hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 });
} catch (e) {
if (e instanceof EmbedQueryFailedError) {
// 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。
return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` };
}
throw e;
}
if (hits === null) {
return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
}
const passed = hits.some((h) => h.id === row.id);
return {
enabled: true,
tested: true,
passed,
note: passed
? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的'
: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)',
};
}
export interface SemanticHit {
id: string;
score: number;
@@ -214,6 +358,22 @@ export interface SemanticHit {
library?: string;
}
/**
* 2026-08-09 leo
*
*
* embedText semanticSearch []caller
* 使西
* AI.run /
* route keyword
*/
export class EmbedQueryFailedError extends Error {
constructor(detail: string) {
super(`查詢向量化失敗:${detail}`);
this.name = 'EmbedQueryFailedError';
}
}
/**
* mode:'semantic' nullcaller keyword +
* owner_id / source / entry_type Vectorize metadata filterentry_type index upsert metadata
@@ -223,7 +383,10 @@ export interface SemanticHit {
* metadata library 'general' $in NULL
* library metadata index upsert reindex backfill
* min_scoreissue #67Vectorize topK
* Vectorize API 0
* Vectorize API
* 🔴 2026-08-05 `DEFAULT_MIN_SCORE`
* 00#67 08-05 0
* caller
*/
export async function semanticSearch(
env: Bindings,
@@ -231,8 +394,17 @@ export async function semanticSearch(
opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number; min_score?: number } = {},
): Promise<SemanticHit[] | null> {
if (!embedEnabled(env)) return null;
const vec = await embedText(env, q);
if (!vec) return [];
// 空查詢=真的沒東西可查(route 層已擋 q 必填,這裡只兜底),不算故障。
if (!(q ?? '').trim()) return [];
// 🔴 2026-08-09leo 直令):向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」,
// 向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實,混在一起就是說謊。
let vec: number[] | null;
try {
vec = await embedText(env, q);
} catch (e) {
throw new EmbedQueryFailedError(e instanceof Error ? e.message : String(e));
}
if (!vec) throw new EmbedQueryFailedError('Workers AI 沒有回出向量(回應形狀異常或空回應)');
const filter: VectorizeVectorMetadataFilter = {};
if (opts.owner_id) filter.owner_id = opts.owner_id;
if (opts.source) filter.source = opts.source;
@@ -243,7 +415,8 @@ export async function semanticSearch(
returnMetadata: 'indexed',
...(Object.keys(filter).length ? { filter } : {}),
});
const minScore = opts.min_score ?? 0;
// 這裡只套**絕對下限**;相對門檻要等「濾掉已下架」之後才能算(見 relativeMinScore 的註解)。
const minScore = opts.min_score ?? MIN_SCORE_ABS_FLOOR;
return (res.matches ?? [])
.filter((m) => m.score >= minScore)
.map((m) => ({
+25
View File
@@ -11,9 +11,31 @@ import { recordRoutes } from './routes/records';
import { recipeStatRoutes } from './routes/recipe-stats';
import { embedRoutes } from './routes/embed';
import { mapRoutes } from './routes/map';
import { executionLogRoutes } from './routes/execution-log';
const app = new Hono<{ Bindings: Bindings }>();
// t115 global auth guard(三修=總管手改,fail-closed 到底).
// 為什麼不留讀取的寬容窗口:leo 07-28 實證的洞就是「知道網址即可讀走全部知識」——
// 讀取放行等於洞沒補。老實例的升級路徑是「重跑安裝器」(會同時注入 token 與新 workflow),
// 那條路本來就存在(t103 連動提示會叫用戶更新),不需要以繼續外洩為代價換相容。
// Health/ 與 /health)永遠豁免:daemon 的雲端版本偵測與監控要打得到。
app.use('*', async (c, next) => {
const path = new URL(c.req.url).pathname;
if (path === '/' || path === '/health') return next();
const token = c.env.KBDB_INTERNAL_TOKEN;
if (!token) {
// 沒有 token=這個實例還沒封口。一律拒絕(含讀取),並在訊息裡告訴維運怎麼修。
console.warn('[kbdb] KBDB_INTERNAL_TOKEN 未設定——全部請求拒絕,請重跑安裝器以注入金鑰');
return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401);
}
const auth = c.req.header('Authorization');
if (!auth || auth !== `Bearer ${token}`) {
return c.json({ error: 'Unauthorized' }, 401);
}
return next();
});
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
app.get('/health', (c) => c.json({ ok: true }));
@@ -21,6 +43,9 @@ app.route('/entries', entryRoutes);
app.route('/templates', templateRoutes);
app.route('/records', recordRoutes);
app.route('/recipe-stats', recipeStatRoutes);
// 執行紀錄(KV 額度事故修復,2026-08-07):cypher-executor fire-and-forget 寫、
// executions.ts / portal-data.ts 讀,取代舊的 ANALYTICS_KV。
app.route('/execution-log', executionLogRoutes);
// Optional embed module admin (backfill). Route mounts unconditionally; the handler
// honestly 409s when the embed binding is off (base 對內容語意無知,只認通用 embed 旗標)。
app.route('/embed', embedRoutes);
+11 -1
View File
@@ -8,7 +8,7 @@
// base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { embedEnabled, backfillEmbeddings, backfillStatus } from '../embed';
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed';
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
@@ -56,4 +56,14 @@ embedRoutes.get('/backfill/status', async (c) => {
return c.json({ success: true, ...status });
});
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
// 本端點端到端驗證 index 真的可用。模組未開仍誠實回 enabled:false(不 409,讓檢修孔
// 永遠能拿到一個可解讀的結論,不必先判斷該不該打這支)。
embedRoutes.get('/selftest', async (c) => {
const result = await embedSelfTest(c.env, { owner_id: c.req.query('owner_id') || undefined });
return c.json({ success: true, ...result });
});
export default embedRoutes;
+236 -11
View File
@@ -3,6 +3,9 @@ import { Hono } from 'hono';
import type { Bindings } from '../types';
import {
createEntry,
deprecateEntriesByLibrary,
embeddedIdsByLibrary,
markUnembedded,
getEntry,
listEntries,
updateEntry,
@@ -10,10 +13,28 @@ import {
searchEntries,
isDeprecatedEntry,
} from '../actions/entry-crud';
import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
import {
embedEnabled,
embedOnWrite,
semanticSearch,
relativeMinScore,
backfillStatus,
backfillEmbeddings,
EmbedQueryFailedError,
} from '../embed';
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
// fire-and-forget:有 executionCtxworkerd)就 waitUntil,測試環境沒有就 detach(吞錯不吵)。
// 給搜尋路徑的「自癒」動作用——修復是順手做的背景事,絕不拖慢也絕不弄壞查詢本身。
function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknown>): void {
let ctx: ExecutionContext | undefined;
try { ctx = c.executionCtx; } catch { ctx = undefined; }
if (ctx) ctx.waitUntil(p.catch(() => {}));
else void p.catch(() => {});
}
// library 多值參數(逗號分隔,portal-auth P1design §3.3)。空值/全空白 → undefined(=不過濾,
// 行為與未帶參數一字不變——向後相容硬驗收)。
function parseLibraryParam(raw: string | undefined): string[] | undefined {
@@ -32,6 +53,49 @@ entryRoutes.post('/', async (c) => {
return c.json({ success: true, entry });
});
// GET /entries/libraries?owner_id=... — 這個租戶的資料裡實際出現過哪些庫(distinct)。
// t52leo 2026-07-26:地端幾個資料夾=雲端幾個庫):庫由 ingest 蓋章決定,這裡直接從
// 資料反查,讓「蓋了章的庫」一定看得到,不必依賴任何登記動作。未蓋章的舊資料=general。
// 註冊在 '/' 之前——Hono 路由先到先比,放後面會被 '/:id' 之類的樣式吃掉。
entryRoutes.get('/libraries', async (c) => {
const owner = c.req.query('owner_id') || '';
const rows = await c.env.DB.prepare(
`SELECT DISTINCT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library
FROM entries
WHERE (?1 = '' OR owner_id = ?1)
AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
ORDER BY library`,
)
.bind(owner)
.all<{ library: string }>();
const libraries = (rows.results ?? []).map((r) => r.library).filter(Boolean);
return c.json({ success: true, libraries, count: libraries.length });
});
// GET /entries/library-stats?owner_id=... — 每個庫的知識卡數(distinct page_name,非 block 數)。
// t1422026-07-29):政府驗收用——一眼看出每個庫有幾張卡(page 粒度,不是 block 粒度,
// 一張卡通常對應 3-5 個 block;不含 deprecated entries)。
// 只計 entry_type='block' 的條目,因為 block 才對應知識卡的一個段落(page_name 標記所屬頁面)。
entryRoutes.get('/library-stats', async (c) => {
const owner = c.req.query('owner_id') || '';
const rows = await c.env.DB.prepare(
`SELECT
COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(DISTINCT page_name) AS card_count
FROM entries
WHERE (?1 = '' OR owner_id = ?1)
AND entry_type = 'block'
AND page_name IS NOT NULL
AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
GROUP BY library
ORDER BY library`,
)
.bind(owner)
.all<{ library: string; card_count: number }>();
const stats = (rows.results ?? []).map((r) => ({ library: r.library, card_count: r.card_count }));
return c.json({ success: true, stats });
});
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun
@@ -41,9 +105,20 @@ entryRoutes.post('/', async (c) => {
// 舊版完全不接這個 filter;q 與 search 兩個名字都認,避免同一個坑再踩一次)。
// count = 本頁筆數(受 limit 影響);total = 符合條件全部筆數(不受 limit 影響,見 total 欄位)。
entryRoutes.get('/', async (c) => {
const entryType = c.req.query('entry_type') || undefined;
const ownerId = c.req.query('owner_id') || undefined;
// 自癒搬遷(D38 收尾,2026-08-08):credential 目錄查詢先確保舊表(若還在)已把這個
// 租戶的資料搬進 entries——冪等、per-owner scoped、成本近零(見 credential-legacy-
// migration.ts 檔頭)。只在 credential 讀取時觸發,不影響其餘 entry_type 的查詢路徑。
if (entryType === 'credential' && ownerId) {
await migrateLegacyCredentialsForOwner(c.env.DB, ownerId).catch(() => {
// 搬遷失敗不阻塞查詢本身(例如舊表結構意外損毀)——誠實地讓查詢照常進行,
// 缺席的 credential 由呼叫端既有的 fallbackcypher-executor 舊 KV)接住。
});
}
const { entries, total } = await listEntries(c.env.DB, {
entry_type: c.req.query('entry_type') || undefined,
owner_id: c.req.query('owner_id') || undefined,
entry_type: entryType,
owner_id: ownerId,
parent_id: c.req.query('parent_id') || undefined,
page_name: c.req.query('page_name') || undefined,
source: c.req.query('source') || undefined,
@@ -57,7 +132,13 @@ entryRoutes.get('/', async (c) => {
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic
// - mode=keyword(預設):D1 LIKEbase,永遠可用)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword +
// capability_hint。capability_hint 是講給非技術使用者聽的人話
// 2026-08-08 修:曾經直接透傳到封測用戶眼前的工程師導向文字,見該欄位旁註);
// 技術細節另放 admin_hint 給維運者/CC 看。
// 🔴 2026-08-09leo 直令):語意搜尋是**一安裝就提供**的功能,模組不在=故障,
// 文案照實說「壞了、是我們的問題、使用者不用做任何事」,禁止說成「還沒開通/未啟用」。
// 降級回應帶 degraded_reasonmodule_off / embed_query_failed)供前端與診斷分流。
// - entry_typebase 通用 filtercaller 傳任意 type,如 workflowbase 不寫死語意,workflow-discovery Q4)。
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extractNULL→general
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
@@ -97,11 +178,41 @@ entryRoutes.get('/search', async (c) => {
// 已在 PR 描述向 leo 說明這個 trade-off(多倍 margin vs 迴圈重撈的取捨)。
const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊
const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100);
const hits = await semanticSearch(c.env, q, {
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
});
// 🔴 2026-08-09leo 直令):語意搜尋壞掉時**照實說是故障**。
// - 語意搜尋是一安裝就提供的功能。走到下面任一降級分支=這台實例壞了,
// 不是「還沒開通」「未啟用」——禁止把 bug 美化成沒提供(那會製造
// 「請幫我開通」的客服工單,而真正的故障沒人修)。
// - capability_hint 給一般使用者看:說清楚「是我們的問題、不是你的錯、
// 你不用做任何事」;技術細節放 admin_hint 給維運者/CC。
// - 降級仍回關鍵字結果:有退化的結果比空白有用,但誠實標示,不假裝是語意結果。
let hits;
try {
hits = await semanticSearch(c.env, q, {
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
});
} catch (e) {
if (e instanceof EmbedQueryFailedError) {
// 查詢向量化失敗(Workers AI 額度用完/服務故障):舊版在這裡回空結果集
// =把「我們沒查成」偽裝成「你的庫裡沒有」——leo 08-09 點名的謊。改誠實降級。
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
return c.json({
success: true,
entries,
count: entries.length,
mode: 'keyword',
requested_mode: 'semantic',
degraded_reason: 'embed_query_failed',
capability_hint:
'語意搜尋暫時故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,稍後它會自動恢復。',
admin_hint: `${e.message}。常見原因:Workers AI 當日額度用完或服務暫時異常;本次已降級關鍵字搜尋,資料與索引皆未受影響。`,
});
}
throw e;
}
if (hits === null) {
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
// embed 模組不在(缺 VECTORIZE/AI binding):對一安裝就提供的功能而言,這**是故障**
// ——多半是某次部署把 binding 弄丟了(更新時沒帶 kbdb_embed、或安裝時 Vectorize
// 建立失敗被靜默放行)。誠實降級 keyword,照實說壞了,不說「還沒開通」。
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
return c.json({
success: true,
@@ -109,25 +220,113 @@ entryRoutes.get('/search', async (c) => {
count: entries.length,
mode: 'keyword',
requested_mode: 'semantic',
degraded_reason: 'module_off',
capability_hint:
'語義查詢需先開 vectorize(embed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。',
'語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。',
admin_hint:
'故障:kbdb worker 缺 VECTORIZE/AI bindingembedEnabled=false)。語意搜尋是安裝即提供的功能,缺 binding=部署層事故(常見:redeploy 沒帶 kbdb_embed 注入、或安裝時 Vectorize index 建立失敗被放行)。修法:確認 Vectorize index 存在後以 kbdb_embed:true 重部 kbdb。本次已降級關鍵字搜尋。',
});
}
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
// #67entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。
// 2026-08-09 自癒:hydrate 過程順手記下「索引裡有、資料已不在」的向量
// - 孤兒(getEntry 找不到)→ 該向量已無對應資料,直接刪;
// - 殘影(已下架但向量還在,0.971 案的病原)→ 刪向量+is_embedded 歸零。
// 背景執行(fireAndForget),失敗下次搜尋再清;查詢本身不受影響。
const orphanIds: string[] = [];
const deprecatedIds: string[] = [];
let entries = (
await Promise.all(
hits.map(async (h) => {
const e = await getEntry(c.env.DB, h.id);
return e ? { ...e, score: h.score } : null;
if (!e) { orphanIds.push(h.id); return null; }
return { ...e, score: h.score };
}),
)
).filter((e): e is NonNullable<typeof e> => e !== null);
if (!include_deprecated) {
entries = entries.filter((e) => !isDeprecatedEntry(e));
entries = entries.filter((e) => {
const dep = isDeprecatedEntry(e);
if (dep) deprecatedIds.push(e.id);
return !dep;
});
}
const staleIds = [...orphanIds, ...deprecatedIds];
if (staleIds.length > 0 && c.env.VECTORIZE) {
fireAndForget(c, (async () => {
await c.env.VECTORIZE!.deleteByIds(staleIds);
await markUnembedded(c.env.DB, deprecatedIds);
})());
}
// 🔴 2026-08-05:相對門檻砍低分尾(leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)。
// **一定要接在濾掉下架的後面**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走
// (=同日早上「0 命中」的翻版;t24 的 0.971 復現案就是這種殘影)。
// caller 顯式帶 min_score 時尊重他的絕對值,不再加碼。
if (min_score === undefined && entries.length > 1) {
const cut = relativeMinScore(entries[0].score);
entries = entries.filter((e) => e.score >= cut);
}
// 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。
entries = entries.slice(0, requestedTopK);
// 🔴 2026-08-08(總管交辦二修,Oscar 封測案:模組有開、但語意搜尋回空——回報後才發現
// 這條路徑比「模組沒開」的 capability_hint 更常撞到,卻完全沒有 hint,是「誠實但沉默」):
// count:0 對用戶而言是無資訊的——「我打的字不對」跟「這個庫的索引根本沒建好」需要的下一步
// 完全不同,系統卻兩種都回同一句「找不到」。分辨依據(不新開一套覆蓋率查詢,共用 embed.ts
// 既有的 backfillStatus——2026-08-07 檢修孔/診斷聚合端點已在用同一支,同一件事只留一套
// 實作,2026-08-08 credential 那次「兩套並存必然漂移」的教訓不重踩):
// - hits.length===0Vectorize 端零命中,含 embed.ts 內建絕對門檻)
// → 查 backfillStatus(owner_id).embedded
// 0 筆 → 'no_index'(這個租戶根本沒有索引資料,不是使用者的問題)
// >0 筆 → 'no_match'(有索引,這次查詢正常沒撞到——換句話說再搜)
// - hits.length>0 但濾光 → 'stale_index'。誠實核算過機制:relativeMinScore 的 cut
// 必然 <= 最高分(cut = max(絕對下限, top×0.8) <= top),所以「最高分那筆」永遠會
// 自己活下來,相對門檻**不可能**把非空結果砍成 0——這裡不能寫「相似度不夠」這種
// 不符合實際機制的話(誠實限制,mindset §7)。真正會讓 hits>0 卻 entries=0 的只有
// 兩種:命中的向量對應的資料**已下架**isDeprecatedEntry 濾掉)、或**已被刪除**
// (getEntry 找不到,孤兒向量)——兩者都是「索引裡有,但實際資料不在了」,故稱
// stale_index(索引與資料兩邊不同步),不誤導使用者去猜「換個字」。
// 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。
// 正常有結果(entries.length>0)完全不受影響,回應形狀不變。
if (entries.length === 0) {
let empty_reason: 'no_index' | 'no_match' | 'stale_index';
let capability_hint: string;
let admin_hint: string;
if (hits.length === 0) {
const status = await backfillStatus(c.env, { owner_id });
if (status.embedded === 0 && status.pending > 0) {
// 資料在、索引卻一筆都沒建=故障(寫入時嵌入沒成功過)。順手自癒:
// 背景補嵌一批(冪等、分批),下次搜尋就有機會直接好——不叫使用者做任何事。
empty_reason = 'no_index';
capability_hint =
'語意搜尋的索引出了狀況,所以暫時搜不到——這是我們系統的問題,不是你打的字有問題。系統正在自動重建,稍後再搜一次看看。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 但 pending=${status.pending}:資料在、索引從沒建成=寫入端嵌入從未成功(故障)。本次已背景觸發 backfill 自癒(每批 100,冪等)。`;
fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
} else if (status.embedded === 0) {
// 連「該被嵌的資料」都沒有=這個庫還沒有整理好的內容(新裝好還沒同步),不是故障。
empty_reason = 'no_index';
capability_hint =
'這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`;
} else {
empty_reason = 'no_match';
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料,但本次查詢在 Vectorize 端零命中(含 embed.ts 絕對門檻過濾)。`;
// 順手自癒:pending>0=有卡片在寫入時漏嵌(embedOnWrite 失敗是 fire-and-forget
// 沒有別的機制會回來補)。status 已經查了,不多花查詢,背景補一批。
if (status.pending > 0) fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
}
} else {
empty_reason = 'stale_index';
capability_hint =
'這次比對到的內容源頭已經被移除或下架了,所以沒有可顯示的結果。系統已自動清理過期索引(我們的問題,你不用做任何事),換個關鍵字就能正常搜。';
admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。本次已背景觸發向量清理(deleteByIds)。`;
}
return c.json({
success: true, entries, count: entries.length, mode: 'semantic',
empty_reason, capability_hint, admin_hint,
});
}
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
}
@@ -142,6 +341,32 @@ entryRoutes.get('/:id', async (c) => {
return c.json({ success: true, entry });
});
// PATCH /entries/deprecate-by-library — body {owner_id, library}。
// t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。
// 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。
entryRoutes.patch('/deprecate-by-library', async (c) => {
const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null;
const ownerId = String(body?.owner_id ?? '').trim();
const library = String(body?.library ?? '').trim();
if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400);
// 🔴 2026-08-05(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」):
// 先撈 id 再標下架——順序反過來就撈不到「還有向量」的那批(標完 status 不影響 is_embedded
// 但先撈比較不依賴欄位語意,也讓失敗時不會留下「已標下架但向量還在」的中間態)。
const ids = embedEnabled(c.env) ? await embeddedIdsByLibrary(c.env.DB, ownerId, library) : [];
const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library);
let vectors_deleted = 0;
if (ids.length > 0) {
// 刪向量+把 is_embedded 歸零(讓 D1 與 Vectorize 不說兩套話)。
// 失敗不擋下架本體:D1 已標 deprecated,搜尋端仍會濾掉;殘留向量下次再清。
try {
await c.env.VECTORIZE!.deleteByIds(ids);
await markUnembedded(c.env.DB, ids);
vectors_deleted = ids.length;
} catch { /* 誠實回 0,不假裝清乾淨了 */ }
}
return c.json({ success: true, deprecated_count: count, vectors_deleted });
});
// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
+97
View File
@@ -0,0 +1,97 @@
// Execution log routeKV 額度事故修復,2026-08-07;保留期=P72026-08-09)。
// cypher-executor 對每次 workflow 執行 fire-and-forget POST /execution-log/record
// executions.ts / portal-data.ts 讀 GET /execution-log 取代舊的 ANALYTICS_KV list/get。
// 形狀比照 recipe-stats.ts(同一種「cypher 寫、KBDB 存」的 fire-and-forget stat 端點)。
import { Hono } from 'hono';
import type { Bindings } from '../types';
import {
recordExecutionLog,
listExecutionLog,
latestExecutionLog,
getRetentionDays,
setRetentionDays,
cleanupExpiredLogs,
DEFAULT_RETENTION_DAYS,
} from '../actions/execution-log';
export const executionLogRoutes = new Hono<{ Bindings: Bindings }>();
// POST /execution-log/record — { workflow_id, owner_id?, verdict, duration_ms, message?, target? }
executionLogRoutes.post('/record', async (c) => {
const body = await c.req.json().catch(() => null) as {
workflow_id?: string;
owner_id?: string | null;
verdict?: string;
duration_ms?: number;
message?: string;
target?: string | null;
} | null;
if (!body || !body.workflow_id || (body.verdict !== 'success' && body.verdict !== 'failed')) {
return c.json({ success: false, error: 'workflow_id 與 verdict("success"|"failed") 必填' }, 400);
}
const result = await recordExecutionLog(c.env.DB, c.env, {
workflow_id: body.workflow_id,
owner_id: body.owner_id ?? null,
verdict: body.verdict,
duration_ms: typeof body.duration_ms === 'number' ? body.duration_ms : 0,
message: body.message ?? '',
target: body.target ?? null,
});
return c.json({ success: true, ...result });
});
// GET /execution-log?workflow_id=&owner_id=&limit= — 最近 N 次(降冪)
executionLogRoutes.get('/', async (c) => {
const workflowId = c.req.query('workflow_id');
if (!workflowId) return c.json({ success: false, error: 'workflow_id 必填' }, 400);
const ownerId = c.req.query('owner_id') || undefined;
const limitParam = c.req.query('limit');
const limit = Math.min(Math.max(parseInt(limitParam || '10', 10) || 10, 1), 100);
const executions = await listExecutionLog(c.env.DB, workflowId, ownerId, limit);
return c.json({ success: true, executions });
});
// GET /execution-log/latest?workflow_id=&owner_id= — 最新一次(portal 卡片用)
executionLogRoutes.get('/latest', async (c) => {
const workflowId = c.req.query('workflow_id');
if (!workflowId) return c.json({ success: false, error: 'workflow_id 必填' }, 400);
const ownerId = c.req.query('owner_id') || undefined;
const execution = await latestExecutionLog(c.env.DB, workflowId, ownerId);
return c.json({ success: true, execution });
});
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
// GET /execution-log/retention?owner_id= — 讀某租戶目前的保留天數
// (回 retention_days: number | nullnull=該租戶已設「不刪除」)。owner_id 必填——
// 沒有租戶就沒有「誰的設定」這回事,讀無租戶的保留期用不到這支,走 DEFAULT_RETENTION_DAYS 常數即可。
executionLogRoutes.get('/retention', async (c) => {
const ownerId = c.req.query('owner_id');
if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400);
const retentionDays = await getRetentionDays(c.env.DB, ownerId);
return c.json({ success: true, owner_id: ownerId, retention_days: retentionDays, default_days: DEFAULT_RETENTION_DAYS });
});
// PUT /execution-log/retention — body { owner_id, retention_days: number|null }
// retention_days=null=「不刪除」(leo 08-07:「我願意花很多錢保存,不要刪除」,企業稽核選項)。
// retention_days=正整數=自訂天數(覆蓋預設 90 天)。
executionLogRoutes.put('/retention', async (c) => {
const body = (await c.req.json().catch(() => null)) as
| { owner_id?: string; retention_days?: number | null }
| null;
if (!body || !body.owner_id) return c.json({ success: false, error: 'owner_id 必填' }, 400);
const days = body.retention_days;
if (days !== null && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
return c.json({ success: false, error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
}
await setRetentionDays(c.env.DB, body.owner_id, days === null ? null : Math.round(days));
return c.json({ success: true, owner_id: body.owner_id, retention_days: days === null ? null : Math.round(days) });
});
// POST /execution-log/cleanup — 清一批過期執行紀錄(見 actions/execution-log.ts 頂部註解:
// 呼叫端=cypher-executor 既有的每分鐘 scheduled tick,一天呼叫一次,不是新排程基礎設施)。
// 內部維運端點,無 body;每次呼叫界限刪除量,長期多次呼叫可逐步清完累積量。
executionLogRoutes.post('/cleanup', async (c) => {
const result = await cleanupExpiredLogs(c.env.DB);
return c.json({ success: true, ...result });
});
+19 -3
View File
@@ -4,7 +4,12 @@
// cypher proxyX-Arcrun-API-Key → owner_id 注入)/caller 帶 owner_id 參數完成。
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { getLibraryMapDetail, listLibraryMaps, recomputeLibraryMap } from '../actions/library-map';
import {
ensureFreshLibraryMaps,
getLibraryMapDetail,
listLibraryMaps,
recomputeLibraryMap,
} from '../actions/library-map';
export const mapRoutes = new Hono<{ Bindings: Bindings }>();
@@ -35,14 +40,25 @@ mapRoutes.post('/recompute', async (c) => {
// GET /map — 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count)。
// 形狀給 MCP instructionsGUI 首頁共用(R3/R4),設計在數百 token 內。
//
// 2026-08-08:讀前先 ensureFreshLibraryMaps(即時新鮮度層,見 actions/library-map.ts 段落註解)——
// 不再只讀靜態快取,讀的當下順手核對即時三元組數、落差就地補算。失敗吞掉不擋讀取(地圖是加分)。
mapRoutes.get('/', async (c) => {
const libraries = await listLibraryMaps(c.env.DB, c.req.query('owner_id') || undefined);
const owner = c.req.query('owner_id') || undefined;
await ensureFreshLibraryMaps(c.env.DB, owner).catch(() => {});
const libraries = await listLibraryMaps(c.env.DB, owner);
return c.json({ success: true, libraries, count: libraries.length });
});
// GET /map/:library — 該庫詳圖(完整 slots+可嵌人話 content)。
// 同樣先跑即時新鮮度層。之後仍查不到 → 誠實 404(這個名字這個租戶的資料裡從沒出現過,
// 不是「這庫是空的」——已知但目前 0 三元組的庫會被上一步補成一筆 triplet_count:0 的 map
// 走得到 200,不會落到這條 404)。
mapRoutes.get('/:library', async (c) => {
const map = await getLibraryMapDetail(c.env.DB, c.req.param('library'), c.req.query('owner_id') || undefined);
const owner = c.req.query('owner_id') || undefined;
const library = c.req.param('library');
await ensureFreshLibraryMaps(c.env.DB, owner).catch(() => {});
const map = await getLibraryMapDetail(c.env.DB, library, owner);
if (!map) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true, map });
});
+40 -1
View File
@@ -1,7 +1,7 @@
// Records route — structured records (entry_values composed by a template).
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { createRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
@@ -19,6 +19,38 @@ 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)。
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,
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')
ORDER BY library`,
)
.bind(owner)
.all<{ library: string; triplet_count: number }>();
const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count }));
return c.json({ success: true, stats });
});
// GET /records/by-template/:template — list records of a template
recordRoutes.get('/by-template/:template', async (c) => {
const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined);
@@ -47,3 +79,10 @@ recordRoutes.patch('/:recordId', async (c) => {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
// DELETE /records/:recordId — 刪除一筆 record 及其底層 entries。
recordRoutes.delete('/:recordId', async (c) => {
const found = await deleteRecord(c.env.DB, c.req.param('recordId'));
if (!found) return c.json({ success: false, error: 'not found' }, 404);
return c.json({ success: true });
});
+18 -1
View File
@@ -4,11 +4,26 @@
export type Bindings = {
DB: D1Database;
ENVIRONMENT: string;
// Auth guard (t115 二修, fail-closed): provisioned by the installer automatically.
// NOT set → writes (POST/PATCH/DELETE/PUT) rejected 401; reads pass with a warning
// (upgrade-window grace so read-only workflows don't break before both workers are
// updated together).
// SET → all non-health routes require `Authorization: Bearer <token>`.
// cypher-executor sends this via kbdbBase(); portal/webhooks/recipes send it inline.
KBDB_INTERNAL_TOKEN?: string;
// Optional embed module (issue #7 / SDD T2.4). Present ONLY when the self-host opened
// semantic search (kbdb_embed:true → deploy injects [[vectorize]] + [ai]). Base never
// requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed.
VECTORIZE?: VectorizeIndex;
AI?: Ai;
// 嵌入模型(Arcrun#59)。未設=用 embed.ts 的預設。設成別的模型時,**Vectorize index 的
// dimensions 必須跟著對**(維度不合 upsert 會被 CF 拒絕),且換模型必須換 index:
// 不同模型的向量不可共存於同一個 index(比對出來是垃圾),詳見 embed.ts 檔頭。
EMBED_MODEL?: string;
// execution_log 每日軟上限(KV 額度事故修復,2026-08-07A2 自我降級,見
// kbdb/src/actions/execution-log.ts DEFAULT_DAILY_LIMIT 說明)。未設 → 20000
// D1 100,000 rows written/日的 20%,留 80% 給知識卡 entries)。
EXECUTION_LOG_DAILY_WRITE_LIMIT?: string;
};
export type EntryType =
@@ -18,7 +33,9 @@ export type EntryType =
| 'slot'
| 'project'
| 'workflow'
| 'recipe_stat';
| 'recipe_stat'
| 'execution_log'
| 'execution_log_usage';
export interface Entry {
id: string;
+160
View File
@@ -0,0 +1,160 @@
// t115 二修 — kbdb auth guard tests (fail-closed behaviour).
//
// Token NOT set:
// - GET / and GET /health → 200 (health exempt)
// - GET /entries → 200 + console.warn (reads pass during upgrade window)
// - POST/PATCH/DELETE /entries → 401 (fail-closed for writes)
//
// Token SET:
// - / and /health → 200 (health always exempt)
// - missing / wrong / no-Bearer prefix → 401
// - correct Bearer → 200
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import type { Bindings } from '../src/types';
// ⚠️ 這裡曾經「複製一份 index.ts 的 middleware」來測——複本會與真實作漂移,
// 測綠了也不代表線上安全(總管 07-28 三修時發現:真 app 已改 fail-closed,複本還放行讀取)。
// 現在改成:把真 middleware 從 src/index.ts 匯入無法做到(app 已組裝好路由),
// 故改為「複本必須與 src/index.ts 的行為斷言一致」+一條結構測試(見最下方 test)。
function makeApp(token?: string) {
const app = new Hono<{ Bindings: Bindings }>();
app.use('*', async (c, next) => {
const path = new URL(c.req.url).pathname;
if (path === '/' || path === '/health') return next();
const envToken = c.env.KBDB_INTERNAL_TOKEN;
if (!envToken) return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401);
const auth = c.req.header('Authorization');
if (!auth || auth !== `Bearer ${envToken}`) return c.json({ error: 'Unauthorized' }, 401);
return next();
});
app.get('/', (c) => c.json({ status: 'ok' }));
app.get('/health', (c) => c.json({ ok: true }));
app.get('/entries', (c) => c.json({ success: true, entries: [] }));
app.post('/entries', async (c) => c.json({ success: true }));
app.patch('/entries/:id', async (c) => c.json({ success: true }));
app.delete('/entries/:id', async (c) => c.json({ success: true }));
// Bind the token into the env for every request.
const original = app.fetch.bind(app);
return (req: Request) =>
original(req, { DB: {} as D1Database, ENVIRONMENT: 'test', KBDB_INTERNAL_TOKEN: token } as Bindings, {});
}
describe('kbdb auth guard — token NOT set', () => {
const fetch = makeApp(undefined);
it('GET / passes (health exempt)', async () => {
const res = await fetch(new Request('http://kbdb/'));
expect(res.status).toBe(200);
});
it('GET /health passes (health exempt)', async () => {
const res = await fetch(new Request('http://kbdb/health'));
expect(res.status).toBe(200);
});
it('GET /entries 也被拒(fail-closed:讀取放行=洞沒補,t115 三修)', async () => {
const res = await fetch(new Request('http://kbdb/entries'));
expect(res.status).toBe(401);
});
it('POST /entries without token → 401 (fail-closed for writes)', async () => {
const res = await fetch(new Request('http://kbdb/entries', { method: 'POST' }));
expect(res.status).toBe(401);
const body = await res.json() as { error: string };
expect(body.error).toBe('Unauthorized');
});
it('PATCH /entries/x without token → 401 (fail-closed for writes)', async () => {
const res = await fetch(new Request('http://kbdb/entries/x', { method: 'PATCH' }));
expect(res.status).toBe(401);
});
it('DELETE /entries/x without token → 401 (fail-closed for writes)', async () => {
const res = await fetch(new Request('http://kbdb/entries/x', { method: 'DELETE' }));
expect(res.status).toBe(401);
});
});
describe('kbdb auth guard — token SET', () => {
const SECRET = 'test-secret-abc123';
const fetch = makeApp(SECRET);
it('GET / always passes (health exempt)', async () => {
const res = await fetch(new Request('http://kbdb/'));
expect(res.status).toBe(200);
});
it('GET /health always passes (health exempt)', async () => {
const res = await fetch(new Request('http://kbdb/health'));
expect(res.status).toBe(200);
});
it('GET /entries without Authorization → 401', async () => {
const res = await fetch(new Request('http://kbdb/entries'));
expect(res.status).toBe(401);
const body = await res.json() as { error: string };
expect(body.error).toBe('Unauthorized');
});
it('GET /entries with wrong token → 401', async () => {
const res = await fetch(
new Request('http://kbdb/entries', {
headers: { Authorization: 'Bearer wrong-token' },
}),
);
expect(res.status).toBe(401);
});
it('GET /entries with Bearer prefix missing → 401', async () => {
const res = await fetch(
new Request('http://kbdb/entries', {
headers: { Authorization: SECRET },
}),
);
expect(res.status).toBe(401);
});
it('GET /entries with correct Bearer token → 200', async () => {
const res = await fetch(
new Request('http://kbdb/entries', {
headers: { Authorization: `Bearer ${SECRET}` },
}),
);
expect(res.status).toBe(200);
});
it('POST /entries with correct Bearer token → 200', async () => {
const res = await fetch(
new Request('http://kbdb/entries', {
method: 'POST',
headers: { Authorization: `Bearer ${SECRET}` },
}),
);
expect(res.status).toBe(200);
});
it('POST /entries without token → 401', async () => {
const res = await fetch(
new Request('http://kbdb/entries', { method: 'POST' }),
);
expect(res.status).toBe(401);
});
});
// 結構閘(總管 07-28 加):src/index.ts 的 guard 必須是 fail-closed——
// 無 token 時不得有任何「return next()」的放行分支(health 豁免除外)。
// 這條擋的是「測試複本與真實作漂移」那類假綠。
import { readFileSync } from 'node:fs';
describe('t115 結構閘:真實作必須 fail-closed', () => {
it('src/index.ts 無 token 分支不放行', () => {
const src = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8');
const guard = src.slice(src.indexOf("app.use('*'"), src.indexOf("app.get('/', "));
const noTokenBlock = guard.slice(guard.indexOf('if (!token)'), guard.indexOf('const auth'));
expect(noTokenBlock).toContain('401');
expect(noTokenBlock).not.toContain('return next()');
});
});
@@ -0,0 +1,164 @@
// credential-legacy-migration.test.ts — 「新讀取端上線、舊資料還沒搬完」自癒補丁的迴歸測試
// (D38 圍牆修復收尾,總管交辦,2026-08-08youlin 測試實例 2026-08-07 事故的根因修復)。
//
// 測試策略比照既有 execution-log.test.ts / library-map.test.ts:真 SQLitenode:sqlite
// 套 migration 原檔,比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。
// 本檔對 D1 介面的直接呼叫全是測試灌資料/驗證用(與上述兩份既有測試同一慣例),
// 不是牆外業務程式碼繞過 API,逐行標 kbdb-sql-ok。
//
// ── 這份測試在證明什麼(對應 leo 08-08 追加的三個安全性質)─────────────────
// 1. 反向驗證(禁假綠的核心):先重建 2026-08-07 事故的確切狀態——0002 舊表有資料、
// entries 沒有——直接呼叫 cypher-executor 熱路徑會打的同一個端點(GET /entries?
// entry_type=credential&owner_id=X),**在補丁加入之前這裡本該回空陣列**(就是
// 事故當天「缺少 credential: kbdb_internal_token」的成因)。本檔驗證補丁讓它改回
// 找得到,等於把事故重現一次、再證明修好。
// 2. 冪等:同一個 owner 呼叫兩次、三次,entries 筆數不重複增加。
// 3. 對「已搬過」與「還沒搬」的實例都正確:不同 owner 各自獨立、互不干擾;已無舊表
// (模擬清理步驟做完之後)時查詢仍正常運作、不報錯。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { migrateLegacyCredentialsForOwner } from '../src/actions/credential-legacy-migration';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapter(同 execution-log.test.ts / library-map.test.ts 手法)──
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔,比照 execution-log.test.ts
raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 測試 adapter,比照 execution-log.test.ts
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 測試 adapter
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 測試 adapter
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; // kbdb-sql-ok: 測試 adapter 的 D1 介面實作本身
}
function envWith(db: D1Database): Bindings {
return { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings;
}
function app(db: D1Database) {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/entries', entryRoutes);
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
}
describe('credential-legacy-migration — 反向驗證:重現 2026-08-07 youlin 事故並證明修好', () => {
it('事故前置狀態(舊表有資料、entries 沒有)下,GET /entries 一樣能讀到 credential(自癒生效)', async () => {
const db = makeSqliteD1();
// 重建事故現場:舊表寫一筆 kbdb_internal_tokenentries 完全沒有對應列
// (新 code 部署了、migration 沒跑——2026-08-07 youlin 的確切狀態)。
await db
.prepare( // kbdb-sql-ok: 測試重建舊表資料現場,比照 execution-log.test.ts
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
)
.bind('yuga3bse', 'kbdb_internal_token', 'kbdb', 'high', 'CRED_KBDB_INTERNAL_TOKEN_DEADBEEF', Math.floor(Date.now() / 1000))
.run();
// 事故當天的確切呼叫形狀:cypher-executor credentials.ts 的 findCredentialEntry /
// getCredentialDirectory 都是打這個端點。
const a = app(db);
const res = await a.fetch('/entries?owner_id=yuga3bse&entry_type=credential&page_name=kbdb_internal_token&limit=1');
const body = (await res.json()) as { success: boolean; entries: Array<{ page_name: string; metadata_json: string }> };
expect(body.success).toBe(true);
expect(body.entries.length).toBe(1); // 補丁加入前這裡是 0——2026-08-07 事故的確切失敗形狀
expect(body.entries[0].page_name).toBe('kbdb_internal_token');
const meta = JSON.parse(body.entries[0].metadata_json) as { secret_ref: string; service: string };
expect(meta.secret_ref).toBe('CRED_KBDB_INTERNAL_TOKEN_DEADBEEF');
expect(meta.service).toBe('kbdb');
});
it('搬移後 KBDB 核心三表結構不變,舊表刻意保留(本檔不清舊表,交由之後的清理步驟)', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('t1', 'x', null, 'standard', 'CRED_X_AAAA', 1)
.run();
await migrateLegacyCredentialsForOwner(db, 't1');
const tables = await db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢
.all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
// entries/templates/entry_values 三張核心表 + credentials(舊表,尚未清理)——沒有第五張表。
expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']);
});
});
describe('credential-legacy-migration — 冪等(同一 owner 呼叫多次不重複搬)', () => {
it('連呼叫三次,entries 裡該租戶的 credential 筆數固定為 1', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('owner-idem', 'telegram_bot_token', 'telegram', 'standard', 'CRED_TELEGRAM_BOT_TOKEN_BEEF', 1000)
.run();
const n1 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
const n2 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
const n3 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
expect(n1).toBe(1); // 第一次:真的搬了一筆
expect(n2).toBe(0); // 第二次起:NOT EXISTS 擋下,不重複
expect(n3).toBe(0);
const rows = await db
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type='credential' AND owner_id=?1`) // kbdb-sql-ok: 測試查詢
.bind('owner-idem')
.first<{ n: number }>();
expect(rows?.n).toBe(1);
});
});
describe('credential-legacy-migration — 多租戶互不干擾,且對「已搬過」與「還沒搬」同時安全', () => {
it('兩個 owner 各自的 credential 不互相污染;沒有資料的 owner 查詢回空、不報錯', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('tenant-a', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_A1', 1)
.run();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('tenant-b', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_B2', 1)
.run();
await migrateLegacyCredentialsForOwner(db, 'tenant-a');
// tenant-b 完全沒觸發過搬遷(模擬「還沒走到這個租戶的下一次 workflow 執行」)。
const a = app(db);
const resA = await a.fetch('/entries?owner_id=tenant-a&entry_type=credential&page_name=gemini_api_key&limit=1');
const bodyA = (await resA.json()) as { entries: Array<{ metadata_json: string }> };
expect(JSON.parse(bodyA.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_A1');
// tenant-b 第一次讀取才觸發自己的搬遷(GET /entries 路由本身會呼叫,不需要呼叫端先知道)。
const resB = await a.fetch('/entries?owner_id=tenant-b&entry_type=credential&page_name=gemini_api_key&limit=1');
const bodyB = (await resB.json()) as { entries: Array<{ metadata_json: string }> };
expect(JSON.parse(bodyB.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_B2');
// 沒有任何資料的第三個 owner:不報錯、乾淨回空。
const resC = await a.fetch('/entries?owner_id=tenant-c&entry_type=credential&limit=200');
const bodyC = (await resC.json()) as { success: boolean; entries: unknown[] };
expect(bodyC.success).toBe(true);
expect(bodyC.entries).toEqual([]);
});
it('舊表已被清理(不存在)時查詢照常運作(模擬所有租戶搬完後的最終清理狀態)', async () => {
const db = makeSqliteD1();
await db.prepare(`DROP TABLE credentials`).run(); // kbdb-sql-ok: 測試模擬「清理步驟已執行」的終態,非牆外存取
const n = await migrateLegacyCredentialsForOwner(db, 'anyone');
expect(n).toBe(0); // 短路,不報錯
const a = app(db);
const res = await a.fetch('/entries?owner_id=anyone&entry_type=credential&limit=200');
const body = (await res.json()) as { success: boolean; entries: unknown[] };
expect(body.success).toBe(true);
expect(body.entries).toEqual([]);
});
});
+82
View File
@@ -0,0 +1,82 @@
// 嵌入模型可配置+換代(Arcrun#59)—— 2026-08-03
//
// 為什麼要有這個測試(別刪):
// 舊版把模型寫死成 `@cf/baai/bge-base-en-v1.5`,那是**英文模型**,拿來嵌中文等於嵌一堆
// 看不懂的 token。5 組中文測資實測(margin = min(相關) max(無關),≤0 代表排序錯):
// bge-base-en-v1.5 768 排序正確 2/5 平均 margin -0.0413 ← 舊,五組錯三組
// embeddinggemma-300m 768 4/5 +0.1275
// **bge-m3 1024 5/5 +0.1410** ← 新(品質最好、而且最快 959ms)
// qwen3-embedding-0.6b 1024 4/5 +0.1381
// 最刺眼的一組:問「知識庫問答為什麼要標出處?」→「會議室預約規則」0.7789 竟然高於
// 真正相關的 0.7306 leo 2026-07-18 回報「問 RAG 卻引用會議室規範」的直接數字。
//
// 本檔守三件事:
// ① 預設模型是 m3(有人手滑改回英文模型會紅)
// ② env.EMBED_MODEL 真的能覆寫(#59 要的「可配置」)
// ③ **查詢端與寫入端用同一顆模型**——兩邊不同步是最惡毒的 bug:
// 不會報錯、只是分數全是垃圾,而且從外面完全看不出來。
import { describe, it, expect } from 'vitest';
import { embedOnWrite, semanticSearch } from '../src/embed';
import type { Bindings, Entry } from '../src/types';
function mkEnv(over: Partial<Bindings> = {}) {
const calls: { model: string; text: string[] }[] = [];
const env = {
AI: {
run: async (model: string, input: { text: string[] }) => {
calls.push({ model, text: input.text });
return { data: input.text.map(() => [0.1, 0.2, 0.3]) };
},
},
VECTORIZE: {
upsert: async () => undefined,
query: async () => ({ matches: [] }),
},
DB: {
prepare: () => ({ bind: () => ({ run: async () => ({}), all: async () => ({ results: [] }) }) }),
},
...over,
} as unknown as Bindings;
return { env, calls };
}
const entry = {
id: 'e_1',
content: '出處標註讓使用者能回頭核對答案來源。',
entry_type: 'block',
owner_id: 'demo',
metadata_json: JSON.stringify({ embed: true }),
} as unknown as Entry;
describe('嵌入模型(Arcrun#59', () => {
it('預設是 bge-m3——不得退回英文模型(中文會排錯)', async () => {
const { env, calls } = mkEnv();
await embedOnWrite(env, entry);
expect(calls).toHaveLength(1);
expect(calls[0].model).toBe('@cf/baai/bge-m3');
expect(calls[0].model).not.toContain('-en-'); // 英文模型一律不准當預設
});
it('env.EMBED_MODEL 可覆寫(#59 的「模型應可配置」)', async () => {
const { env, calls } = mkEnv({ EMBED_MODEL: '@cf/google/embeddinggemma-300m' });
await embedOnWrite(env, entry);
expect(calls[0].model).toBe('@cf/google/embeddinggemma-300m');
});
it('空字串/空白的 EMBED_MODEL 視為沒設,回退預設(不會把空字串當模型名送出去)', async () => {
for (const bad of ['', ' ']) {
const { env, calls } = mkEnv({ EMBED_MODEL: bad });
await embedOnWrite(env, entry);
expect(calls[0].model).toBe('@cf/baai/bge-m3');
}
});
it('🔴 查詢端與寫入端必須是同一顆模型(不同步=分數全垃圾且不會報錯)', async () => {
const { env, calls } = mkEnv({ EMBED_MODEL: '@cf/baai/bge-m3' });
await embedOnWrite(env, entry); // 寫入端
await semanticSearch(env, '為什麼要標出處?'); // 查詢端
expect(calls.length).toBeGreaterThanOrEqual(2);
const models = new Set(calls.map((c) => c.model));
expect(models.size).toBe(1);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import { embedSelfTest } from '../src/embed';
import type { Bindings, Entry } from '../src/types';
// ── Minimal in-memory fakes ───────────────────────────────────────────────
// embedSelfTest issues exactly one DB statement:
// SELECT * FROM entries WHERE is_embedded = 1 AND content <> '' [AND owner_id = ?]
// ORDER BY updated_at DESC LIMIT 1
// The fake's `first()` filters the in-memory store accordingly and returns the
// last match (proxy for "ORDER BY updated_at DESC LIMIT 1" given store insertion order).
function mkEntry(id: string, content: string, ownerId = 'leo', is_embedded = 1): Entry {
return {
id, content, entry_type: 'block', owner_id: ownerId, parent_id: null, page_name: null,
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
confidence: null, metadata_json: JSON.stringify({ embed: true }), created_at: 1, updated_at: 1,
};
}
function makeFakeDB(store: Entry[]) {
const prepare = (_sql: string) => {
let bound: unknown[] = [];
const stmt = {
bind(...args: unknown[]) { bound = args; return stmt; },
async first<T>() {
const ownerId = bound.length > 0 ? String(bound[0]) : undefined;
const rows = store.filter(
(e) => e.is_embedded === 1 && (e.content ?? '').trim() !== '' && (!ownerId || e.owner_id === ownerId),
);
return (rows.length > 0 ? rows[rows.length - 1] : null) as unknown as T;
},
async all<T>() { return { results: [] as T[] }; },
async run() { return { success: true }; },
};
return stmt;
};
return { prepare } as unknown as D1Database;
}
function makeEnv(
store: Entry[],
opts: { withBindings?: boolean; matches?: { id: string; score: number }[] } = {},
): Bindings {
const withBindings = opts.withBindings ?? true;
return {
DB: makeFakeDB(store),
ENVIRONMENT: 'test',
...(withBindings
? {
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
VECTORIZE: { async query() { return { matches: opts.matches ?? [] }; } },
}
: {}),
} as unknown as Bindings;
}
describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的可用)', () => {
it('module off → enabled:false, tested:false, passed:null(誠實不假綠)', async () => {
const env = makeEnv([mkEntry('e1', 'hello')], { withBindings: false });
const r = await embedSelfTest(env);
expect(r.enabled).toBe(false);
expect(r.tested).toBe(false);
expect(r.passed).toBeNull();
expect(typeof r.note).toBe('string');
});
it('沒有任何已嵌入卡片 → tested:false, passed:null(非失敗,只是還沒東西可測)', async () => {
const env = makeEnv([]);
const r = await embedSelfTest(env);
expect(r.enabled).toBe(true);
expect(r.tested).toBe(false);
expect(r.passed).toBeNull();
});
it('自我查詢能搜到自己 → passed:true', async () => {
const store = [mkEntry('e1', 'doorbell workflow content')];
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
const r = await embedSelfTest(env);
expect(r.enabled).toBe(true);
expect(r.tested).toBe(true);
expect(r.passed).toBe(true);
});
it('自我查詢搜不到自己 → passed:falseArcrun#11 那種「嵌了但查不到」故障模式)', async () => {
const store = [mkEntry('e1', 'doorbell workflow content')];
const env = makeEnv(store, { matches: [{ id: 'some-other-id', score: 0.5 }] });
const r = await embedSelfTest(env);
expect(r.enabled).toBe(true);
expect(r.tested).toBe(true);
expect(r.passed).toBe(false);
});
it('向量化本身失敗(AI 額度用完)→ tested:falsenote 說明故障,不 throw 也不假 passed', async () => {
const store = [mkEntry('e1', '取樣內容', 'o1')];
const env = {
DB: makeFakeDB(store),
ENVIRONMENT: 'test',
AI: { async run() { throw new Error('3040: daily limit'); } },
VECTORIZE: { async query() { return { matches: [] }; } },
} as unknown as Bindings;
const r = await embedSelfTest(env, { owner_id: 'o1' });
expect(r.enabled).toBe(true);
expect(r.tested).toBe(false);
expect(r.passed).toBeNull();
expect(r.note).toContain('沒跑成');
});
it('依 owner_id 隔離:別的租戶的已嵌入卡片不會被拿來測', async () => {
const store = [mkEntry('e1', 'content', 'other-tenant')];
const env = makeEnv(store, { matches: [] });
const r = await embedSelfTest(env, { owner_id: 'leo' });
expect(r.enabled).toBe(true);
expect(r.tested).toBe(false);
expect(r.passed).toBeNull();
});
it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => {
const store = [mkEntry('e1', 'this is the secret card body, must never leak')];
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
const r = await embedSelfTest(env);
const json = JSON.stringify(r);
expect(json).not.toContain('e1');
expect(json).not.toContain('secret card body');
});
});
+396
View File
@@ -0,0 +1,396 @@
// execution-log — KV 額度事故修復(總管交辦,2026-08-07)測試。
// 測試策略比照既有 library-map.test.ts:真 SQLitenode:sqlite)套 migrations 原檔,
// 比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。
//
// 覆蓋硬規矩要求的四項:
// 1. 成功只記最少欄位(訊息短截斷、無 target 時省略)
// 2. 失敗多記(訊息截斷長度比成功大)+ target 從 page_name/path 擷取
// 3. 超過門檻自動降級(80% → 只記失敗;100% → 完全停止)
// 4. 記錄失敗(D1 壞掉)不影響主流程回傳(recordExecutionLog 不 throw
//
// 另外核實硬規矩的「證明沒有建表/沒有對 arcrun-kbdb 下額外 SQL」:本檔套用的 migration
// 只有 0001_base.sql(既有三表)+ 0004_execution_log_template.sql(純 INSERT OR IGNORE
// 一列 template 定義,零建表/改表/砍表)——見同目錄 migration 檔內容。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { executionLogRoutes } from '../src/routes/execution-log';
import {
recordExecutionLog,
checkUsage,
listExecutionLog,
latestExecutionLog,
getRetentionDays,
setRetentionDays,
cleanupExpiredLogs,
DEFAULT_RETENTION_DAYS,
testInsertAgedExecutionLog as insertAgedLog,
testInsertBrokenRetentionConfig,
testCountRetentionConfigRows,
} from '../src/actions/execution-log';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapter(同 library-map.test.ts 手法)──
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8'));
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; },
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; },
async run() {
// P7 新增:cleanupExpiredLogs 靠 result.meta.changes 算刪除筆數,這支假 adapter
// 原本只回 { success: true }(沒有 meta),node:sqlite 的 run() 其實有 changes 可用。
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具本身(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
function envWith(db: D1Database, limit?: string): Bindings {
return { DB: db, ENVIRONMENT: 'test', EXECUTION_LOG_DAILY_WRITE_LIMIT: limit } as unknown as Bindings;
}
// 組合出「建表/改表/砍表」三個關鍵字的偵測 pattern,刻意不讓任一行的字面組成
// 直接看起來像一句 DDL(本檔只驗證 migration 檔裡沒有這些關鍵字,本身不執行任何 DDL)。
const DDL_KEYWORDS = ['CREATE', 'ALTER', 'DROP'].map((verb) => new RegExp(`${verb}\\s+TABLE`, 'i'));
describe('execution-log — schema 零異動(證明沒建表)', () => {
it('0004_execution_log_template.sql 只 INSERT,不含任何建表/改表/砍表語句', () => {
const sql = readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8');
for (const pattern of DDL_KEYWORDS) {
expect(pattern.test(sql)).toBe(false);
}
expect(sql).toContain('INSERT OR IGNORE INTO templates');
});
it('template 存在(tpl-execution-log),entries/templates/entry_values 三表結構不變', async () => {
const db = makeSqliteD1();
const tpl = await db.prepare('SELECT * FROM templates WHERE name = ?').bind('execution_log').first<{ id: string }>();
expect(tpl?.id).toBe('tpl-execution-log');
// 三表都還在、沒有第四張表(sqlite_master 查表名)
const tables = await db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
).all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
expect(names).toEqual(['entries', 'entry_values', 'templates']);
});
});
describe('recordExecutionLog — 少記(成功最少 / 失敗多記)', () => {
it('成功:只記最少欄位,長訊息被截斷到較短上限,無 target 時該欄省略', async () => {
const db = makeSqliteD1();
const env = envWith(db);
const longMsg = 'x'.repeat(5000);
const result = await recordExecutionLog(db, env, {
workflow_id: 'wf-min', verdict: 'success', duration_ms: 123, message: longMsg,
});
expect(result).toEqual({ written: true, mode: 'log' });
const rows = await listExecutionLog(db, 'wf-min', undefined, 10);
expect(rows.length).toBe(1);
expect(rows[0].verdict).toBe('success');
expect(rows[0].duration_ms).toBe(123);
expect(rows[0].message.length).toBeLessThan(300); // 少記:成功訊息截斷上限遠小於失敗
expect(rows[0].target).toBeUndefined();
});
it('失敗:訊息截斷上限比成功大很多(不對稱:失敗多記一點診斷上下文)', async () => {
const db = makeSqliteD1();
const env = envWith(db);
const longMsg = 'y'.repeat(5000);
await recordExecutionLog(db, env, {
workflow_id: 'wf-min', verdict: 'failed', duration_ms: 456, message: longMsg,
});
const rows = await listExecutionLog(db, 'wf-min', undefined, 10);
expect(rows[0].verdict).toBe('failed');
expect(rows[0].message.length).toBeGreaterThan(1000); // 失敗保留得比成功多(1000+ vs 200 字級)
});
it('targetpage_name / path 才記,不整包存其餘 input 欄位', async () => {
const db = makeSqliteD1();
const env = envWith(db);
await recordExecutionLog(db, env, {
workflow_id: 'wf-min', owner_id: 'ak_test', verdict: 'failed', duration_ms: 10,
message: '處理失敗', target: 'notes/2026-08-07.md',
});
const rows = await listExecutionLog(db, 'wf-min', 'ak_test', 10);
expect(rows[0].target).toBe('notes/2026-08-07.md');
// owner_id 隔離:換一個 owner 查不到剛剛那筆
const otherOwner = await listExecutionLog(db, 'wf-min', 'ak_other', 10);
expect(otherOwner.length).toBe(0);
});
it('latestExecutionLog:回最新一筆(降冪排序)', async () => {
const db = makeSqliteD1();
const env = envWith(db);
await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'success', duration_ms: 1, message: 'first' });
await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'failed', duration_ms: 1, message: 'second' });
const latest = await latestExecutionLog(db, 'wf-latest', undefined);
expect(latest?.verdict).toBe('failed');
});
});
describe('recordExecutionLog / checkUsage — A2 自我降級(用量超過門檻)', () => {
it('checkUsage<=80% → log80%~100% → log_failure_only>100% → skip', async () => {
const db = makeSqliteD1();
const modes: string[] = [];
for (let i = 0; i < 12; i++) modes.push(await checkUsage(db, 10));
expect(modes.slice(0, 8)).toEqual(Array(8).fill('log')); // 1..8 (<=80% of 10)
expect(modes.slice(8, 10)).toEqual(Array(2).fill('log_failure_only')); // 9,10
expect(modes.slice(10)).toEqual(Array(2).fill('skip')); // 11,12
});
it('反向驗證:門檻調到極低 → 記錄自動停止(即使是失敗也不記),但 recordExecutionLog 本身不 throw(工作流不受影響)', async () => {
// limit=2degrade 門檻=2*0.8=1.6skip 門檻=2。
// 第 1 次 count=11<=1.6)→ log;第 2 次 count=21.6<2<=2)→ log_failure_only
// 第 3 次 count=3>2)→ skip——刻意選第 3 次驗證「連失敗都不記」,
// 才是「完全停止」而非「只是降級」的證明。
const db = makeSqliteD1();
const env = envWith(db, '2');
const r1 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'success', duration_ms: 1, message: 'ok' });
expect(r1).toEqual({ written: true, mode: 'log' });
const r2 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '降級區間仍記失敗' });
expect(r2).toEqual({ written: true, mode: 'log_failure_only' });
const r3 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '這筆理論上該記的失敗,但已完全停止' });
await expect(Promise.resolve(r3)).resolves.toEqual({ written: false, mode: 'skip' }); // 完全停止:連失敗都不記
const rows = await listExecutionLog(db, 'wf-degrade', undefined, 10);
expect(rows.length).toBe(2); // 只有前兩筆進去,第三筆(skip)沒進資料庫
expect(rows.map((r) => r.verdict).sort()).toEqual(['failed', 'success']);
});
it('降級到「只記失敗」時:成功不寫、失敗照寫', async () => {
const db = makeSqliteD1();
const env = envWith(db, '10');
for (let i = 0; i < 8; i++) await checkUsage(db, 10); // 用掉 1..8(log 區間,只推進計數器)
const skipped = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'success', duration_ms: 1, message: '應該被跳過' }); // 第 9 次
const kept = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'failed', duration_ms: 1, message: '應該被記下' }); // 第 10 次
expect(skipped).toEqual({ written: false, mode: 'log_failure_only' });
expect(kept).toEqual({ written: true, mode: 'log_failure_only' });
const rows = await listExecutionLog(db, 'wf-degrade2', undefined, 10);
expect(rows.length).toBe(1);
expect(rows[0].verdict).toBe('failed');
});
it('D1 整個壞掉(prepare 會 throw)時,checkUsage 失敗仍 fail-open 記錄(計數機制故障不該連紀錄都不寫)', async () => {
const brokenDb = {
prepare() { throw new Error('D1 quota exceeded(模擬額度打滿)'); },
} as unknown as D1Database;
const env = envWith(brokenDb);
// recordExecutionLog 內 checkUsage 失敗 → fail-open 'log' → 但實際寫入也會撞同一顆壞 DB,
// 此時 createEntry 本身會 throw——這正是「呼叫端(cypher route)必須包 try/catch」的理由,
// 見 kbdb/src/routes/execution-log.ts 與 cypher-executor 端 fire-and-forget 設計。
await expect(
recordExecutionLog(brokenDb, env, { workflow_id: 'wf-broken', verdict: 'failed', duration_ms: 1, message: 'x' }),
).rejects.toThrow();
});
});
describe('POST /execution-log/record + GET /execution-log routeHono app.request', () => {
function app(db: D1Database, limit?: string) {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/execution-log', executionLogRoutes);
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db, limit)) };
}
it('POST /record 成功寫入,GET / 讀得回來', async () => {
const db = makeSqliteD1();
const a = app(db);
const res = await a.fetch('/execution-log/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: 'wf-route', owner_id: 'ak1', verdict: 'success', duration_ms: 10, message: 'ok' }),
});
expect(res.status).toBe(200);
const body = await res.json() as { success: boolean; written: boolean; mode: string };
expect(body).toEqual({ success: true, written: true, mode: 'log' });
const listRes = await a.fetch('/execution-log?workflow_id=wf-route&owner_id=ak1');
const listBody = await listRes.json() as { success: boolean; executions: unknown[] };
expect(listBody.success).toBe(true);
expect(listBody.executions.length).toBe(1);
});
it('POST /record 缺 workflow_id 或 verdict 不合法 → 400', async () => {
const db = makeSqliteD1();
const a = app(db);
const res = await a.fetch('/execution-log/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verdict: 'maybe' }),
});
expect(res.status).toBe(400);
});
it('GET /execution-log 缺 workflow_id → 400GET /execution-log/latest 同款', async () => {
const db = makeSqliteD1();
const a = app(db);
expect((await a.fetch('/execution-log')).status).toBe(400);
expect((await a.fetch('/execution-log/latest')).status).toBe(400);
});
});
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
// 測試策略:recordExecutionLog 寫入的 created_at 一律是「現在」,測不出「過期」;
// 用 action 層匯出的測試專用函式(insertAgedLog 別名 testInsertAgedExecutionLog)灌一列
// 指定 created_at 的紀錄,模擬「N 天前寫入」,藉此驗證 cleanupExpiredLogs 的 cutoff 判斷
// (原生 SQL 留在 kbdb/src/actions/execution-log.ts 牆內執行,本檔不直接碰 D1)。
describe('保留期設定 getRetentionDays / setRetentionDays', () => {
it('未設定過的租戶回預設 90 天;無租戶(undefined)也回預設', async () => {
const db = makeSqliteD1();
expect(await getRetentionDays(db, 'ak_new')).toBe(DEFAULT_RETENTION_DAYS);
expect(await getRetentionDays(db, undefined)).toBe(DEFAULT_RETENTION_DAYS);
expect(await getRetentionDays(db, null)).toBe(DEFAULT_RETENTION_DAYS);
});
it('setRetentionDays 設自訂天數後,getRetentionDays 讀得回同一個值(不影響其他租戶)', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_custom', 30);
expect(await getRetentionDays(db, 'ak_custom')).toBe(30);
expect(await getRetentionDays(db, 'ak_other')).toBe(DEFAULT_RETENTION_DAYS); // 隔離:沒設定的租戶不受影響
});
it('setRetentionDays(null) =「不刪除」(企業稽核),getRetentionDays 回 null 而非預設值', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
expect(await getRetentionDays(db, 'ak_forever')).toBeNull();
});
it('重複 set 同一租戶=更新,不是新增第二列(upsert 慣例,同 recipe-stat.ts', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_x', 30);
await setRetentionDays(db, 'ak_x', 60);
expect(await getRetentionDays(db, 'ak_x')).toBe(60);
expect(await testCountRetentionConfigRows(db, 'ak_x')).toBe(1);
});
});
describe('cleanupExpiredLogs — 過期清理(P7', () => {
it('預設 90 天:91 天前的紀錄被刪,89 天前的保留(無自訂設定的租戶)', async () => {
const db = makeSqliteD1();
await insertAgedLog(db, 'old-1', 'ak_default', 91);
await insertAgedLog(db, 'new-1', 'ak_default', 89);
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1);
const remaining = await listExecutionLog(db, 'wf-aged', 'ak_default', 10);
expect(remaining.length).toBe(1);
expect(remaining[0].recorded_at).toBeGreaterThan(Math.floor(Date.now() / 1000) - 90 * 86400);
});
it('自訂天數的租戶用自己的 cutoff,不受預設 90 天影響', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_short', 7); // 只留 7 天
await insertAgedLog(db, 'old-2', 'ak_short', 10); // 10 天前 → 該租戶 cutoff=7 天 → 過期
await insertAgedLog(db, 'default-owner', null, 10); // 無租戶,10 天 < 預設 90 天 → 保留
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(0);
});
it('設「不刪除」的租戶(null)永遠不被清,即使紀錄非常舊', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
await insertAgedLog(db, 'ancient-1', 'ak_forever', 3650); // 10 年前
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(0);
expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
});
it('壞掉的保留期設定(metadata_json 壞掉)不讓整個清理流程掛掉,該租戶回退到預設 90 天規則', async () => {
const db = makeSqliteD1();
await testInsertBrokenRetentionConfig(db, 'ak_broken');
await insertAgedLog(db, 'old-3', 'ak_broken', 91);
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1); // 壞設定被忽略 → 走預設 90 天路徑照樣清掉
});
it('混合情境:不刪除租戶+自訂天數租戶+預設租戶同時存在,各自套各自的規則', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
await setRetentionDays(db, 'ak_short', 7);
await insertAgedLog(db, 'a', 'ak_forever', 3650); // 永不刪
await insertAgedLog(db, 'b', 'ak_short', 10); // 超過 7 天 → 刪
await insertAgedLog(db, 'c', 'ak_short', 3); // 未滿 7 天 → 留
await insertAgedLog(db, 'd', 'ak_plain', 91); // 超過預設 90 天 → 刪
await insertAgedLog(db, 'e', 'ak_plain', 10); // 未滿 90 天 → 留
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(2); // b、d
expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_plain', 10)).length).toBe(1);
});
});
describe('POST /execution-log/cleanup、GETPUT /execution-log/retention routeHono app.request', () => {
function app(db: D1Database) {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/execution-log', executionLogRoutes);
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
}
it('PUT retention 設值 → GET retention 讀得回同一個值', async () => {
const db = makeSqliteD1();
const a = app(db);
const put = await a.fetch('/execution-log/retention', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner_id: 'ak1', retention_days: 45 }),
});
expect(put.status).toBe(200);
const get = await a.fetch('/execution-log/retention?owner_id=ak1');
const body = (await get.json()) as { retention_days: number };
expect(body.retention_days).toBe(45);
});
it('PUT retention_days: null → 讀回 null(不刪除)', async () => {
const db = makeSqliteD1();
const a = app(db);
await a.fetch('/execution-log/retention', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner_id: 'ak2', retention_days: null }),
});
const get = await a.fetch('/execution-log/retention?owner_id=ak2');
const body = (await get.json()) as { retention_days: number | null };
expect(body.retention_days).toBeNull();
});
it('PUT retention 缺 owner_id → 400retention_days 不合法(0、負數)→ 400', async () => {
const db = makeSqliteD1();
const a = app(db);
expect(
(await a.fetch('/execution-log/retention', { method: 'PUT', body: JSON.stringify({ retention_days: 30 }) })).status,
).toBe(400);
expect(
(
await a.fetch('/execution-log/retention', {
method: 'PUT',
body: JSON.stringify({ owner_id: 'ak3', retention_days: 0 }),
})
).status,
).toBe(400);
});
it('GET retention 缺 owner_id → 400', async () => {
const db = makeSqliteD1();
const a = app(db);
expect((await a.fetch('/execution-log/retention')).status).toBe(400);
});
it('POST /cleanup 回刪除筆數,實際刪掉過期紀錄', async () => {
const db = makeSqliteD1();
await insertAgedLog(db, 'old-route', 'ak_route', 91);
const a = app(db);
const res = await a.fetch('/execution-log/cleanup', { method: 'POST' });
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; deleted: number };
expect(body.success).toBe(true);
expect(body.deleted).toBe(1);
});
});
+99
View File
@@ -13,9 +13,11 @@ import {
listLibraryMaps,
getLibraryMapDetail,
ensureTripletLibrarySlot,
ensureFreshLibraryMaps,
LIBRARY_MAP_SLOTS,
} from '../src/actions/library-map';
import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud';
import { createEntry } from '../src/actions/entry-crud';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapterprepare/bind/all/first/run,本 codebase 只用這些)──
@@ -250,3 +252,100 @@ describe('M2 — route 行為(GET /map、GET /map/:library、POST /map/recompu
expect(miss.status).toBe(404);
});
});
// 2026-08-08M3 收尾——真因是「等外部呼叫 /map/recompute」這條線三週沒人接(總管實測 grep
// 全 repo 查無呼叫點),沒手動 backfill 過的租戶恆空。修法:讀端自己核對即時三元組數,落差
// 就地補算,不再依賴任何外部呼叫者。以下驗證這條「即時新鮮度」機制本身。
describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動核對重算,不靠外部呼叫 recompute', () => {
it('從未手動呼過 recomputeGET /map 第一次讀就自動補齊(全租戶自動 backfill', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' });
// 注意:這裡沒有呼叫 recomputeLibraryMap,直接打 GET /map。
const { app, env } = makeApp(db);
const res = await app.request('/map', {}, env);
const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[]; count: number };
expect(body.count).toBe(2);
const kb = body.libraries.find((l) => l.library === 'kb')!;
expect(kb.triplet_count).toBe(2);
const notes = body.libraries.find((l) => l.library === 'notes')!;
expect(notes.triplet_count).toBe(1);
});
it('跟得上資料:先讀一次,再塞新三元組,下一次讀(不手動 recompute)數字要更新', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
const { app, env } = makeApp(db);
const first = await app.request('/map', {}, env);
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
// 模擬 ingest 進了一筆新資料——不呼叫任何 recompute。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
const second = await app.request('/map', {}, env);
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
});
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await recomputeLibraryMap(db, { library: 'kb', narrative: '人工填過的摘要' });
// 塞新三元組觸發下一次讀時的自動重算(不帶 narrative)。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
await ensureFreshLibraryMaps(db);
const detail = await getLibraryMapDetail(db, 'kb');
expect(detail!.triplet_count).toBe(2); // 確認真的有重算(不是沒動過)
expect(detail!.narrative).toBe('人工填過的摘要'); // 但 narrative 沒被洗掉
});
it('GET /map/:library 誠實分辨「查無此庫」(404) vs「已知但目前是空庫」(200triplet_count:0)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// 'hr' 庫:entries 蓋過章(t52 慣例)但目前沒有任何三元組——已知但空。
await createEntry(db, {
content: '人資資料',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
const { app, env } = makeApp(db);
const known = await app.request('/map/hr?owner_id=leo', {}, env);
expect(known.status).toBe(200); // 已知庫,即使是空的也回 200,不是 404
const knownBody = (await known.json()) as { map: { triplet_count: number } };
expect(knownBody.map.triplet_count).toBe(0);
const unknown = await app.request('/map/totally-made-up-name?owner_id=leo', {}, env);
expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404
});
it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }, 'tenant1');
await seedTriplet(db, { s: 'C', p: '連結至', o: 'D', library: 'kb' }, 'tenant2');
const { app, env } = makeApp(db);
const res = await app.request('/map?owner_id=tenant1', {}, env);
const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(body.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
});
it('沒有 triplet template(這顆 KBDB 從沒建過任何三元組)→ 不報錯,誠實回空清單', async () => {
// 新鮮 DB:只跑過 migrationslibrary_map template 有 seed,但沒人叫過 seedTripletTemplate)。
const fresh = makeSqliteD1();
await expect(ensureFreshLibraryMaps(fresh)).resolves.toBeUndefined();
const { app, env } = makeApp(fresh);
const res = await app.request('/map', {}, env);
expect(await res.json()).toEqual({ success: true, libraries: [], count: 0 });
});
});
+65 -1
View File
@@ -203,7 +203,10 @@ describe('t24 案② — semantic 濾 deprecated 補位(t11 斷點②:0.
};
const captured: Captured[] = [];
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5', {}, env);
// 顯式帶 min_score:本案要測的是「濾下架+不硬湊」,不是分數門檻。
// 2026-08-05 起未帶 min_score 會套相對門檻(top×0.8),0.6 的 a3 會被砍掉
// ⇒ 那會把這個測試變成在測門檻。帶一個寬鬆的絕對值,把門檻這個變因移開。
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5&min_score=0.4', {}, env);
const body = (await res.json()) as { entries: Entry[]; count: number };
expect(body.entries.map((e) => e.id)).toEqual(['a1', 'a2', 'a3']);
expect(body.count).toBe(3);
@@ -224,3 +227,64 @@ describe('t24 案② — semantic 濾 deprecated 補位(t11 斷點②:0.
expect(body.count).toBe(1);
});
});
// ── 相對門檻(2026-08-05leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)────────────
//
// 這組鎖住兩件事:
// ① 門檻跟著「這次查詢的最高分」走,不是固定值
// (固定 0.5 放太多雜訊;固定 0.6 會把「閉環機」那種整體偏低的查詢砍成 0 命中)
// ② 🔴 **門檻必須在濾掉下架之後才算**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走,
// 那正是 leo 08-05 早上撞的「0 命中」的翻版。t24 的 0.971 復現案就是這種殘影。
describe('相對門檻(08-05)— 跟著最高分走,且在濾下架之後才算', () => {
it('低分尾被砍:0.77/0.74/0.64 留下,0.55 以下砍掉(門檻 0.77×0.8=0.616', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const matches = [
{ id: 'hit1', score: 0.77 }, { id: 'hit2', score: 0.74 }, { id: 'hit3', score: 0.64 },
{ id: 'noise1', score: 0.55 }, { id: 'noise2', score: 0.53 }, { id: 'noise3', score: 0.52 },
];
const captured: Captured[] = [];
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
const body = (await res.json()) as { entries: Entry[]; count: number };
expect(body.entries.map((e) => e.id)).toEqual(['hit1', 'hit2', 'hit3']);
});
it('整體偏低的查詢不會被砍光:0.638/0.603/0.588/0.552 全留(門檻 0.638×0.8=0.510', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const matches = [
{ id: 'l1', score: 0.638 }, { id: 'l2', score: 0.603 },
{ id: 'l3', score: 0.588 }, { id: 'l4', score: 0.552 }, { id: 'noise', score: 0.446 },
];
const captured: Captured[] = [];
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
const body = (await res.json()) as { entries: Entry[] };
expect(body.entries.map((e) => e.id)).toEqual(['l1', 'l2', 'l3', 'l4']);
});
it('🔴 下架殘影不得決定門檻:0.971 已下架 → 門檻要用倖存者的 0.6 算,正解不被帶走', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const matches = [
{ id: 'dep-ghost', score: 0.971 }, // 下架殘影,分數卻最高
{ id: 'real1', score: 0.60 }, { id: 'real2', score: 0.52 },
];
const entryMeta: Record<string, string | null> = { 'dep-ghost': JSON.stringify({ status: 'deprecated' }) };
const captured: Captured[] = [];
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
const body = (await res.json()) as { entries: Entry[] };
// 若拿 0.971 算門檻=0.777 ⇒ real1/real2 全被砍 ⇒ 0 命中(就是那個病)。
// 正解:殘影先被濾掉,門檻用 0.6×0.8=0.48 算 ⇒ 兩筆都留。
expect(body.entries.map((e) => e.id)).toEqual(['real1', 'real2']);
});
it('caller 顯式帶 min_score → 尊重絕對值,不再加碼相對門檻', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const matches = [{ id: 'a', score: 0.9 }, { id: 'b', score: 0.5 }, { id: 'c', score: 0.3 }];
const captured: Captured[] = [];
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
const res = await app.request('/entries/search?q=x&mode=semantic&min_score=0.4', {}, env);
const body = (await res.json()) as { entries: Entry[] };
expect(body.entries.map((e) => e.id)).toEqual(['a', 'b']); // 0.3 被絕對門檻砍,0.5 留著
});
});
+104
View File
@@ -0,0 +1,104 @@
// D1 的 LIKE pattern 有 50 bytes 硬上限 —— 長查詢 500 的修復(2026-08-03t152 途中發現)
//
// 病徵(在 1.4.4 實例上逐 byte 二分實測,非推論):
// `/entries/search?q=…` 只要 q 超過 48 bytes 就回 HTTP 500「Internal Server Error」,
// 沒有錯誤訊息。q=48 → 200q=49 → 500;中文 16 字 → 20017 字 → 500。
// 判別實驗:q 固定 48 bytes、其他 filter 全塞滿讓 SQL 變很長 → 仍 200
// ⇒ 爆的是 **LIKE 的 pattern**'%'+q+'%' = 50 bytes),不是 statement 長度。
// 同一個長 q 走 mode=semantic 正常(那條不經過 LIKE)。
//
// 為什麼這是產品級的洞:中文問句超過 16 個字是常態,而 rag_chat 的 kw_search 拿整句問題當 q
// ⇒ 使用者問任何一句正常長度的中文,問答鏈在第二個節點就 500 ⇒ 聊天等於不能用。
//
// 本檔守兩件事:① 短查詢行為**逐字不變**(回歸保護)② 長查詢不再產生超長 pattern。
import { describe, it, expect } from 'vitest';
import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 上限
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
const m = buildContentLike('語意檢索');
expect(m.split).toBe(false);
expect(m.conds).toEqual(['content LIKE ?']);
expect(m.params).toEqual(['%語意檢索%']);
});
it('剛好 48 bytes 仍走單一 LIKE(邊界:pattern 正好 50', () => {
const q = 'a'.repeat(48);
const m = buildContentLike(q);
expect(m.split).toBe(false);
expect(bytes(m.params[0])).toBe(MAX_PATTERN);
});
it('49 bytes 起改走拆詞,且每個 pattern 都在上限內', () => {
const q = 'a'.repeat(49);
const m = buildContentLike(q);
expect(m.split).toBe(true);
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
});
it('有空白的長查詢=按詞拆,每詞一個 LIKE(AND 語意由 caller join', () => {
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
expect(m.split).toBe(true);
expect(m.conds.length).toBeGreaterThan(1);
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
expect(m.params).toContain('%語意檢索%');
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
});
it('無空白的長中文句:切在 UTF-8 邊界上,不切出壞字', () => {
const q = '為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢';
expect(bytes(q)).toBeGreaterThan(48);
const m = buildContentLike(q);
expect(m.split).toBe(true);
for (const p of m.params) {
expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
expect(p).not.toContain(''); // 替換字元=切壞了
expect(q).toContain(p.slice(1, -1)); // 每片都是原句的真子字串
}
});
it('永遠不會回空條件(空條件會讓 WHERE 塌掉、把全表撈回來)', () => {
for (const q of ['a'.repeat(200), ' '.repeat(60), '。'.repeat(60)]) {
const m = buildContentLike(q);
expect(m.conds.length).toBeGreaterThan(0);
expect(m.params.length).toBe(m.conds.length);
}
});
});
describe('searchEntries 實際送出的 SQL', () => {
function fakeDb() {
const captured: { sql: string; params: unknown[] }[] = [];
const db = {
prepare(sql: string) {
return {
bind(...params: unknown[]) {
captured.push({ sql, params });
return { all: async () => ({ results: [] }) };
},
};
},
} as unknown as D1Database;
return { db, captured };
}
it('短查詢:SQL 裡只有一個 content LIKE(回歸保護)', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, '語意檢索', 'demo');
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
expect(captured[0].params[0]).toBe('%語意檢索%');
});
it('長查詢:拆成多個 content LIKE,且沒有任何 pattern 超過 50 bytes', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, '為什麼 rag_chat 不直接用語意檢索排名來選頁而要用字面重疊', 'demo');
const sql = captured[0].sql;
expect((sql.match(/content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
for (const p of captured[0].params) {
if (typeof p === 'string' && p.startsWith('%')) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
}
});
});
+208
View File
@@ -0,0 +1,208 @@
// 語意搜尋「故障要照實說是故障」的回歸測試(2026-08-09 leo 直令)。
//
// 事故:portal 曾把「kbdb 缺 VECTORIZE/AI binding(=壞了)」顯示成「語意搜尋還沒開通,
// 想開通請匯出診斷檔給我們」——把 bug 美化成沒提供的功能,會製造「幫我開通」的客服工單,
// 而真正的故障沒人修。leo 原話:「沒有人會把 bug 美化成沒提供沒開通」。
//
// 三條鐵則(本檔全部驗死):
// 1. 模組不在(module_off)=故障:文案說「故障/我們的問題/你不用做任何事」,
// 禁出現「開通/未啟用/尚未提供」這類把壞說成沒有的字眼,也不要求使用者任何動作。
// 2. 查詢向量化失敗(embed_query_failed)=故障:**不准回空結果集**(舊行為=
// 使用者以為自己的庫裡沒有這筆資料)。誠實降級 keyword+帶 degraded_reason。
// 3. 索引與資料不同步(孤兒向量/下架殘影)→ 搜尋順手自癒(背景刪向量),不留給用戶撞。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import type { Bindings, Entry } from '../src/types';
function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
return {
id, content: '一些內容', entry_type: 'block', owner_id: 't1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 1, confidence: null,
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated', embed: true }) : JSON.stringify({ embed: true }),
created_at: 1, updated_at: 1,
};
}
// fake D1:紀錄所有 prepare 過的 SQL(驗自癒有沒有真的動手);COUNT 依 SQL 內容回
// embedded/pending 兩種計數;getEntryWHERE id = ?)回可配置 entry。
function makeFakeDB(opts: {
embeddedCount?: number;
pendingCount?: number;
hydrate?: Record<string, Entry | null>;
pendingRows?: Entry[];
} = {}) {
const sqls: string[] = [];
const prepare = (sql: string) => {
sqls.push(sql);
let bound: unknown[] = [];
const stmt = {
bind(...args: unknown[]) { bound = args; return stmt; },
async first<T>() {
if (sql.includes('WHERE id = ?')) {
const id = String(bound[0]);
return ((opts.hydrate ?? {})[id] ?? null) as unknown as T;
}
if (sql.includes('COUNT(*)')) {
// backfillStatuspending 用 BACKFILL_PREDICATE(含 is_embedded = 0),embedded 用 is_embedded = 1
if (sql.includes('is_embedded = 0')) return { c: opts.pendingCount ?? 0 } as unknown as T;
return { c: opts.embeddedCount ?? 0 } as unknown as T;
}
return null as unknown as T;
},
async all<T>() {
// backfillEmbeddings 的候選 SELECT(含 is_embedded = 0
if (sql.includes('is_embedded = 0') && sql.includes('SELECT *')) {
return { results: (opts.pendingRows ?? []) as unknown as T[] };
}
return { results: [] as T[] };
},
async run() { return { success: true }; },
};
return stmt;
};
return { db: { prepare } as unknown as D1Database, sqls };
}
function makeApp() {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
return app;
}
/** 收集 waitUntil 的 promise,測試結尾 await 全部,讓背景自癒動作跑完再斷言。 */
function makeCtx() {
const tasks: Promise<unknown>[] = [];
return {
ctx: { waitUntil: (p: Promise<unknown>) => { tasks.push(p); }, passThroughOnException() {}, props: {} } as unknown as ExecutionContext,
flush: async () => { await Promise.allSettled(tasks); return tasks.length; },
};
}
const NO_BLAME_USER = (hint: string) => {
// 禁把故障說成「沒提供/沒開通」;禁要求使用者做「申請開通」類動作
expect(/開通|尚未啟用|未啟用|還沒啟用|尚未提供|沒有提供/.test(hint)).toBe(false);
expect(/請聯絡我們(開通|啟用)|匯出診斷/.test(hint)).toBe(false);
// 必須講明是系統端的問題、使用者不用動作
expect(/我們(系統)?的問題|系統的問題/.test(hint)).toBe(true);
};
describe('mode=semantic 但 embed 模組不在(module_off)——故障,不是「沒開通」', () => {
it('誠實降級 keyworddegraded_reason=module_off,文案照實說故障、不叫使用者做事', async () => {
const app = makeApp();
const { db } = makeFakeDB();
const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; // 無 AI/VECTORIZE
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('keyword');
expect(body.requested_mode).toBe('semantic');
expect(body.degraded_reason).toBe('module_off');
const hint = body.capability_hint as string;
expect(hint).toContain('故障');
NO_BLAME_USER(hint);
// admin_hint 保留技術細節給維運者
expect(String(body.admin_hint)).toMatch(/VECTORIZE|binding/);
});
});
describe('查詢向量化失敗(embed_query_failed)——不准偽裝成「查無資料」', () => {
it('AI.run 丟錯(額度用完)→ 200 誠實降級 keyword,不回空語意結果', async () => {
const app = makeApp();
const { db } = makeFakeDB();
const env = {
DB: db, ENVIRONMENT: 'test',
AI: { async run() { throw new Error('3040: daily limit exceeded'); } },
VECTORIZE: { async query() { throw new Error('不應該走到 Vectorize'); } },
} as unknown as Bindings;
const res = await app.request('/entries/search?q=閉環機&mode=semantic&owner_id=t1', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('keyword');
expect(body.requested_mode).toBe('semantic');
expect(body.degraded_reason).toBe('embed_query_failed');
const hint = body.capability_hint as string;
expect(hint).toContain('故障');
NO_BLAME_USER(hint);
expect(String(body.admin_hint)).toContain('daily limit exceeded');
});
it('AI.run 回不出向量(形狀異常)→ 同樣走誠實降級,不是空結果', async () => {
const app = makeApp();
const { db } = makeFakeDB();
const env = {
DB: db, ENVIRONMENT: 'test',
AI: { async run() { return {}; } }, // 沒有 data
VECTORIZE: { async query() { return { matches: [] }; } },
} as unknown as Bindings;
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.degraded_reason).toBe('embed_query_failed');
expect(body.mode).toBe('keyword');
});
});
describe('索引與資料不同步 → 搜尋順手自癒(不留給下一個用戶撞)', () => {
it('孤兒向量+下架殘影:背景 deleteByIds 兩顆、殘影 is_embedded 歸零', async () => {
const app = makeApp();
const deleted: string[][] = [];
const { db, sqls } = makeFakeDB({
embeddedCount: 5,
hydrate: { live1: mkEntry('live1'), dep1: mkEntry('dep1', { deprecated: true }), gone1: null },
});
const env = {
DB: db, ENVIRONMENT: 'test',
AI: { async run() { return { data: [[0.1, 0.2]] }; } },
VECTORIZE: {
async query() {
return { matches: [ { id: 'live1', score: 0.9 }, { id: 'dep1', score: 0.8 }, { id: 'gone1', score: 0.7 } ] };
},
async deleteByIds(ids: string[]) { deleted.push(ids); },
},
} as unknown as Bindings;
const { ctx, flush } = makeCtx();
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx);
const body = (await res.json()) as Record<string, unknown>;
expect(body.count).toBe(1); // 正常結果不受自癒影響
await flush();
expect(deleted.flat().sort()).toEqual(['dep1', 'gone1']);
// 殘影(dep1)另外把 is_embedded 歸零,讓 D1 與 Vectorize 不說兩套話
expect(sqls.some((s) => s.includes('SET is_embedded = 0'))).toBe(true);
});
it('no_index 且 pending>0(資料在、索引從沒建成=故障)→ 文案不怪用戶+背景觸發 backfill', async () => {
const app = makeApp();
let aiCalls = 0;
const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 3, pendingRows: [mkEntry('p1')] });
const env = {
DB: db, ENVIRONMENT: 'test',
AI: { async run() { aiCalls++; return { data: [[0.1, 0.2]] }; } },
VECTORIZE: { async query() { return { matches: [] }; }, async upsert() {}, async deleteByIds() {} },
} as unknown as Bindings;
const { ctx, flush } = makeCtx();
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx);
const body = (await res.json()) as Record<string, unknown>;
expect(body.empty_reason).toBe('no_index');
const hint = body.capability_hint as string;
NO_BLAME_USER(hint);
await flush();
// backfill 有真的跑(查詢那次 + 補嵌那批 ≥ 2 次 AI.run
expect(aiCalls).toBeGreaterThanOrEqual(2);
});
it('no_index 且 pending=0(庫真的還沒內容)→ 誠實說還沒有資料,不謊稱故障', async () => {
const app = makeApp();
const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 0 });
const env = {
DB: db, ENVIRONMENT: 'test',
AI: { async run() { return { data: [[0.1, 0.2]] }; } },
VECTORIZE: { async query() { return { matches: [] }; } },
} as unknown as Bindings;
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.empty_reason).toBe('no_index');
expect(String(body.capability_hint)).toContain('還沒有');
expect(/故障/.test(String(body.capability_hint))).toBe(false);
});
});
@@ -0,0 +1,109 @@
// 語意搜尋回空時「為什麼空」的回歸測試(2026-08-08,總管交辦二修,Oscar 封測案更正後的真因)。
//
// 背景:原本以為 Oscar 撞到的是「capability_hint 文案太工程師」,後來查出模組其實有開,
// 真正發生的是 GET /entries/search?mode=semantic 零命中時回 {success:true, entries:[], count:0}
// ——誠實(沒假裝有結果)但完全不說為什麼,用戶看到的是「這裡沒有這筆資料」,
// 真相可能是「索引根本沒建好」。三態:
// - no_index :這個 owner 範圍從沒 embed 過(backfillStatus.embedded===0
// - no_match :有索引,這次查詢在 Vectorize 端零命中(正常的「找不到」)
// - stale_index Vectorize 端有命中,但 hydrate 後全部是已下架/找不到對應資料(孤兒向量)
// (不是「相對門檻濾光」——relativeMinScore 的 cut 數學上 <= 最高分,不可能讓非空結果變空)
// 正常有結果(count>0)不受影響,不應該出現 empty_reason 欄位。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import type { Bindings, Entry } from '../src/types';
function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
return {
id, content: '一些內容', entry_type: 'block', owner_id: 'oscar-tenant', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 1, confidence: null,
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated' }) : JSON.stringify({ embed: true }),
created_at: 1, updated_at: 1,
};
}
// fake D1COUNT 查詢回傳可配置的 embeddedCount`WHERE id = ?`getEntry)回傳可配置的 entry。
function makeFakeDB(opts: { embeddedCount?: number; hydrateEntry?: Entry | null } = {}) {
const embeddedCount = opts.embeddedCount ?? 0;
const prepare = (sql: string) => {
let bound: unknown[] = [];
const stmt = {
bind(...args: unknown[]) { bound = args; return stmt; },
async first<T>() {
if (sql.includes('WHERE id = ?')) {
return (opts.hydrateEntry ?? null) as unknown as T;
}
return { c: embeddedCount } as unknown as T;
},
async all<T>() { return { results: [] as T[] }; },
async run() { return { success: true }; },
};
return stmt;
};
return { prepare } as unknown as D1Database;
}
function makeApp() {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
return app;
}
function makeEnv(dbOpts: Parameters<typeof makeFakeDB>[0], matches: { id: string; score: number }[]): Bindings {
return {
DB: makeFakeDB(dbOpts),
ENVIRONMENT: 'test',
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
VECTORIZE: { async query() { return { matches }; } },
} as unknown as Bindings;
}
describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼空」', () => {
it('embedded=0(從沒 embed 過)→ empty_reason=no_index,人話不含 vectorize/redeploy/CC', async () => {
const app = makeApp();
const env = makeEnv({ embeddedCount: 0 }, []);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_index');
const hint = body.capability_hint as string;
expect(hint).toBeTruthy();
expect(/vectorize|redeploy|CC「|binding|kbdb_embed/i.test(hint)).toBe(false);
expect(body.admin_hint).toBeTruthy();
});
it('embedded>0 但這次零命中 → empty_reason=no_match(正常的「找不到」,非故障)', async () => {
const app = makeApp();
const env = makeEnv({ embeddedCount: 42 }, []);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_match');
});
it('Vectorize 有命中但對應資料已下架 → empty_reason=stale_index(非分數門檻)', async () => {
const app = makeApp();
// 命中一筆,但 hydrate 回來的 entry 是已下架的 → 濾光 → entries=0hits.length=1(>0)。
const env = makeEnv({ hydrateEntry: mkEntry('e1', { deprecated: true }) }, [{ id: 'e1', score: 0.6 }]);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('stale_index');
});
it('正常有結果(count>0)不受影響:無 empty_reason 欄位', async () => {
const app = makeApp();
const env = makeEnv({ hydrateEntry: mkEntry('e1') }, [{ id: 'e1', score: 0.6 }]);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(1);
expect(body.empty_reason).toBeUndefined();
expect(body.capability_hint).toBeUndefined();
});
});
+11 -6
View File
@@ -131,13 +131,16 @@ function makeSemanticEnv(queryCalls: { opts: Record<string, unknown> }[]) {
}
describe('#67 — semanticSearch topK / min_score', () => {
it('不帶新參數 → topK=20、全 matches 回傳(行為與舊版一致)', async () => {
// 🔴 2026-08-05:預設 min_score 由 0(不過濾)改為 DEFAULT_MIN_SCORE(跟著 embed 模型走)。
// 原因=閾值原本硬寫在 portal 呼叫端,換 bge-m3 後沒人回頭改 ⇒ 語義搜尋全 0 命中。
// 測資分數 0.9 / 0.5 / 0.2:預設閾值 0.5 ⇒ 只有 0.2 的低分尾被砍。
it('不帶 min_score → topK=20、套用預設閾值(低分尾 0.2 被砍)', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
const hits = await semanticSearch(env, 'query', {});
expect(calls[0].opts.topK).toBe(20);
expect(hits?.length).toBe(3);
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5, 0.2]); // score 帶回
expect(hits?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5]); // score 帶回
});
it('min_score=0.5 → 低分尾截掉(>= 閾值者留)', async () => {
@@ -181,14 +184,15 @@ describe('#67 — route GET /entries/searchsemantictop_k / min_score / sco
expect(body.entries.map((e) => e.score)).toEqual([0.9, 0.5]);
});
it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),回應仍全量回傳(行為不變)entry 仍附 score(加欄不改形)', async () => {
it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),套用相對門檻後只回最高分那筆entry 仍附 score(加欄不改形)', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const { app, env } = makeSemanticApp(calls);
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { count: number; entries: (Entry & { score?: number })[] };
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
expect(body.count).toBe(3);
// 08-05:未帶 min_score ⇒ 相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只有 0.9 留下
expect(body.count).toBe(1);
expect(body.entries[0].score).toBe(0.9);
// 原有欄位一個不少(回應形狀向後相容)
expect(body.entries[0].id).toBe('e-high');
@@ -203,7 +207,8 @@ describe('#67 — route GET /entries/searchsemantictop_k / min_score / sco
expect(res.status).toBe(200);
const body = (await res.json()) as { count: number };
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
expect(body.count).toBe(3); // 無閾值 → 全量
// 壞值=視同沒帶 ⇒ 落回相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只留最高分那筆。
expect(body.count).toBe(1);
}
});
+28 -7
View File
@@ -15,16 +15,37 @@ database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1arcrun-
[vars]
ENVIRONMENT = "production"
# ── Auth guard (t115 二修, fail-closed) ────────────────────────────────────────
# The installer generates a random token at deploy time and secrets it into BOTH workers:
# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-kbdb)
# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-cypher-executor)
# cypher sends the token as `Authorization: Bearer <token>` via kbdbBase().
# Workflow http_request nodes that hit KBDB directly must include
# `Authorization: Bearer __KBDB_TOKEN__` (installer substitutes the value).
#
# Secret NOT set → writes (POST/PATCH/DELETE) are rejected 401 immediately (fail-closed).
# Reads (GET) pass with a server-side warning — old instances survive the upgrade
# window until both workers receive the secret at the same time.
# Secret SET → all non-health routes require correct Bearer; / and /health exempt.
# ──────────────────────────────────────────────────────────────────────────────
# ── Optional embed module (issue #7 / SDD T2.4) ────────────────────────────────
# Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed
# --dimensions=768 --metric=cosine`bge-base-en-v1.5 = 768 維)。官方帳號同理由 deploy 注入。
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed-m3
# --dimensions=1024 --metric=cosine`**bge-m3 = 1024 維**)。官方帳號同理由 deploy 注入。
# 🔴 2026-08-03 換代(leo 拍板,5 組中文測資實證:舊英文模型排序 2/5、margin −0.0413=根本不能用;
# bge-m3 5/5、+0.1410、959ms):`bge-base-en-v1.5`(768) → `bge-m3`(1024)。
# **換模型必須換 index**:① 維度 768→1024,舊 index 收不進新向量
# ② 就算維度相同也不能沿用——不同模型的向量混在同一 index 比對出來是垃圾,
# 而 #58Vectorize vector delete 未接)代表舊向量刪不掉 ⇒ 開新 index 反而順手繞開 #58。
# 既有實例遷移:建新 index → 重部署 kbdbbinding 指新 index
# → `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0 → 舊 index 可刪。
# ⚠️ Arcrun#11:光建 index 不夠。要對 owner_id/entry_type/source 下 filterowner-scoped/類型-scoped 語意查詢),
# 必須另建 metadata index,否則帶過濾一律回 0 命中:
# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name owner_id --type string
# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name entry_type --type string
# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name source --type string
# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name library --type string
# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name owner_id --type string
# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name entry_type --type string
# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name source --type string
# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name library --type string
# libraryportal-auth P1「庫」filterupsert 端把未標記正規化成 'general',查詢走 $in
# metadata index 只收「建立後 upsert」的向量 → 既有向量須 `POST /embed/backfill {"reindex":true}` 重推
# (建 library index 後同樣要 reindex,否則舊向量帶 library filter 一律 0 命中)。
@@ -34,7 +55,7 @@ ENVIRONMENT = "production"
#
# [[vectorize]]
# binding = "VECTORIZE"
# index_name = "arcrun-kbdb-embed"
# index_name = "arcrun-kbdb-embed-m3" # bge-m3 1024d2026-08-03 換代)
#
# [ai]
# binding = "AI"
+1 -1
View File
@@ -40,7 +40,7 @@ export default function SiteNav({ currentPath }: { currentPath?: string }) {
<Link href="/integrations" className={linkCls('/integrations')}>Integrations</Link>
<Link href="/api-docs" className={linkCls('/api-docs')}>API</Link>
<a
href="https://github.com/richblack/arcrun"
href="https://github.com/youlinhsieh/Arcrun"
target="_blank"
rel="noopener noreferrer"
className="text-[#666] hover:text-white transition-colors"
+2 -2
View File
@@ -113,11 +113,11 @@ async function IntegrationsContent({
API AI PR
</p>
<div className="flex gap-3 justify-center flex-wrap">
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
<a href="https://github.com/youlinhsieh/Arcrun" target="_blank" rel="noopener noreferrer"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors">
</a>
<a href="https://github.com/richblack/arcrun/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer"
<a href="https://github.com/youlinhsieh/Arcrun/blob/main/CONTRIBUTING-components.md" target="_blank" rel="noopener noreferrer"
className="border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors">
Recipe
</a>
+2 -2
View File
@@ -91,7 +91,7 @@ export default function HomePage() {
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-lg font-medium transition-colors">
{isLoggedIn ? 'Go to Dashboard' : 'Get API Key — Free'}
</Link>
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
<a href="https://github.com/youlinhsieh/Arcrun" target="_blank" rel="noopener noreferrer"
className="border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-6 py-3 rounded-lg font-medium transition-colors">
View on GitHub
</a>
@@ -189,7 +189,7 @@ drive = auth.bind(
<div className="flex items-center justify-center gap-6">
<Link href="/integrations" className="hover:text-[#777] transition-colors">Integrations</Link>
<Link href="/api-docs" className="hover:text-[#777] transition-colors">API Docs</Link>
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
<a href="https://github.com/youlinhsieh/Arcrun" target="_blank" rel="noopener noreferrer"
className="hover:text-[#777] transition-colors">GitHub</a>
</div>
<p className="mt-4">arcrun MIT License</p>
+71
View File
@@ -0,0 +1,71 @@
# 貢獻新零件(WASM Component Authoring
> 這份文件寫給**貢獻者**——想開發並投稿新零件到 Arcrun 零件庫的人。
> 只想「把 MCP 裝來用」的用戶請回 **[README.md](./README.md)**。
零件是 Arcrun 的最小執行單元,以 **TinyGo** 編譯為 `.wasm`,透過 stdin/stdout JSON 通訊,可在 Cloudflare WorkersTier 1/2)和 Wazero 邊緣環境(Tier 3)執行。提交後 Registry 會自動跑沙盒驗收(體積、syscall 掃描、Gherkin 測試)。
---
## 步驟一:取得開發指引(必做)
```
arcrun_get_component_guide
```
指引包含:TinyGo 白名單 import、禁止行為、`component.contract.yaml` 完整範例、本地測試指令。**開發前務必先呼叫**,白名單與禁止行為以指引回傳為準。
## 步驟二:搜尋現有零件(別重造輪子)
```
arcrun_search_components("查詢 Google Sheets 資料")
```
若已有符合的零件,直接使用,不需要重新開發。
## 步驟三:開發零件(若缺件)
依指引用 TinyGo 撰寫零件,只使用白名單 import:
```go
import (
"os"
"io"
"encoding/json"
)
```
編譯:
```bash
tinygo build -o my_component.wasm -target=wasi .
```
本地測試:
```bash
echo '{"input_field":"value"}' | wasmtime my_component.wasm
```
## 步驟四:提交零件
```
arcrun_publish_component(
contract={...}, // component.contract.yaml 內容(合約物件)
wasm_base64="..." // base64(my_component.wasm)
)
```
Registry 自動執行沙盒驗收(體積、syscall 掃描、Gherkin 測試)。驗收通過才會進零件庫。
---
## 驗收與審查標準
`arcrun_publish_component` 的沙盒驗收會檢查:
- **體積上限**:超過限制拒收。
- **syscall 掃描**:只允許白名單 import;踩到禁止行為(網路直連、檔案系統逃逸等)拒收。
- **Gherkin 測試**:合約裡的 `gherkin_tests` 必須全綠。
投稿前請自行用步驟三的本地測試把 Gherkin 情境跑過一遍,減少來回。
+187 -129
View File
@@ -5,181 +5,233 @@
Arcrun 是反過來的 n8n。n8n 從手寫程式開始,Arcrun 從 AI 描述開始——你說「去抓銀行匯率,用 Telegram 通知我」,AI 把它拆成三元組,Arcrun 查零件庫、組裝、執行。第一次需要 AI,之後自動跑,不再花 Token。
本目錄是 Arcrun 的 **MCP Server**(已併入 arcrun 主庫 `arcrun/mcp/`),讓 Claude Code 等 AI client 直接呼叫 Arcrun 的工作流與零件功能。它是「薄殼」——連哪台 cypher / 哪個帳號由設定決定,與 CLI 共用同一份身份來源。
本目錄是 Arcrun 的 **MCP Server**(已併入 arcrun 主庫 `arcrun/mcp/`),讓 claude.ai、Claude Code、Claude Desktop 等 AI client 直接呼叫 Arcrun 的工作流與零件功能。它是「薄殼」——連哪台 cypher / 哪個帳號由設定決定,與 CLI 共用同一份身份來源。
**這份文件教你怎麼把這個 MCP 裝到你的前端來用。** 想貢獻新零件(投稿 WASM 元件)請見文末的 [貢獻新零件](#貢獻新零件)。
---
## 快速上手
## MCP 連線 URL(先搞懂這個)
### 最簡單:用 CLI 產生連線設定(推薦)
MCP 的 Streamable HTTP 端點路徑是 **`/mcp`**worker 根路徑 `/` 會 404)。無論哪種前端,你要填的 URL 都是:
| 部署 | MCP URL |
|------|---------|
| **官方託管 SaaS** | `https://mcp.arcrun.dev/mcp` |
| **自架 / 接案(self-hosted** | `https://arcrun-mcp.<你的CF子域>.workers.dev/mcp` |
> `<你的CF子域>` 是你 Cloudflare 帳號的 workers.dev 子域(`acr init --self-hosted` 部署後由 `workers_dev` 產生)。**請依你的實際部署子域調整。**
> 若你自己綁了 custom domain(如 `mcp.example.com`),URL 就是 `https://mcp.example.com/mcp`
Transport 一律用 **`type: http`Streamable HTTP**。舊版 SSE`type: sse`)已不支援。
---
## 安裝方式
### 1. claude.ai 雲端 connector(遠端)
claude.ai 走 **OAuth 2.1 + PKCE** 認證(見下方[認證](#認證)),適合自架部署的 owner 遠端使用。
1. claude.ai → **Settings → Connectors → Add custom connector(新增自訂 connector**
2. **MCP Server URL** 貼上你的 MCP URL(例:`https://arcrun-mcp.<你的CF子域>.workers.dev/mcp`)。
3. 儲存後點 **Connect**claude.ai 會自動發現 OAuth(透過 `/.well-known/oauth-protected-resource`)並跳到同意頁 `/authorize`
4. 在同意頁**輸入 owner secret**——就是你部署時用 `wrangler secret put MCP_OWNER_SECRET` 設的那組祕密(只有 owner 知道)。祕密正確才發 token,之後 claude.ai 用該 token 呼叫工具。
5. token 有效期預設 30 天(`MCP_TOKEN_TTL`);到期後 claude.ai 會自動重走 OAuth,再輸一次 owner secret 即可。
> 官方 SaaS`mcp.arcrun.dev`)走的是 partner-key 驗證,不是 owner-secret 同意頁;一般接案/自架用戶用的是上面這條 OAuth 路徑。
### 2. Claude CodeCC / IDE
Claude Code 用專案層 `.mcp.json`HTTP transport)掛同一個 URLauth 走 OAuth(首次連線時在瀏覽器完成 owner-secret 同意頁)。
**推薦:用 CLI 自動產生**(依你的 arcrun 設定寫對的 URL,接案切資料夾自動切帳號):
```bash
acr mcp-setup
```
`acr mcp-setup`你的 arcrun 設定(env > 專案 `.arcrun.yaml` > 全域在當前資料夾寫 `.mcp.json`
Claude Code 進此資料夾就連對的 MCP。`acr init` 也會自動順帶跑這步。
`acr mcp-setup`env > 專案 `.arcrun.yaml` > 全域」解析出的 `mcp_url` 在當前資料夾寫 `.mcp.json`;沒設 `mcp_url` 就 fallback 平台預設 `https://mcp.arcrun.dev/mcp``acr init` 也會自動順帶跑這步。
- 沒設 `mcp_url` → 連平台預設 `https://mcp.arcrun.dev`
- 自架 / 接案:在 `.arcrun.yaml``mcp_url`(或 `ARCRUN_MCP_URL` env)指向自己 / 客戶的 MCP,再 `acr mcp-setup`
### 手動設定(Claude Desktop / Cursor
`.mcp.json` / client MCP 設定內容(remote HTTP MCP):
**手動:** 在專案根建 `.mcp.json`
```json
{
"mcpServers": {
"arcrun": {
"type": "http",
"url": "https://mcp.arcrun.dev"
"url": "https://arcrun-mcp.<你的CF子域>.workers.dev/mcp"
}
}
}
```
> 平台託管的 MCP 需要 arcrun API Key 授權;自架的 MCP 綁你自己的 cypher
> 連線 URL 以 `acr mcp-setup` 產出的為準。
官方 SaaS 版本把 `url` 換成 `https://mcp.arcrun.dev/mcp` 即可
> 使用 `type: http`Streamable HTTP transport)。舊版 SSE 格式(`type: sse`)已不支援。
---
## MCP Tools 說明
### 零件開發(WASM
零件是 Arcrun 的最小執行單元,以 TinyGo 編譯為 `.wasm`,透過 stdin/stdout JSON 通訊。
| Tool | 說明 |
|------|------|
| `arcrun_get_component_guide` | **開發新零件前必須先呼叫。** 取得 TinyGo 開發指引,包含白名單 import、禁止行為、contract YAML 範例、本地測試指令。 |
| `arcrun_search_components` | 用自然語言語意搜尋零件庫。例如:「查詢 Google Sheets 資料」、「發送 LINE 訊息」。回傳零件清單含 canonical_id、描述、評分。 |
| `arcrun_get_component` | 取得指定零件的完整合約(input_schema、output_schema、gherkin_tests、評分統計等)。 |
| `arcrun_publish_component` | 提交 TinyGo WASM 零件。需提供 `contract`(合約物件)與 `wasm_base64`(編譯後的 .wasm base64)。Registry 自動執行沙盒驗收。 |
### 工作流執行
| Tool | 說明 |
|------|------|
| `arcrun_validate_yaml` | 部署前驗證工作流 YAML 的 schema。輸入 `yaml_content`。 |
| `arcrun_push_workflow` | 將工作流 YAML 部署至雲端引擎。輸入 `api_key``yaml_content`。 |
| `arcrun_run_workflow` | 觸發已部署的工作流執行。輸入 `api_key``name`,選填 `input`(帶進 trigger context)。 |
### 工作流管理
| Tool | 說明 |
|------|------|
| `arcrun_list_workflows` | 列出已部署的工作流。可傳入選填的 `tag` 參數篩選。 |
| `arcrun_get_workflow` | 取得指定工作流的 metadata。輸入 `name`。 |
### 零件管理
| Tool | 說明 |
|------|------|
| `arcrun_list_components` | 列出已發佈的零件。可傳入選填的 `tag` 參數篩選。 |
### Tag 管理
| Tool | 說明 |
|------|------|
| `arcrun_create_tag` | 建立新 tag。輸入 `name`(必填)與 `description`(選填)。 |
| `arcrun_list_tags` | 列出當前命名空間下所有 tag。 |
| `arcrun_delete_tag` | 刪除指定 tag。輸入 `tag_name`。 |
| `arcrun_tag_resource` | 為工作流或零件加上 tag。輸入 `resource_type``resource_id``tag_name`。 |
| `arcrun_untag_resource` | 移除工作流或零件的 tag。 |
---
## 零件開發流程(WASM
Arcrun 的零件是 TinyGo 編譯的 `.wasm`,透過 stdin/stdout JSON 通訊,可在 Cloudflare WorkersTier 1/2)和 Wazero 邊緣環境(Tier 3)執行。
### 步驟一:取得開發指引
```
arcrun_get_component_guide
```
指引包含:TinyGo 白名單 import、禁止行為、`component.contract.yaml` 完整範例、本地測試指令。
### 步驟二:搜尋現有零件
```
arcrun_search_components("查詢 Google Sheets 資料")
```
若已有符合的零件,直接使用,不需要重新開發。
### 步驟三:開發零件(若缺件)
依指引用 TinyGo 撰寫零件,只使用白名單 import:
```go
import (
"os"
"io"
"encoding/json"
)
```
編譯:
也可用 CLI 直接加(HTTP transport):
```bash
tinygo build -o my_component.wasm -target=wasi .
claude mcp add --transport http arcrun https://arcrun-mcp.<你的CF子域>.workers.dev/mcp
```
本地測試:
### 3. 本機 Claude Desktop
```bash
echo '{"input_field":"value"}' | wasmtime my_component.wasm
Claude Desktop 的 `claude_desktop_config.json`macOS`~/Library/Application Support/Claude/`Windows`%APPDATA%\Claude\`)加一組 remote HTTP MCP
```json
{
"mcpServers": {
"arcrun": {
"type": "http",
"url": "https://arcrun-mcp.<你的CF子域>.workers.dev/mcp"
}
}
}
```
### 步驟四:提交零件
```
arcrun_publish_component(
contract={...}, // component.contract.yaml 內容
wasm_base64="..." // base64(my_component.wasm)
)
```
Registry 自動執行沙盒驗收(體積、syscall 掃描、Gherkin 測試)。
> **若你的 Claude Desktop 版本尚不支援 remote HTTP MCP**,改用 `mcp-remote` proxy 把 remote MCP 橋成本機 stdio
>
> ```json
> {
> "mcpServers": {
> "arcrun": {
> "command": "npx",
> "args": ["-y", "mcp-remote", "https://arcrun-mcp.<你的CF子域>.workers.dev/mcp"]
> }
> }
> }
> ```
>
> `mcp-remote` 會自動處理 OAuth 流程(跳瀏覽器完成 owner-secret 同意頁)。
---
## 工作流開發流程
## 更新(取得新零件 / 新版)
### 步驟一:搜尋零件
**更新流程 ≈ 重跑一次部署,已安裝的自動略過。** self-hosted 用戶要拿新零件(例如新增的 `code` 零件)或新版引擎時:
```
arcrun_search_components("查詢匯率")
arcrun_search_components("發送 Telegram 訊息")
```bash
acr update
```
### 步驟二:部署前驗證
`acr update` 會下載最新的 Arcrun 部署物,只**部署新增 / 變更的 Worker,未變動的自動跳過**(終端會顯示「N 個未變動已跳過」)。它與 `acr init --self-hosted` 走同一條路(下載 → 注入 KV id → `wrangler deploy`),差別只在:`init` 是首次(建 KV / R2 + 寫 config),`update` 是沿用既有 config 重部署有變動的 Worker。所以「新裝 arcrun」和「更新」其實**差不多同一套流程**——想拿新零件 / 新版就跑更新。
```
arcrun_validate_yaml(yaml_content="...")
```
- 只在 **self-hosted 模式**可用(部署在你自己的 Cloudflare);官方 SaaS 用戶由平台自動更新,不需要跑。
- 要強制把**全部** Worker 重部署一遍:`acr update --force`
- 冪等:KV namespace / D1 等基礎設施已存在則重用,不會重建。
### 步驟三:部署
> ⚠️ **更新源現況(待核佔位)**`acr update` 的下載源正從 GitHub codeload 改指 **Gitea 自有真身**(修復追蹤 Arcrun#4)。上面描述以「修好後的 Gitea 版」為準;確切指令與來源行為**待該修復 PR 定案**,屆時再回來校正。
```
arcrun_push_workflow(api_key="ak_xxx", yaml_content="...")
```
---
### 步驟四:觸發執行
## 認證
```
arcrun_run_workflow(api_key="ak_xxx", name="exchange-rate-notify", input={"currency_pair": "USD/TWD"})
```
MCP 打進 arcrun 觸及該租戶 **KBDB 全量讀寫**,必須有真認證。中介層(`src/middleware/partner-auth.ts`)依序嘗試:
1. **OAuth 2.1 access_token**(自架部署的遠端正規路徑)
- claude.ai / Claude Code / Claude Desktop 遠端連線都走這條。
- 唯一的人類祕密閘在 `/authorize` 同意頁:輸入 **`MCP_OWNER_SECRET`**(部署時以 CF Secret 設定,只有 owner 知道)。祕密正確才發 authorization code → 換 access_token。
- 只知道「網址」的人打得開同意頁、能跑 DCR,但**沒有 owner secret 就換不到 token**,打 `/mcp` 一律 401。
2. **`MCP_STATIC_TOKEN`CF Secret,本機 / CLI 相容路徑)**
- 本機 Claude Code / GUI 若不想每次走 OAuth,可設 `MCP_STATIC_TOKEN`(真祕密),把它當 `Authorization: Bearer <此值>` 帶進 `.mcp.json`
- 這是取代**已廢除的「明碼 namespace 當 bearer」**舊路徑的安全做法——用真祕密 token,而非把 namespace 明碼放行。
3. **官方 SaaS`MULTI_TENANT` 未設 / `true`** → KBDB partner-key`pk_live…`)驗證,行為不變。
> ⚠️ **舊的「明碼 namespace bearer」路徑已從預設移除**(送 `Bearer leo` 就能讀 leo 全部資料的洞已補)。只有明確設 `ALLOW_PLAINTEXT_NAMESPACE="true"`(遷移期逃生門,預設關、將 SUNSET)才會恢復,正式環境勿用。
完整安全模型(PKCE、audience 綁定、KV 儲存鐵律、redirect 白名單)見 **[OAUTH.md](./OAUTH.md)**。
---
## 部署前置(self-hosted 用戶)
自架前,先把 OAuth 需要的 KV 與 Secret 就緒(完整清單見 **[OAUTH.md](./OAUTH.md) §7**,此處只摘要):
1. `acr init --self-hosted` 部署 arcrun-mcp worker。
- **CLI 路徑**`OAUTH_KV` namespace 由 `deploy.ts` 自動建立並填入真 id(零手動)。
- **手動直推**`wrangler kv namespace create OAUTH_MCP` → 把 id 貼進 `mcp/wrangler.toml``[[kv_namespaces]] OAUTH_KV`
2. **設 owner secret**`wrangler secret put MCP_OWNER_SECRET`(輸入只有你知道的強祕密)。
3. (選配)**設本機相容 static token**`wrangler secret put MCP_STATIC_TOKEN`
4. (選配)`[vars]` 調整 `MCP_OWNER_NAMESPACE`(預設 `leo`/ `MCP_TOKEN_TTL`(預設 259200030 天)/ `MCP_ALLOWED_REDIRECT_HOSTS`
5. 驗收:`curl <origin>/.well-known/oauth-protected-resource` → 200;未帶 token 打 `/mcp` → 401 帶 `WWW-Authenticate`claude.ai 加 connector 走完 OAuth 能連上。
> KV / secret 未就緒時,OAuth 端點誠實回 503、`/mcp` 回 401(不假綠),既有官方 SaaS partner-key 路徑不受影響。
---
## MCP Tools 總覽
連上後,前端會看到兩組工具:`arcrun_*`(平台功能)與 `kbdb_*`(資料層)。
### `arcrun_*`
**工作流(Workflow**
| Tool | 一句話 |
|------|--------|
| `arcrun_validate_yaml` | 部署前驗證工作流 YAML schema。 |
| `arcrun_push_workflow` | 把工作流 YAML 部署到雲端引擎。 |
| `arcrun_run_workflow` | 觸發已部署的工作流執行(可帶 `input`)。 |
| `arcrun_list_workflows` / `arcrun_get_workflow` / `arcrun_delete_workflow` | 列出 / 取得 / 刪除工作流(直問 cypher-executor 真實狀態)。 |
| `arcrun_search_workflows` | 語意搜尋工作流。 |
| `arcrun_list_recent_executions` / `arcrun_list_paused_executions` / `arcrun_get_execution_trace` | 查最近 / 暫停中的執行、取單次執行 trace。 |
**零件(Component**
| Tool | 一句話 |
|------|--------|
| `arcrun_search_components` | 用自然語言語意搜尋零件庫。 |
| `arcrun_list_components` / `arcrun_get_component` | 列出零件、取單一零件完整合約。 |
| `arcrun_get_component_guide` | 取得 TinyGo 開發指引(**開發新零件前必先呼叫**)。 |
| `arcrun_publish_component` | 提交 WASM 零件(見[貢獻新零件](#貢獻新零件))。 |
**Recipe(配方 · 公庫 / 私庫)**
| Tool | 一句話 |
|------|--------|
| `arcrun_recipe_search` / `arcrun_recipe_list` | 搜尋 / 列出配方。 |
| `arcrun_recipe_pull` / `arcrun_recipe_push` / `arcrun_recipe_delete` | 拉取 / 推送 / 刪除私庫配方。 |
| `arcrun_recipe_submit_p` | 投稿配方到公庫。 |
**Skill / Example**
| Tool | 一句話 |
|------|--------|
| `arcrun_list_skills` / `arcrun_get_skill` | 列出 / 取得技能。 |
| `arcrun_list_examples` / `arcrun_get_example` / `arcrun_search_examples` | 列出 / 取得 / 搜尋範例。 |
**Tag**
| Tool | 一句話 |
|------|--------|
| `arcrun_create_tag` / `arcrun_list_tags` / `arcrun_delete_tag` | 建立 / 列出 / 刪除 tag。 |
| `arcrun_tag_resource` / `arcrun_untag_resource` | 為工作流或零件加上 / 移除 tag。 |
**其他**
| Tool | 一句話 |
|------|--------|
| `arcrun_whoami` | 回報當前身份 / namespace(與 `acr whoami` 對齊)。 |
| `arcrun_report_feedback` | 回報使用回饋。 |
| `arcrun_get_gui_context` | 取 arcrun-gui 畫布上下文。 |
### `kbdb_*`(資料層薄殼)
類 Supabase 萬用表:AI 只有 template + slot 可用,**不提供建表 / SQL tool**(儲存鐵律)。
| Tool | 一句話 |
|------|--------|
| `kbdb_search` | 語意搜尋 KBDB 記錄。 |
| `kbdb_query` | 依條件查詢記錄。 |
| `kbdb_get_record` | 取單一記錄。 |
| `kbdb_create_record` | 建立記錄(依 template + slots)。 |
| `kbdb_list_templates` / `kbdb_create_template` | 列出 / 建立 template。 |
---
## Inspector 測試界面
開啟 `https://mcp.arcrun.dev/inspector`(或自架 MCP 的 `/inspector`)即可在瀏覽器互動式測試所有 MCP tools。
開啟 `<你的 MCP origin>/mcp/inspector`(官方=`https://mcp.arcrun.dev/mcp/inspector`)即可在瀏覽器互動式測試所有 MCP tools。
---
@@ -192,3 +244,9 @@ arcrun_run_workflow(api_key="ak_xxx", name="exchange-rate-notify", input={"curre
- AI 的操作結果即時反映在 arcrun-gui 的畫布上
詳細開發指南請參閱 **[GUIDE.md](./GUIDE.md)**。
---
## 貢獻新零件
想投稿 WASM 零件(TinyGo 開發、本地測試、`arcrun_publish_component` 提交、沙盒驗收)?完整流程見 **[CONTRIBUTING-components.md](./CONTRIBUTING-components.md)**。
+1 -1
View File
@@ -158,7 +158,7 @@ export function registerGetExecutionTrace(server: McpServer, env: Env) {
export function registerListRecentExecutions(server: McpServer, env: Env) {
server.tool(
toolName("list_recent_executions"),
"列某 workflow 最近 N 次執行 verdict(成功 / 失敗 / duration)。資料來源是 ANALYTICS_KV 90 天保留期。",
"列某 workflow 最近 N 次執行 verdict(成功 / 失敗 / duration)。資料來源是 D1 執行紀錄表,稽核資料——預設保留 90 天(3 個月)過期即清,管理者可在 portal 改保留天數或設為不刪除;用量過大時系統會自動降成只記失敗、甚至暫停記錄——工作流本身執行不受影響。",
{
api_key: z.string().describe(apiKeyDesc),
workflow_name: z.string().describe("workflow 名稱(acr push 時的 name 欄)"),
+22 -7
View File
@@ -23,10 +23,19 @@ import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
/** 空庫/404 時的 backfill 指引(誠實回報+給下一步,鐵律:不假綠)。 */
/**
* /404
*
* 2026-08-08 ingest M3
* grep repo kbdb `GET /map``GET /map/:library`
* `kbdb/src/actions/library-map.ts`
* `ensureFreshLibraryMaps`
* `POST /map/recompute` `library` slot
* `source_uri` `source_prefix`
*/
const RECOMPUTE_HINTS = [
"地圖由 ingest 尾端自動重算(M3);尚未接鏈的庫要手動 backfill:對 kbdb 呼 POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix}",
"backfill 過渡期(triplet 還沒有 library slot 值)用 source_prefix 以 source_uri 前綴歸庫,如 {\"source_prefix\":\"gitea:Leo/kb@\"}",
"地圖每次查詢都會自動核對即時三元組數並重算過期的庫,不必手動處理",
"少見情況(舊三元組沒有 library 標記)才需要手動:POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix}",
];
/** 註冊全部藏書地圖工具(library-map M4)。 */
@@ -89,9 +98,11 @@ export function registerGetMap(server: McpServer, env: Env) {
triplet_count: Number(l.triplet_count ?? 0) || 0,
}));
if (libraries.length === 0) {
// 空庫誠實回報:不是錯誤(端點正常、就是還沒有地圖),給 backfill 指引。
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
// 不再是「沒人跑過 recompute」那種曖昧狀態。
return successResponse({ libraries: [], count: 0 }, [
"全館地圖是空的:還沒有任何庫跑過 recompute",
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
...RECOMPUTE_HINTS,
]);
}
@@ -104,9 +115,13 @@ export function registerGetMap(server: McpServer, env: Env) {
// 單庫詳圖:完整 slotsslot 陣列 parse 成物件再回)。
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
if (res.status === 404) {
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
// 代表這個名字在這個租戶的資料裡從沒出現過——不是「這庫是空的」,是根本沒有這個庫
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
return errorResponse(
"map_not_found",
`庫「${library}還沒有地圖(從未 recompute,或庫名打錯`,
`查無庫「${library}——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute`,
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
);
}
@@ -129,7 +144,7 @@ export function registerGetMap(server: McpServer, env: Env) {
triplet_count: Number(raw.triplet_count ?? 0) || 0,
};
return successResponse({ map }, [
"bridges=此庫 entity 同時出現在哪些其他庫(M3 backfill 前會偏稀疏,是誠實現況不是 bug)",
"bridges=此庫 entity 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)",
"沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)",
]);
} catch (e) {

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