Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af3edff856 | |||
| c5d696556e | |||
| 5919c6b90f | |||
| b6ef0f07dc | |||
| 507620e313 | |||
| 1e94f8451e | |||
| dcb6ad693b | |||
| 3b0238bb28 | |||
| 788295ed71 | |||
| 797e7f751c | |||
| d1c44a5878 | |||
| a7e23badf2 | |||
| eebb691426 | |||
| c76e10d314 | |||
| 9a29eb5af6 | |||
| 8d49d883c0 | |||
| 417d69ceb3 | |||
| 035e8b255b | |||
| 1c630ecfd4 | |||
| a99d3e5a3e | |||
| 894408181f | |||
| d022ca067b | |||
| 6715402bcc | |||
| c4cee35adb | |||
| ae81d22775 | |||
| 9740794050 | |||
| 453dec60b1 | |||
| bfc98fe41a | |||
| be9b92eb28 | |||
| 19c82df05f | |||
| 23d36b311a | |||
| ebd4bf5d97 | |||
| 889b70b8f9 | |||
| 21be6d19f7 | |||
| 6846d6ddae | |||
| 8eb10049b8 | |||
| 831cb62d2e | |||
| 894d9abeb1 | |||
| 3447efc94e | |||
| 4ca23c256a | |||
| aa6b899276 | |||
| 466e56bc2d | |||
| 07cc7f51b5 | |||
| 42cb1d7aa9 | |||
| e730b3f831 | |||
| 84471659af | |||
| 93b1140bf1 | |||
| 962d863ef7 | |||
| 7dbd4f59e7 | |||
| d779a11958 | |||
| c7a0b317cb | |||
| 046ceba29c | |||
| ac4fb56c91 | |||
| 1e2ef6806a | |||
| 5388f40c03 | |||
| 83aa1f6bb2 | |||
| 9344562258 |
@@ -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
|
||||
|
||||
+20
@@ -6,6 +6,10 @@ dist/
|
||||
# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署
|
||||
# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6)
|
||||
!.component-builds/**/component.wasm
|
||||
# 例外:Arcrun#80 tier2 worker 官方編譯成品(cypher-executor/kbdb/http_request/code/mcp 的
|
||||
# esbuild bundle + 隨附 wasm part)——commit 進 repo 同一套理由:固定位置、any clone 都拿得到,
|
||||
# 不必自己再編一次(見 scripts/build-worker-artifacts.mjs)。
|
||||
!.worker-builds/**/*.wasm
|
||||
# 例外:code 零件(自足 Worker)的 vendored quickjs.wasm 同屬部署物 —— acr init/update 從
|
||||
# repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件
|
||||
# @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs
|
||||
@@ -52,3 +56,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
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-11T05:33:37.988Z",
|
||||
"repo_head": "d8bbf2241bd6b117d76fb27d9e386ecfb0ffe8f7",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 568855,
|
||||
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"EXEC_CONTEXT",
|
||||
"WEBHOOKS",
|
||||
"CREDENTIALS_KV",
|
||||
"ANALYTICS_KV",
|
||||
"RECIPES",
|
||||
"USERS_KV",
|
||||
"SESSIONS_KV"
|
||||
],
|
||||
"d1": [
|
||||
{
|
||||
"binding": "CREDENTIALS_DB",
|
||||
"database_name": "arcrun-kbdb"
|
||||
}
|
||||
],
|
||||
"vectorize": 0,
|
||||
"ai": true,
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production",
|
||||
"CF_ACCOUNT_ID": "",
|
||||
"WORKER_SUBDOMAIN": "uncle6-me",
|
||||
"KBDB_BASE_URL": "https://arcrun-kbdb.uncle6-me.workers.dev",
|
||||
"CONSOLE_TENANT": "leo",
|
||||
"PORTAL_SESSION_TTL": "604800",
|
||||
"PORTAL_SHOW_WORKFLOWS": "admin",
|
||||
"GITEA_BASE_URL": "https://git.uncle6.me",
|
||||
"GITEA_SPRINT_REPO": "Leo/InkStoneCo",
|
||||
"GITEA_SPRINT_DIR": "system-dev/docs/3-specs/autonomy-dispatch"
|
||||
}
|
||||
},
|
||||
"stripped": {
|
||||
"services": 13
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "a7e23badf2a771be779a861e69e7efa6e8141dfe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 135910,
|
||||
"content_sha256": "5e5a7a030f4fd1f5549ace6791c3827b6497b0bfdd9add46af041af47c472905",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "arcrun-kbdb"
|
||||
}
|
||||
],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-http-request",
|
||||
"source_dir": ".component-builds/http_request",
|
||||
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-http-request/worker.mjs",
|
||||
"js_bytes": 80073,
|
||||
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-http-request/component.wasm",
|
||||
"sha256": "cc15cc785703e7bbb8dbff2d38dc84a4ac24e2f44316182730abae0f170ef133"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "http_request"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153671,
|
||||
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1165130,
|
||||
"content_sha256": "be15033f32e605f03f69bd10cd87782dafa34dbafeee2ce367bd7361a062a291",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"notes": []
|
||||
}
|
||||
@@ -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)** — 創始人,產品設計與架構決策
|
||||
- **Claude(Anthropic)** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查
|
||||
|
||||
歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
想投稿零件?看 [CONTRIBUTING-components.md](CONTRIBUTING-components.md)。
|
||||
|
||||
@@ -17,9 +17,8 @@ fetch_rows >> 對每個 row >> notify
|
||||
```
|
||||
|
||||
- 第一個節點固定是 `input`(觸發時帶進來的資料)
|
||||
- **邊只有兩種**:`ON_SUCCESS` 與 `對每個 <變數>`(FOREACH)
|
||||
- **沒有** `ON_TRUE`/`ON_FALSE`/`ON_FAILURE`——引擎不支援條件分支。
|
||||
需要判斷就寫成一個獨立節點再接 `ON_SUCCESS`。
|
||||
- **邊有這些**:`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。
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ fetch_rows >> 對每個 row >> notify
|
||||
```
|
||||
|
||||
- 第一個節點固定是 `input`
|
||||
- 邊只有 `ON_SUCCESS` 與 `對每個 <變數>`(**沒有** `ON_TRUE`/`ON_FALSE`/`ON_FAILURE`)
|
||||
- 需要判斷 → 寫成獨立節點(例 `check_amount`)再接 `ON_SUCCESS`
|
||||
- 邊有 `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。
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ if [ "$TOOL" = "Write" ] || [ "$TOOL" = "Edit" ] || [ "$TOOL" = "MultiEdit" ]; t
|
||||
# 只在 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;要判斷 → 寫成獨立節點接 \`ON_SUCCESS\`(引擎沒有條件邊)。每個寫進 code 的 if 都是沒被測過的新 bug。見 arcrun-mindset Skill。"
|
||||
"\`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
|
||||
|
||||
@@ -54,17 +54,55 @@ description: >-
|
||||
- **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名**
|
||||
- **邊**=什麼情況下往下走
|
||||
|
||||
## 2. 邊只有兩種(真範本裡出現過的)
|
||||
## 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) >> 處理啟用` |
|
||||
|
||||
⚠️ **不要寫 `ON_FAILURE`/`ON_TRUE`/`ON_FALSE`**——引擎目前**沒有條件分支**
|
||||
(實測 `grep ON_TRUE|ON_FALSE` 於 cypher-executor = 0;見 Gitea Arcrun#5)。
|
||||
需要判斷時:**寫成一個獨立節點**(例 `check_amount`)再接 `ON_SUCCESS`,
|
||||
讓查詢告訴你有沒有零件可用。
|
||||
### 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`
|
||||
|
||||
@@ -157,7 +195,9 @@ curl -s -X POST https://arcrun-cypher-executor.<subdomain>.workers.dev/cypher/se
|
||||
|
||||
## 7. 常犯的錯
|
||||
|
||||
1. **用不存在的邊**(`ON_FAILURE`/`ON_TRUE`)→ 只有 `ON_SUCCESS` 與 `對每個 X`
|
||||
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
|
||||
|
||||
+1
-1
@@ -44,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,17 @@ const CHECKS = [
|
||||
['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_TRUE 只准出現在「教它不存在」的脈絡(否定詞/實測證據)。
|
||||
// 若哪天它出現在範本裡(正面示範),就是教材寫錯,該擋。
|
||||
['ON_TRUE', '引擎沒有條件邊,教材不該把它當可用的邊', /不要寫|不存在|沒有條件|❌|非法|grep|= 0/],
|
||||
// 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);教材不該把它教成可用的邊', /不要寫|不存在|沒有這種|❌|非法/],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 部署)...'));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -28,10 +28,12 @@ export interface ArcrunConfig {
|
||||
mcp_url?: string;
|
||||
multi_tenant?: boolean;
|
||||
// 語義查詢開關(issue #7 / SDD T2.4,self-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 keyword(free-tier 友善,不建 index、不花費)。
|
||||
// 開法:設 kbdb_embed:true → redeploy(acr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
|
||||
// false → base 維持 LIKE keyword(顯式選擇才有這個狀態;缺欄位不再等於關——
|
||||
// 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)。
|
||||
kbdb_embed?: boolean;
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
|
||||
+60
-11
@@ -271,12 +271,24 @@ export async function downloadAndDeploy(
|
||||
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
|
||||
await ensureVectorizeIndex(ctx);
|
||||
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
|
||||
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
|
||||
await ensureVectorizeMetadataIndexes(ctx);
|
||||
// metadata index,否則帶 owner_id/entry_type/source/library 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
|
||||
const created = await ensureVectorizeMetadataIndexes(ctx);
|
||||
console.log(chalk.green(' ✓'));
|
||||
// 新建的 metadata index **只收「建立之後 upsert」的向量** ⇒ 既有向量不重推就永遠 filter 不到。
|
||||
// 這一步不能靜默:leo21c 全盲事件裡,人看到「✓」就以為好了,實際上舊向量一筆都查不到。
|
||||
if (created.length > 0) {
|
||||
console.log(chalk.yellow(
|
||||
` ⚠ 新建了 metadata index(${created.join('/')})。Vectorize 只索引「建立之後寫入」的向量,\n` +
|
||||
' 既有向量必須重推才查得到 → 部署完成後打:\n' +
|
||||
' POST <kbdb>/embed/backfill {"reindex":true} (重複呼叫直到 remaining=0)',
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
console.log(chalk.red(' ✗'));
|
||||
failures.push(
|
||||
`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}` +
|
||||
' ⇒ 語意搜尋會「看起來有開、實際全盲」(帶歸屬條件的查詢一律 0 命中),請先修好這項再驗收語意搜尋。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,8 +475,16 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
|
||||
/**
|
||||
* embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。
|
||||
*
|
||||
* 🔴 這份清單必須與 `kbdb/src/embed.ts` 的 upsert metadata 欄位**逐欄對齊**:少一欄,
|
||||
* 帶那一欄過濾的語意查詢就永遠回 0 命中(Vectorize 只認「已建 metadata index」的欄位),
|
||||
* **而且不會報錯**——與 bge-m3 換代那次同款的靜默漂移(wiki/mistakes.md「改 A 要連動 B」)。
|
||||
* `library` 是 2026-08-11 補的:portal-auth P1 的「庫」filter 早就拿它在查,清單卻一直停在
|
||||
* 三欄(`kbdb/wrangler.toml` 自己記著「library 待補進該清單」,那張欠條在這裡還掉)。
|
||||
*/
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source', 'library'] as const;
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed index 上的 metadata index(owner_id/entry_type/source)存在(Arcrun#11 根因修復)。
|
||||
@@ -472,16 +492,18 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`(indexType=string)。
|
||||
* 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。
|
||||
*/
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<string[]> {
|
||||
const base = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}`;
|
||||
const auth = { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' };
|
||||
const created: string[] = [];
|
||||
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(`${base}/metadata_index/create`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||
headers: auth,
|
||||
body: JSON.stringify({ propertyName, indexType: 'string' }),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (res.ok) continue;
|
||||
if (res.ok) { created.push(propertyName); continue; }
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
|
||||
| null;
|
||||
@@ -489,6 +511,33 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void>
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue;
|
||||
throw new Error(`metadata_index ${propertyName}: ${msg}`);
|
||||
}
|
||||
|
||||
// 🔴 建完一定要複驗(2026-08-11 立,Arcrun#85 D70 事故的直接教訓)。
|
||||
// leo21c 的現役 index 上**一個 metadata index 都沒有**,於是每一條帶 owner_id 的
|
||||
// 語意查詢(=所有真實使用者路徑,租戶隔離一律帶)都回 0 命中,語意搜尋全盲三天。
|
||||
// 真兇是 arcrun-rag 安裝器把端點寫成 `metadata-index/create`(連字號,CF 回 404,
|
||||
// 正解是底線 `metadata_index/create`),而那支把失敗降級成一行 ⚠ 就宣告安裝成功。
|
||||
// ⇒ **「我發過 create 請求」不等於「index 真的在」**。這一段就是那個等號。
|
||||
// 複驗失敗一律 throw:呼叫端會把它收進 failures 讓部署誠實標紅,而不是
|
||||
// 「語意搜尋開起來了、但全盲」這種最貴的假綠(mindset §7 禁假綠)。
|
||||
const listRes = await fetch(`${base}/metadata_index/list`, { headers: auth, signal: AbortSignal.timeout(60_000) });
|
||||
if (!listRes.ok) {
|
||||
throw new Error(`metadata_index 複驗失敗:list HTTP ${listRes.status}(無法確認 index 是否真的建起來,不當作成功)`);
|
||||
}
|
||||
const listJson = (await listRes.json().catch(() => null)) as
|
||||
| { result?: { metadataIndexes?: Array<{ propertyName?: string }> } }
|
||||
| null;
|
||||
const present = new Set(
|
||||
(listJson?.result?.metadataIndexes ?? []).map(m => String(m.propertyName ?? '')),
|
||||
);
|
||||
const missing = KBDB_VECTORIZE_META_FIELDS.filter(f => !present.has(f));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`metadata_index 複驗不通過:${missing.join('/')} 不在 ${KBDB_VECTORIZE_INDEX} 上。` +
|
||||
'沒有這些 index,帶 owner_id/library 等條件的語意查詢會一律回 0 命中(不會報錯,只是全盲)。',
|
||||
);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** 下載 Gitea archive tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
|
||||
|
||||
@@ -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",
|
||||
" 烤進產物。但 t160(e744ad1)為了清世代債把 build.mjs 整支刪掉、改成直接託管 public/,",
|
||||
" **沒有人把『把宣告值寫進產物』這件事接手過去** ⇒ deploy.mjs 照樣在終端機印",
|
||||
" 『profile:full / 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,",
|
||||
" 你要確定不可再犯」):**組態對 ≠ 世代對**。",
|
||||
" 當天實測:三個對外網址的 apiBase/views/home **三項全過**,",
|
||||
" 但它們跑的是 07-22 那一代的 portal(82,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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: "https://cypher.arcrun.dev" };
|
||||
@@ -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#25:portal admin 密碼救援——只吃 console owner session(S.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
|
||||
// 當場拋錯,把後面所有綁定(含登出)一起打斷。
|
||||
|
||||
@@ -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/ 即可。
|
||||
|
||||
@@ -122,6 +122,12 @@
|
||||
.modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
|
||||
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
|
||||
.modebtn.hide { display: none; }
|
||||
/* 🔴 2026-08-10 實測撞到:`.hide` 一直**只有上面那三條有 scope 的規則**(#tabbar .tab / #sidenav .nav / .modebtn),
|
||||
所以任何其他元素掛上 class="hide" 都**完全沒有被藏起來**——看起來有藏、其實沒藏。
|
||||
D62 的「忘記密碼」欄位與「現有密碼」欄位就是這樣在瀏覽器上原形畢露的。
|
||||
補一條通用規則;帶 !important 是因為要蓋過 inline 的 display(#forgot-box 就有)。
|
||||
既有那三種用法的意圖與本規則一致(都是「藏起來」),加這條不改變它們的行為。 */
|
||||
.hide { display: none !important; }
|
||||
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
|
||||
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
|
||||
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
|
||||
@@ -219,11 +225,42 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
|
||||
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
|
||||
<!-- 🔴 D50/D62:這裡原本寫「忘記密碼請聯絡管理員重設」+一條「用管理主控台密碼救援自己」
|
||||
的 console 連結。**兩條都是已否決的做法**,不准寫回來:
|
||||
① leo:「我要是記得密碼,還來找忘記密碼幹嘛?」——console 登入頁一樣要密碼
|
||||
② console 與 portal 是安裝時**同一組帳密**寫進兩個地方(D50 補刀)
|
||||
⇒ console 不是第二道閘,是同一把鎖的第二個鑰匙孔,往它補洞不會多出任何一條路
|
||||
③ 唯一管理員忘記密碼時「聯絡管理員」=叫他聯絡他自己
|
||||
leo 2026-08-10:「**是對 portal 不是對 console**,這樣 youlin 雖然忘記,
|
||||
我還是可以去 portal 忘記密碼。」⇒ 出口就在這裡。 -->
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">
|
||||
忘記密碼了?<a href="#" id="login-forgot" style="color:var(--amber)">寄一條「修改密碼」連結給我</a>
|
||||
</div>
|
||||
<div id="forgot-box" class="hide" style="display:flex;flex-direction:column;gap:10px;margin-top:4px">
|
||||
<input type="email" id="forgot-email" class="txt" placeholder="你註冊時用的 Email" autocomplete="username">
|
||||
<button class="btn" id="forgot-submit">寄出連結</button>
|
||||
<div id="forgot-status" style="font-size:13.5px;min-height:1.2em;line-height:1.6"></div>
|
||||
</div>
|
||||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密碼(D62):忘記密碼點信裡的連結進來的落點。
|
||||
🔴 這個殼**刻意沒有自己的密碼欄位**——真正的表單是設定頁那一份 #pw-form,
|
||||
進到這個模式時被 JS 原封不動搬進 #reset-slot。D62:「同一個畫面、同一條寫入路徑,
|
||||
差別只有一格:現有密碼是要填、還是被連結豁免。」複製一份長得像的就會漂移。 -->
|
||||
<div class="authwrap view" id="v-reset">
|
||||
<div class="authbox">
|
||||
<div>
|
||||
<div style="font-size:22px;font-weight:600;margin-bottom:6px">設定新密碼</div>
|
||||
<div id="reset-who" style="font-size:14px;color:rgba(var(--ink-rgb),.55);line-height:1.7;margin-bottom:14px"></div>
|
||||
<div id="reset-slot"></div>
|
||||
<div id="reset-error" class="err" style="font-size:14px;line-height:1.7"></div>
|
||||
<div style="margin-top:16px;font-size:13.5px"><a href="#/" id="reset-back" style="color:var(--amber)">回登入頁</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首次設定(t49,leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
|
||||
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
|
||||
<div class="authwrap view" id="v-firstsetup">
|
||||
@@ -374,10 +411,14 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">更改密碼</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<!-- D62(leo 2026-08-10):「改密碼與忘記密碼**這兩個機制其實是一個機制,可以簡化**。」
|
||||
⇒ 這個表單是**整個站唯一一份**改密碼的表單。忘記密碼點連結進來時,
|
||||
它會被原封不動搬到 #v-reset 那個殼裡(不是複製一份長得像的)——
|
||||
差別只有「現有密碼」那一格顯不顯示。同一個畫面、同一條寫入路徑。 -->
|
||||
<div class="panel" id="st-pw-panel">
|
||||
<div style="font-size:17px;font-weight:600" id="pw-title">更改密碼</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)" id="pw-sub">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||
<div id="pw-form" style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
|
||||
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
|
||||
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
|
||||
@@ -401,6 +442,21 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div style="margin-top:8px;font-size:13px;line-height:1.7;color:rgba(var(--ink-rgb),.5)">封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 08-09(arcrun-rag#7,封測者原話「說明叫我把 MCP 加進 claude.ai connector,但我找不到網址」):
|
||||
文件一直寫「登入 portal 設定頁直接複製」,但畫面上從沒真的顯示過這串網址——用戶照著文件的
|
||||
指示走到這裡,只會撲空。這裡補上:MCP 網址跟知識庫網址(apiBase)是同一顆自架帳號的
|
||||
workers.dev 子網域,只是 worker 名字從 arcrun-cypher-executor 換成 arcrun-mcp
|
||||
(CLI 部署當時就是這樣組出這兩個網址的,見 cli/src/lib/deploy.ts:386-392)——
|
||||
純前端字串轉換,不需要後端新端點、不需要安裝器多寫一份設定。 -->
|
||||
<div class="panel">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:13.5px">
|
||||
<span style="color:rgba(var(--ink-rgb),.6);white-space:nowrap">你的 MCP 網址(給你的 AI 連線用)</span>
|
||||
<code id="st-mcp-url" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)">—</code>
|
||||
<button class="btn3" id="st-copy-mcp-url" style="padding:5px 12px;font-size:13px;white-space:nowrap;flex:none">複製</button>
|
||||
</div>
|
||||
<div style="font-size:17px;font-weight:600">接上你的 AI(MCP)</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">把上面這串網址貼到 Claude、ChatGPT 等 AI 的「新增自訂連接器」欄位,就能讓你的 AI 直接查這個知識庫。</div>
|
||||
</div>
|
||||
<!-- t176(leo 08-03):AI 設定整塊移除。
|
||||
雲端聊天問答走 Workers AI(用戶自己 CF 帳號內建,**免金鑰、裝好就能用**);
|
||||
地端萃取用哪把金鑰改由「同步小幫手」自己設定(托盤選單「AI 設定…」)。
|
||||
@@ -415,6 +471,26 @@ if (!window.ARCRUN_API_BASE) {
|
||||
文件整理成知識卡的部分,請在<b style="color:var(--ink)">同步小幫手</b>(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。
|
||||
</div>
|
||||
</div>
|
||||
<!-- 檢修孔演進史:
|
||||
2026-08-07 leo 直接指令「一顆按鈕在設定裡,按鈕下載一個檔案,把檔案發給我」
|
||||
→「疑難排解」面板+#st-diag-export 按鈕誕生,打 GET /portal/data/diagnostics。
|
||||
|
||||
2026-08-08(t213,InkStoneCo 總管交辦)發現這顆按鈕在**封測者的瀏覽器**裡執行,
|
||||
跟他電腦上的 daemon 是兩個獨立行程,構不到本機資料(檔案總量/失敗分類/
|
||||
daemon 版本)——完整版改在 arcrun-app(同步小幫手)「版本與更新」頁本機端匯出
|
||||
(打新端點 GET /portal/daemon/diagnostics)。當時按鈕先保留當退路,文案改成
|
||||
誠實講清楚自己只有一半、導去完整版。
|
||||
|
||||
2026-08-09(leo 拍板拿掉):leo 08-08「雲端那個要刪掉?不刪用戶搞不清楚要去
|
||||
哪裏下載」,封測者已被通知去更新到有地端匯出的版本後,08-09 追認「通知完畢
|
||||
可以刪除」。⇒ 按鈕與 #st-diag-export/#st-diag-status 一併移除,面板改成純文字
|
||||
指路(同步小幫手才是唯一還按得到、也答得出完整診斷的地方)。
|
||||
GET /portal/data/diagnostics 端點本身留著未刪(無害、未被任何 UI 呼叫,
|
||||
純粹清路標,不動後端)。 -->
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">疑難排解</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">要回報問題,請到你電腦上的 <b style="color:var(--ink)">Arcrun</b>(同步小幫手)「版本與更新」頁——那裡的「疑難排解」按一下就能匯出完整診斷檔給我們(只有統計數字,不含你的任何文件內容)。</div>
|
||||
</div>
|
||||
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,6 +499,21 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div class="view page" id="v-admin">
|
||||
<div class="pagehead"><span class="t">管理</span><span class="m">帳號與知識庫授權</span></div>
|
||||
|
||||
<div class="sechead">執行紀錄保留期</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">保留天數</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.55);margin-bottom:12px">執行紀錄是稽核資料,超過保留天數會被每日自動清除;預設 90 天(3 個月),也可設為「不刪除」(企業稽核用途)。</div>
|
||||
<div class="formrow">
|
||||
<input type="number" id="ad-ret-days" class="txt" min="1" step="1" placeholder="天數(例:90)">
|
||||
<label style="display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:15px;padding:0 4px">
|
||||
<input type="checkbox" id="ad-ret-never"> 不刪除
|
||||
</label>
|
||||
<button class="btn" id="ad-ret-save" style="flex:none;padding:0 20px">儲存</button>
|
||||
</div>
|
||||
<div id="ad-ret-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
|
||||
<div id="ad-ret-current" class="muted" style="font-size:13px;margin-top:2px"></div>
|
||||
</div>
|
||||
|
||||
<div class="sechead">帳號管理</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">新增同仁帳號</div>
|
||||
@@ -635,12 +726,25 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
return src.slice(5).replace(/#[^#]*$/, '');
|
||||
}
|
||||
function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; }
|
||||
// 內部型別 → 人話標籤(2026-08-07:entry_type 原始值如 wiki_card / block / execution_log
|
||||
// 是資料庫內部分類,不是用戶該懂的詞——尤其 wiki_card 直接違背上傳頁自己講的「AI 整理後
|
||||
// 會以 wiki 卡形式出現」,若卡片上貼的標籤是英文 snake_case「wiki_card」,等於自打嘴巴。
|
||||
// 未知型別一律落地成中性的「筆記」,不吐原始英文字串給使用者。
|
||||
var ENTRY_TYPE_LABEL = {
|
||||
wiki_card: '知識卡', block: '知識卡', value: '記錄', workflow: '工作流',
|
||||
execution_log: '執行紀錄', todo: '待辦', inbox: '收件', user_template: '範本',
|
||||
recipe_submission: '投稿', agent_feedback: '回饋'
|
||||
};
|
||||
function entryTypeLabel(t) { return ENTRY_TYPE_LABEL[t] || '筆記'; }
|
||||
// 搜尋模式的原始值(keyword/semantic)是 API 參數,不是用戶詞彙——一律轉中文再顯示。
|
||||
var SEARCH_MODE_LABEL = { keyword: '關鍵字', semantic: '語意', graph: '圖譜' };
|
||||
function searchModeLabel(m) { return SEARCH_MODE_LABEL[m] || m; }
|
||||
function entryTitle(e) {
|
||||
if (e.page_name) return e.page_name;
|
||||
var first = String(e.content || '').split(/\r?\n/).find(function (l) { return l.trim(); }) || '';
|
||||
first = first.replace(/^#+\s*/, '').replace(/^[-*>]\s*/, '').trim();
|
||||
if (first.length > 60) first = first.slice(0, 60) + '…';
|
||||
return first || '(無標題・' + (e.entry_type || 'entry') + ')';
|
||||
return first || '(無標題・' + entryTypeLabel(e.entry_type) + ')';
|
||||
}
|
||||
function entrySnippet(e) {
|
||||
var lines = String(e.content || '').split(/\r?\n/).filter(function (l) { return l.trim(); });
|
||||
@@ -682,6 +786,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
|
||||
function route() {
|
||||
// 🔴 D62:`#/reset?token=…` **不是站內的一個 view**,是「還沒登入的人拿著連結進來」。
|
||||
// 少了這一段,下面那行正規化會把不認得的 `reset` 換成 HOME ⇒ **連 token 一起丟掉**,
|
||||
// 使用者點信裡的連結會落在搜尋頁、而且再也回不去(連結一次有效,已經耗掉了)。
|
||||
// 實測(2026-08-10 stage,瀏覽器):網址被改寫成 `#/search`、修改密碼畫面根本沒出現。
|
||||
var rt = resetTokenFromUrl();
|
||||
if (rt) { showReset(rt); return; }
|
||||
var r = currentRoute();
|
||||
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
|
||||
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
|
||||
@@ -753,11 +863,28 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
} catch (e) { /* 登入殼還沒渲染就算了 */ }
|
||||
}
|
||||
function boot() {
|
||||
// D62:信裡的「修改密碼」連結最優先——這個人**就是進不去才收到這封信的**,
|
||||
// 不可以因為瀏覽器裡剛好還留著一個舊 token 就把他丟去別的畫面。
|
||||
var rt = (typeof resetTokenFromUrl === 'function') ? resetTokenFromUrl() : '';
|
||||
if (rt) { showReset(rt); return; }
|
||||
if (!S.token) { showAuth(); return; }
|
||||
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { dropSession(); return; }
|
||||
// 🔴 arcrun-rag#66:**只有 401 才算「你被登出了」**。
|
||||
// 舊版是 `if (!x.ok) dropSession()` = 任何非 2xx 都清掉 token——
|
||||
// 包含改完密碼後那幾十秒的 503(認證 secret 正在鋪開)與 502(KBDB 暫時不可達)。
|
||||
// 那正是 leo 08-10「改完密碼、重新整理就回不去」的最後一哩:
|
||||
// 後端就算不刪 KV,前端自己把鑰匙丟了,結果一樣。
|
||||
if (x.status === 401) { dropSession(); return; }
|
||||
if (!x.ok) {
|
||||
// 暫時性故障:留著 session,告訴他這是暫時的、下一步做什麼。
|
||||
showAuth();
|
||||
$('login-status').textContent = (x.d && x.d.code === 'auth_store_propagating')
|
||||
? '認證資料正在更新中(通常幾十秒),請稍候重新整理——你並沒有被登出。'
|
||||
: '服務暫時不可用,請稍後重新整理(你的登入沒有失效)。';
|
||||
return;
|
||||
}
|
||||
S.profile = x.d;
|
||||
showApp();
|
||||
})
|
||||
@@ -924,9 +1051,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
dropSession();
|
||||
});
|
||||
|
||||
// t87 07-28 leo:知識庫網址 helper(只有 origin,不含 /portal/# 後綴),兩處 UI 共用
|
||||
function copyOriginUrl(btn) {
|
||||
var url = location.origin;
|
||||
// 檢修孔前端已於 2026-08-09 移除(t213,leo 拍板「通知完畢可以刪除」)——
|
||||
// #st-diag-export 元素不再存在,匯出診斷檔改在 arcrun-app(同步小幫手)
|
||||
// 「版本與更新」頁本機端做。GET /portal/data/diagnostics 端點本身未刪(無害、
|
||||
// 已無任何 UI 呼叫),只是這裡不再掛按鈕去打它。
|
||||
|
||||
// t87 07-28 leo:複製任意網址到剪貼簿的共用 helper(08-09 從 copyOriginUrl 拆出
|
||||
// copyText,讓 MCP 網址這種「不是 location.origin」的字串也能共用同一套按鈕行為)。
|
||||
function copyText(btn, url) {
|
||||
var orig = btn.textContent;
|
||||
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||
alert('請手動選取並複製:' + url);
|
||||
@@ -939,6 +1071,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
alert('請手動選取並複製:' + url);
|
||||
});
|
||||
}
|
||||
function copyOriginUrl(btn) { copyText(btn, location.origin); }
|
||||
(function () {
|
||||
['st-origin-url', 'ad-origin-url'].forEach(function (id) {
|
||||
var el = $(id); if (el) el.textContent = location.origin;
|
||||
@@ -948,6 +1081,33 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
});
|
||||
})();
|
||||
|
||||
// 08-09(arcrun-rag#7):MCP 網址=apiBase 換一個 worker 名字(arcrun-cypher-executor →
|
||||
// arcrun-mcp),同一顆自架帳號的 workers.dev 子網域(deploy.ts:386-392 部署時就是這樣組的)。
|
||||
// 不是這個形狀(例如官方多租戶自訂網域)就誠實留空,不亂猜一個貼上去會連錯的網址。
|
||||
function mcpUrlFromApiBase() {
|
||||
try {
|
||||
var u = new URL(window.ARCRUN_API_BASE);
|
||||
if (u.hostname.indexOf('arcrun-cypher-executor.') === 0) {
|
||||
u.hostname = u.hostname.replace('arcrun-cypher-executor.', 'arcrun-mcp.');
|
||||
return u.origin + '/mcp';
|
||||
}
|
||||
} catch (e) { /* apiBase 空值或格式不符時不猜 */ }
|
||||
return '';
|
||||
}
|
||||
(function () {
|
||||
var mcpUrl = mcpUrlFromApiBase();
|
||||
var el = $('st-mcp-url');
|
||||
var btn = $('st-copy-mcp-url');
|
||||
if (el) el.textContent = mcpUrl || '(尚未偵測到,請確認安裝已完成)';
|
||||
if (btn) {
|
||||
if (mcpUrl) {
|
||||
btn.addEventListener('click', function () { copyText(this, mcpUrl); });
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
|
||||
function setupSteps() {
|
||||
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
|
||||
@@ -1088,6 +1248,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
|
||||
// 任何 data 請求收到 401 → session 失效 → 回登入殼
|
||||
//
|
||||
// 🔴 arcrun-rag#66 的前端那一半:後端在「認證資料正在鋪開」時改回 503
|
||||
// `auth_store_propagating`(不再回 401、也不再刪 KV 那筆 session)。
|
||||
// 這裡**必須跟著只認 401**——若前端把任何錯誤都當登出,後端不刪也沒用:
|
||||
// 使用者手上的 token 會被自己的瀏覽器丟掉,症狀跟被踢出去一模一樣。
|
||||
// (503 由各呼叫點自己顯示錯誤訊息,session 原封不動。)
|
||||
function guard401(status) {
|
||||
if (status === 401) { dropSession(); return true; }
|
||||
return false;
|
||||
@@ -1120,12 +1286,26 @@ 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.mode === '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 || '系統尚未開啟語意索引——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
|
||||
// 🔴 2026-08-09 leo:「語義搜尋已經確定是一安裝就提供的功能⋯⋯我沒有不開通這個
|
||||
// 功能,是壞了,沒有人會把 bug 美化成沒提供沒開通。」
|
||||
// 走到這裡=這台實例的語意搜尋壞了(缺 binding 或向量化失敗),照實說是故障、
|
||||
// 是我們的問題,不要求使用者做任何事。文案優先用後端 capability_hint(已是人話,
|
||||
// 能分「暫時故障/部署故障」),舊版後端沒有就用底下的通用故障文案。
|
||||
var bhint = (d.capability_hint && !/開通|尚未啟用/.test(d.capability_hint))
|
||||
? d.capability_hint
|
||||
: '語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。';
|
||||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋目前故障</div><div class="b">' + esc(bhint) + '</div></div>';
|
||||
}
|
||||
var entries = d.entries || [];
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + (d.note ? '・' + d.note : '');
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + searchModeLabel(d.mode || 'keyword') + (d.note ? '・' + d.note : '');
|
||||
if (!entries.length) {
|
||||
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
|
||||
// 空結果不一律怪查詢字:語意模式的空結果,後端 capability_hint 會分
|
||||
// 「真的沒命中(換字)」與「索引故障/還沒有資料(不是用戶的問題)」,照實顯示。
|
||||
// 降級(mode=keyword)時故障說明已在上方橫幅,這裡不重複。
|
||||
var emptyMsg = (d.capability_hint && d.mode === 'semantic')
|
||||
? esc(d.capability_hint)
|
||||
: '找不到「' + esc(q) + '」——換個關鍵字試試。';
|
||||
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">' + emptyMsg + '</div>';
|
||||
return;
|
||||
}
|
||||
$('se-results').innerHTML = entries.map(function (e) {
|
||||
@@ -1134,7 +1314,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
'<div class="kt">' + esc(entryTitle(e)) + '</div>' +
|
||||
'<div class="ks">' + esc(entrySnippet(e)) + '</div>' +
|
||||
'<div class="km">' +
|
||||
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
|
||||
'<span class="tag">' + esc(entryTypeLabel(e.entry_type)) + '</span>' +
|
||||
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
|
||||
(src ? '<span class="dim" style="word-break:break-all">' + esc(src) + '</span>' : '') +
|
||||
'<span class="dim mono" style="margin-left:auto">' + esc(fmtDate(e.created_at)) + '</span></div></div>';
|
||||
@@ -1178,7 +1358,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var mode = (s && s.mode) || '';
|
||||
var href = s && s.source ? srcHref(s.source) : '';
|
||||
return '<div class="nbrow" data-aisrc="' + esc(page) + '">' +
|
||||
(mode ? '<span class="tag" style="flex:none">' + esc(mode) + '</span>' : '') +
|
||||
(mode ? '<span class="tag" style="flex:none">' + esc(searchModeLabel(mode)) + '</span>' : '') +
|
||||
'<span style="word-break:break-word">' + esc(page || '(無頁名)') + '</span>' +
|
||||
(href ? '<a href="' + esc(href) + '" target="_blank" rel="noopener" style="flex:none;color:var(--amber);font-size:13px" title="開啟來源檔">來源 ↗</a>' : '') +
|
||||
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none">›</span></div>';
|
||||
@@ -1278,7 +1458,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var src = entrySource(e);
|
||||
$('cd-main').innerHTML = '<div class="cardtitle">' + esc(entryTitle(e)) + '</div>' +
|
||||
'<div style="display:flex;gap:10px;align-items:center;margin-top:12px;font-size:13.5px;flex-wrap:wrap">' +
|
||||
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
|
||||
'<span class="tag">' + esc(entryTypeLabel(e.entry_type)) + '</span>' +
|
||||
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
|
||||
(e.page_name ? '<span class="tag dim">' + esc(e.page_name) + '</span>' : '') +
|
||||
'<span class="muted mono">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +
|
||||
@@ -1521,9 +1701,57 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
});
|
||||
}
|
||||
|
||||
// 執行紀錄保留期(P7):GET/PUT /portal/admin/execution-log-retention,role=admin 閘(server 端)。
|
||||
// retention_days: number=自訂天數;null=「不刪除」(企業稽核,leo 08-07:「我願意花很多錢
|
||||
// 保存,不要刪除」);未設定過的租戶也會回一個值(KBDB 端退回預設 90 天)。
|
||||
function loadRetention() {
|
||||
$('ad-ret-current').textContent = '載入中…';
|
||||
adminApi('GET', '/portal/admin/execution-log-retention')
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { $('ad-ret-current').textContent = ''; $('ad-ret-status').textContent = x.d.error || ('讀取失敗(HTTP ' + x.status + ')'); return; }
|
||||
var days = x.d.retention_days;
|
||||
var def = x.d.default_days || 90;
|
||||
$('ad-ret-never').checked = (days === null);
|
||||
$('ad-ret-days').value = (days === null) ? '' : (days != null ? days : def);
|
||||
$('ad-ret-days').disabled = (days === null);
|
||||
$('ad-ret-current').textContent = (days === null)
|
||||
? '目前設定:不刪除(企業稽核)'
|
||||
: '目前設定:保留 ' + (days != null ? days : def) + ' 天' + (days == null ? '(沿用預設值,尚未自訂)' : '');
|
||||
})
|
||||
.catch(function (e) { $('ad-ret-current').textContent = ''; $('ad-ret-status').textContent = friendlyErr(e); });
|
||||
}
|
||||
$('ad-ret-never').addEventListener('change', function () {
|
||||
$('ad-ret-days').disabled = this.checked;
|
||||
});
|
||||
$('ad-ret-save').addEventListener('click', function () {
|
||||
var st = $('ad-ret-status');
|
||||
st.textContent = '';
|
||||
var never = $('ad-ret-never').checked;
|
||||
var body;
|
||||
if (never) {
|
||||
body = { retention_days: null };
|
||||
} else {
|
||||
var n = parseInt($('ad-ret-days').value, 10);
|
||||
if (!n || n <= 0) { st.textContent = '請輸入大於 0 的天數,或勾「不刪除」'; return; }
|
||||
body = { retention_days: n };
|
||||
}
|
||||
$('ad-ret-save').disabled = true;
|
||||
adminApi('PUT', '/portal/admin/execution-log-retention', body)
|
||||
.then(function (x) {
|
||||
$('ad-ret-save').disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = x.d.error || ('儲存失敗(HTTP ' + x.status + ')'); return; }
|
||||
toast('保留期已更新');
|
||||
loadRetention();
|
||||
})
|
||||
.catch(function (e) { $('ad-ret-save').disabled = false; st.textContent = friendlyErr(e); });
|
||||
});
|
||||
|
||||
function loadAdmin() {
|
||||
$('ad-users').innerHTML = '<div class="muted">載入中…</div>';
|
||||
$('ad-libs').innerHTML = '<div class="muted">載入中…</div>';
|
||||
loadRetention();
|
||||
adminApi('GET', '/portal/admin/libraries')
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
@@ -1863,33 +2091,147 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
|
||||
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
|
||||
}
|
||||
$('st-pw-save').addEventListener('click', function () {
|
||||
// ══════════ D62:改密碼與忘記密碼=同一個機制 ══════════
|
||||
//
|
||||
// leo 2026-08-10:「這兩個機制其實是一個機制,可以簡化。」
|
||||
// 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋
|
||||
// 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
|
||||
// ⇒ 底下只有**一個表單元素**(#pw-form)與**一支送出函式**,差別只有「現有密碼」那一格
|
||||
// 顯不顯示、以及帶 current 還是帶 reset_token。兩份長得像的東西必然漂移
|
||||
// (arcrun-rag#40「同一個事實兩份」)。
|
||||
// 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。
|
||||
|
||||
var S_RESET_TOKEN = ''; // 非空=現在是「忘記密碼」模式
|
||||
|
||||
/** 把那一份唯一的表單搬到指定容器,並決定「現有密碼」那一格出不出現。 */
|
||||
function mountPasswordForm(host, needCurrent) {
|
||||
var form = $('pw-form');
|
||||
if (form && form.parentNode !== host) host.appendChild(form);
|
||||
$('st-pw-old').classList.toggle('hide', !needCurrent);
|
||||
$('st-pw-save').textContent = needCurrent ? '更新密碼' : '設定新密碼';
|
||||
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||||
$('st-pw-status').textContent = '';
|
||||
}
|
||||
|
||||
/** 送出:兩種模式共用。帶 reset_token 就走連結那一格,否則走現有密碼那一格。 */
|
||||
function submitPasswordChange() {
|
||||
var needCurrent = !S_RESET_TOKEN;
|
||||
var oldPw = $('st-pw-old').value;
|
||||
var newPw = $('st-pw-new').value;
|
||||
var newPw2 = $('st-pw-new2').value;
|
||||
var st = $('st-pw-status');
|
||||
st.className = 'err';
|
||||
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
|
||||
if (needCurrent && !oldPw) { st.textContent = '請填現在的密碼'; return; }
|
||||
if (!newPw) { st.textContent = '請填新密碼'; return; }
|
||||
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
|
||||
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
|
||||
var body = { 'new': newPw };
|
||||
if (S_RESET_TOKEN) body.reset_token = S_RESET_TOKEN; else body.current = oldPw;
|
||||
$('st-pw-save').disabled = true;
|
||||
st.textContent = '';
|
||||
fetch(API_BASE + '/portal/me/password', {
|
||||
st.textContent = '處理中…';
|
||||
fetch(API_BASE + '/portal/password/change', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify({ current: oldPw, 'new': newPw })
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, needCurrent ? authHeaders() : {}),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$('st-pw-save').disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
|
||||
// 忘記密碼模式沒有 session,401 不代表「你被登出」——別誤踢
|
||||
if (needCurrent && guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = (x.d && x.d.error) || '更新失敗'; return; }
|
||||
st.className = 'ok';
|
||||
st.textContent = '密碼已更新';
|
||||
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||||
toast('密碼已更新');
|
||||
if (S_RESET_TOKEN) {
|
||||
st.textContent = '密碼已設定好了,正在帶你回登入頁…';
|
||||
var who = (x.d && x.d.email) || '';
|
||||
S_RESET_TOKEN = '';
|
||||
setTimeout(function () {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
location.hash = '';
|
||||
$('v-reset').classList.remove('on');
|
||||
mountPasswordForm($('st-pw-panel'), true);
|
||||
showAuth();
|
||||
if (who) $('login-email').value = who;
|
||||
$('login-status').className = 'ok';
|
||||
$('login-status').textContent = '新密碼已生效,請用它登入。';
|
||||
}, 1200);
|
||||
} else {
|
||||
st.textContent = '密碼已更新(你不會被登出,可以繼續操作)';
|
||||
toast('密碼已更新');
|
||||
}
|
||||
})
|
||||
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
|
||||
}
|
||||
$('st-pw-save').addEventListener('click', submitPasswordChange);
|
||||
|
||||
// ── 登入頁的「忘記密碼」入口(**在 portal,不是 console**,leo 2026-08-10)──
|
||||
$('login-forgot').addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
$('forgot-box').classList.remove('hide');
|
||||
$('forgot-email').value = $('login-email').value.trim();
|
||||
$('forgot-email').focus();
|
||||
});
|
||||
$('forgot-submit').addEventListener('click', function () {
|
||||
var email = $('forgot-email').value.trim();
|
||||
var st = $('forgot-status');
|
||||
st.className = 'err';
|
||||
if (!email) { st.textContent = '請填 Email'; return; }
|
||||
$('forgot-submit').disabled = true;
|
||||
st.textContent = '寄送中…';
|
||||
fetch(API_BASE + '/portal/password/forgot', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$('forgot-submit').disabled = false;
|
||||
if (!x.ok) { st.textContent = (x.d && x.d.error) || '寄送失敗'; return; }
|
||||
st.className = 'ok';
|
||||
st.textContent = (x.d && x.d.message) || '如果這個 email 有帳號,連結已經寄出了。';
|
||||
})
|
||||
.catch(function (e) { $('forgot-submit').disabled = false; st.textContent = friendlyErr(e); });
|
||||
});
|
||||
|
||||
/** 點信裡的連結進來:`…/portal/#/reset?token=…`(cypher 的 /portal/password/reset-link 導過來的)。 */
|
||||
function resetTokenFromUrl() {
|
||||
var m = String(location.hash || '').match(/[?&]token=([^&]+)/);
|
||||
if (m) return decodeURIComponent(m[1]);
|
||||
m = String(location.search || '').match(/[?&]token=([^&]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
function showReset(token) {
|
||||
$('v-login').classList.remove('on');
|
||||
$('v-firstsetup').classList.remove('on');
|
||||
$('shell').classList.remove('on');
|
||||
$('tabbar').classList.remove('on');
|
||||
$('v-reset').classList.add('on');
|
||||
$('reset-error').textContent = '';
|
||||
// 先問一次「這張票還有效嗎」——連結是一次有效、30 分鐘過期的,
|
||||
// 過期就要當場說清楚,不要讓人填完密碼才被打回票(#49:別把系統狀態講成用戶的錯)。
|
||||
fetch(API_BASE + '/portal/password/reset?token=' + encodeURIComponent(token))
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok || !x.d || x.d.valid !== true) {
|
||||
$('reset-who').textContent = '';
|
||||
$('reset-error').textContent = (x.d && x.d.error) || '這條連結已經失效了。請回登入頁重新按一次「忘記密碼」。';
|
||||
return;
|
||||
}
|
||||
S_RESET_TOKEN = token;
|
||||
$('reset-who').innerHTML = '你正在為 <strong>' + esc(x.d.email) + '</strong> 設定新密碼。<br>這條連結只能用一次,<strong>不需要輸入現在的密碼</strong>。';
|
||||
mountPasswordForm($('reset-slot'), false); // ← 差別只有這一格
|
||||
})
|
||||
.catch(function (e) { $('reset-error').textContent = friendlyErr(e); });
|
||||
}
|
||||
$('reset-back').addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
S_RESET_TOKEN = '';
|
||||
history.replaceState(null, '', location.pathname);
|
||||
location.hash = '';
|
||||
$('v-reset').classList.remove('on');
|
||||
mountPasswordForm($('st-pw-panel'), true);
|
||||
showAuth();
|
||||
});
|
||||
|
||||
boot();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
// t160(leo 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。');
|
||||
|
||||
@@ -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 缺 _profiles(profile → 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 那一代的 portal(82,911 bytes、
|
||||
// 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的
|
||||
// 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。
|
||||
//
|
||||
// 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶——
|
||||
// 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。
|
||||
//
|
||||
// 為什麼是位元組而不是「找幾個關鍵字」:
|
||||
// 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線,
|
||||
// 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事:
|
||||
// repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。
|
||||
//
|
||||
// 誠實的 trade-off(mindset §7,不假裝完美):
|
||||
// ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**,
|
||||
// 因為那時線上確實不是當代的。它會吵,但吵的是真的。
|
||||
// ② 若哪天 CF 邊緣開始改寫 HTML(Rocket Loader 之類),會出現假 ❌。
|
||||
// 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致),
|
||||
// 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查——
|
||||
// **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 納入世代指紋的資產:file=public/ 底下的路徑,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/HOME:public/ 裡那兩行只是本機 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 實撞:原版直接對全文比對「登記新庫」,而 66f1b59(08-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');
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
* 兩層,缺一不可:
|
||||
* ① 組態層:apiBase/profile 的 views/home = deploy.targets.json 宣告值
|
||||
* ② 世代層:線上資產的位元組指紋 = repo public/ 的指紋
|
||||
*
|
||||
* 為什麼要第二層(2026-08-08,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
|
||||
* 你要確定不可再犯」):當天實測三個對外網址,第一層**三項全過**,
|
||||
* 而它們跑的是 07-22 那一代的 portal(82,911 bytes、金色 serif 舊品牌),
|
||||
* repo 是 343,969 bytes 的新品牌世代。
|
||||
* ⇒ **組態可以完全正確,同時展示一套早就被淘汰的介面,而機械檢查一片綠。**
|
||||
* 第二層就是為了讓這個狀態不可能無聲存在。
|
||||
*
|
||||
* 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.js/VIEWS/HOME
|
||||
* 與世代指紋都是**純文字資產比對**,抓原始碼比對是這幾項的正確驗法;
|
||||
* 「頁面真的能用」另外走瀏覽器實載。
|
||||
* 🔴 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 當代。');
|
||||
}
|
||||
@@ -348,7 +348,15 @@ export class GraphExecutor {
|
||||
|
||||
// BUILD-006:將節點 output 存入 KV(key = {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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 認證儲存(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 查一次」的指標。
|
||||
* 自足是重點——只要還要回頭查一次,就又被綁回去了。
|
||||
* - 不開新 D1(P9:leo 2026-08-07「你建一顆新的 D1,以後就會偷偷溜去那裡建表」)。
|
||||
* - 不牴觸 D38「KBDB 三張核心表永不加新的」:本檔是把東西**搬出去**,KBDB 表數不增不減。
|
||||
*
|
||||
* 容量(2026-08-10 查官方 developers.cloudflare.com/workers/platform/limits/,不是憑記憶):
|
||||
* - 每個變數(secret + text 合計)上限 **5 KB**
|
||||
* - 每顆 worker 變數數量上限 **64(Free)/ 128(Paid)**,與 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 overlay(AUTH_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;
|
||||
return readAuthStoreFromEnv(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只讀 `env` 那一版(**跳過 overlay**)。
|
||||
*
|
||||
* 為什麼要分出這一支(#66 修補的一半):read-modify-write 時,overlay 與 env 兩份都可能
|
||||
* 各自「有對方沒有的帳號」——overlay 可能來自加速器(別台 isolate 剛寫的),
|
||||
* env 可能是**比加速器更新**的一版(加速器過期、或這顆 isolate 已經吃到新版本)。
|
||||
* 只採信其中一份就會把另一份獨有的帳號寫掉,而 secret 是唯一真相源 ⇒ **永久消失**。
|
||||
*/
|
||||
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
|
||||
*
|
||||
* 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事:
|
||||
* - 「查不到這個帳號」= 帳號真的被刪了 → 該擋(401)
|
||||
* - 「查不到這個帳號」= secret 新版本還沒鋪到這顆 isolate → **不該擋,更不該刪 session**
|
||||
* 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`,它存在就代表「最近有人動過認證儲存」。
|
||||
* 讀不到(KV 掛了/沒設)⇒ 回 false,退回舊行為,不會比現在更糟。
|
||||
*/
|
||||
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
|
||||
try {
|
||||
return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 兩份 store 取聯集:同一個 id 以 `updated_at` 新者為準;只在一邊出現的一律保留。 */
|
||||
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
|
||||
const byId = new Map<string, AuthUserRecord>();
|
||||
for (const u of [...a.users, ...b.users]) {
|
||||
const prev = byId.get(u.id);
|
||||
if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
|
||||
}
|
||||
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。
|
||||
*
|
||||
* 🔴 #66:**改之前先把手上這份補齊**。舊版直接 `readAuthStore(env)` 當底稿,而 `writeAuthStore`
|
||||
* 會把整份重切分片並刪掉多出來的舊分片 ⇒ 若底稿是「某個帳號被建立之前」的版本,
|
||||
* 那個帳號會在這次寫入中**被抹掉,且再也回不來**(secret 是唯一真相源,沒有第二份可還原)。
|
||||
* 這正是「改一次密碼=有人被鎖在門外」的另一半病因。
|
||||
*
|
||||
* 補法:先問一次加速器,再把 env 版與 overlay 版**取聯集**當底稿——
|
||||
* 兩邊獨有的帳號都留下來;刪除仍然有效,因為 `fn()` 是在聯集**之後**才跑。
|
||||
*/
|
||||
export async function mutateAuthStore(
|
||||
env: Bindings,
|
||||
fn: (data: AuthStoreData) => void | Promise<void>,
|
||||
): Promise<AuthStoreData> {
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -98,6 +98,20 @@ export function randomHex(bytes: number): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV key(D62)。
|
||||
*
|
||||
* 為什麼不直接拿 token 當 key:連結裡的 token 是**能改密碼的憑據**,直接當 key 等於
|
||||
* 把它明碼存在 KV 裡;改存雜湊後,看得到 KV 的人也拿不到可用的連結。
|
||||
* (這裡只做 digest——不是 rule 2.2 禁的 `crypto.subtle.decrypt` / RSASSA 簽章。)
|
||||
*/
|
||||
export 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('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
|
||||
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
||||
|
||||
@@ -22,6 +22,19 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
// D61(ADR 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 token(localStorage 存這個,不存密碼)。
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,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 設定,寫入路徑未就緒(見 ' +
|
||||
@@ -122,7 +122,7 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||
* 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 設定,刪除路徑未就緒');
|
||||
}
|
||||
|
||||
@@ -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,23 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
// 只是沒有人把它吐出來)。修=誠實回報本實例的 bundle 版本。
|
||||
// 未注入(本地 dev/很舊的實例)就省略該欄——daemon 對空字串仍判 stale,
|
||||
// 那是**正確的**(真的是老實例,該更新)。
|
||||
// D61(ADR 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),
|
||||
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||
// 版本無關(同一個 cypher 版本,有的實例有這個 var、有的沒有)。純比版本號的話,
|
||||
// 已經在最新版的實例(如 leo 自己那台)永遠不會因為「按更新」而重推,這個 var
|
||||
// 就永遠補不進去。只回布林(有沒有設,不回值本身)——不洩漏郵差網址。
|
||||
mail_relay_configured: Boolean(String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()),
|
||||
});
|
||||
});
|
||||
|
||||
healthRouter.get('/', (c) =>
|
||||
|
||||
@@ -119,6 +119,27 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// PATCH /kbdb/records/:recordId — 翻某筆 record 的 slot 值({ values:{slot:content} })。
|
||||
// 補上基本盤既有能力(kbdb/src/routes/records.ts 的 PATCH /records/:recordId,mira-dissolve T2.1)
|
||||
// 缺的對外通道——2026-08-11 leo 三元組 library 補標核實:base 早有這個端點,但這條 proxy
|
||||
// 之前只轉發 GET/POST,插件/工作流打不到,補標三元組只能繞去改表(違 D38)。單純轉發,無業務邏輯。
|
||||
// by-id 沿用既有慣例(require-key,不額外做 owner 比對——與本檔 GET .../:recordId、
|
||||
// PATCH /kbdb/entries/:id 同款)。
|
||||
kbdbProxyRouter.patch('/kbdb/records/:recordId', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.values !== 'object' || body.values === null) {
|
||||
return c.json({ error: 'values 必填({slot名: 內容})' }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ values: body.values }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
|
||||
|
||||
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -584,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 檔下載。
|
||||
//
|
||||
// 🔴 t213(2026-08-08,InkStoneCo 總管交辦):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
@@ -6,6 +6,7 @@
|
||||
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度)
|
||||
* 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,19 @@ export type Bindings = {
|
||||
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
|
||||
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
|
||||
PORTAL_SESSION_TTL?: string;
|
||||
// Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它;
|
||||
// D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。
|
||||
UI_ORIGINS?: string;
|
||||
// ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)───────────────────────────
|
||||
// 中央代寄服務的 base URL(landing worker)。**用戶自己的實例沒有寄信能力**——安裝器
|
||||
// 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize,
|
||||
// **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。
|
||||
// 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。
|
||||
// ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。
|
||||
PORTAL_MAIL_RELAY_BASE?: string;
|
||||
// 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端
|
||||
// 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。
|
||||
PORTAL_MAIL_RELAY_KEY?: string;
|
||||
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
|
||||
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
|
||||
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,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:false;login 回「讀不到認證資料」(不是密碼錯)。
|
||||
* 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:true,credentials_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);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,260 @@
|
||||
/**
|
||||
* credentials 路由測試 —— 🔴 待重寫(D38 圍牆修復後)
|
||||
* credentials 路由測試(D38 圍牆修復後補寫,2026-08-08)
|
||||
*
|
||||
* 【為什麼是紅燈而不是空檔】
|
||||
* 2026-08-07 D38 圍牆修復把 credential 目錄從「獨立 credentials 表 + 原生 SQL」
|
||||
* 改成「KBDB entries(entry_type='credential')+ HTTP API」。原本 111 行的測試
|
||||
* 測的是舊的 SQL 實作,全部不再適用。
|
||||
* 前身是刻意留紅的 placeholder(見 git history):2026-08-07 D38 把 credential 目錄從
|
||||
* 「獨立 credentials 表 + 原生 SQL」改成「KBDB entries(entry_type='credential')+
|
||||
* HTTP API」,舊測試全部作廢,agent 中途被中斷沒補上,故意留一個會失敗的測試佔位、
|
||||
* 避免「no tests」被誤讀成「通過」。本檔依 placeholder 頭部列的五項補齊。
|
||||
*
|
||||
* 施工的 agent 中途被中斷,留下一行 `// placeholder — see edit below` ——
|
||||
* 那個 "edit below" 從來沒發生。vitest 對這種檔案回報 `Tests: no tests`,
|
||||
* **很容易被讀成「沒失敗=通過」**,正是 CP 記過的
|
||||
* 「這條 route 曾整條消失過沒人發現」同型。
|
||||
*
|
||||
* ⇒ 這裡刻意留一個**會失敗**的測試:空檔會被誤認為綠,紅燈不會。
|
||||
*
|
||||
* 【重寫時要涵蓋什麼】(照新實作 routes/credentials.ts)
|
||||
* 1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離)
|
||||
* 2. 讀取查得回 secret_ref,且查不到別的租戶的
|
||||
* 3. 刪除是真的刪(不是 deprecated)
|
||||
* 4. **零原生 SQL**:整支檔案不得出現 .prepare/.exec/.batch
|
||||
* 5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變
|
||||
* 測試手法比照姊妹模組 execution-logger.test.ts:`vi.stubGlobal('fetch', ...)` 攔截,
|
||||
* 但這裡的攔截器是**有狀態的假 KBDB**(in-memory entries store),因為 credentials.ts
|
||||
* 一次操作常涉及多輪 HTTP 呼叫(find → upsert / find → delete),單次回應的 mock 測不出
|
||||
* 「查得到剛寫的」「刪掉後真的查不到」這類語意,需要一個會記狀態的假後端。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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';
|
||||
|
||||
describe('credentials 路由(D38 改走 KBDB API 後)', () => {
|
||||
it('🔴 測試待重寫 —— 見本檔頭部清單(刻意紅燈,別刪掉改成空檔)', () => {
|
||||
expect.fail(
|
||||
'D38 圍牆修復後 credential 改走 KBDB entries + HTTP API,' +
|
||||
'舊的 SQL 版測試已作廢、新測試尚未寫。' +
|
||||
'要補的五項見本檔頭部註解。',
|
||||
);
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
// ── 有狀態假 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;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function app() {
|
||||
const a = new Hono<{ Bindings: Bindings }>();
|
||||
a.route('/', credentialsRouter);
|
||||
return a;
|
||||
}
|
||||
|
||||
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 };
|
||||
expect(body.success).toBe(true);
|
||||
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('兩個不同 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('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);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,3 +250,84 @@ describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () =>
|
||||
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 只用到 put;get 給 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_SUCCESS+FOREACH 工作流(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();
|
||||
// 修法前這裡是 8(list_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.a(BUILD-006 讀路徑不變)
|
||||
const bCtx = seen[seen.length - 1];
|
||||
expect((bCtx._kv_outputs as Record<string, unknown>)?.a).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* PATCH /kbdb/records/:recordId proxy 測試(2026-08-11,三元組 library 補標需求核實)
|
||||
*
|
||||
* 背景:基本盤 kbdb/src/routes/records.ts 早有 PATCH /records/:recordId(mira-dissolve T2.1,
|
||||
* updateRecord 已支援「補一個 record 原本沒有的 slot 值」的 idempotent grow)。但這條 cypher
|
||||
* proxy(kbdb-proxy.ts)之前只轉發 GET/POST /kbdb/records,沒開 PATCH——外部(工作流/CLI/
|
||||
* 任何走 X-Arcrun-API-Key 的呼叫者)打不到,等於基本盤能力在,通道沒開。
|
||||
*
|
||||
* 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-map-proxy.test.ts 慣例):
|
||||
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
|
||||
* 2. body 沒有 values → 400,不轉發
|
||||
* 3. 轉發:PATCH /kbdb/records/:id → base PATCH /records/:id,body 只帶 { values }
|
||||
* 4. base 404(record 不存在)→ 原樣透傳,不假裝成功
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——測試絕不外連。
|
||||
*/
|
||||
import { SELF, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 租戶閘', () => {
|
||||
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ values: { library: 'kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 參數驗證', () => {
|
||||
it('body 沒有 values → 400,不轉發', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 轉發', () => {
|
||||
it('轉發 base PATCH /records/:id,body 只帶 values(不夾帶其他欄位)', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records/rec_1',
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
record: { record_id: 'rec_1', template_id: 'tpl-triplet', values: { library: 'gitea:Leo/kb' } },
|
||||
});
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.record.values.library).toBe('gitea:Leo/kb');
|
||||
});
|
||||
|
||||
it('base 404(record 不存在)→ 原樣透傳,不假裝成功', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/records/nope', method: 'PATCH' })
|
||||
.reply(404, { success: false, error: 'not found' });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/nope', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ values: { library: 'kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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 子 namespace;PATCH graph_source boolean。
|
||||
* 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、
|
||||
@@ -14,12 +15,21 @@
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 fixture(rec_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
|
||||
@@ -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-password(arcrun-rag#25:admin 忘記 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(每帳號可查庫)', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆蓋(=tasks.md P2 測試項):
|
||||
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
||||
* 驗證對錯、壞格式誠實 false、600k 舊 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 次失敗 → 429(KV TTL 計數)
|
||||
@@ -12,16 +12,28 @@
|
||||
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
||||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55)補的覆蓋(原本沒有,這次變更的重點):
|
||||
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
|
||||
* 9. 舊實例相容:帳號只存在 KBDB(舊家)時仍登得進去,登入成功後自動搬進認證儲存
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.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';
|
||||
@@ -44,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) */
|
||||
@@ -134,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#55,leo 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', () => {
|
||||
@@ -142,28 +209,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('console session OK → 建第一個 admin:record + 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',
|
||||
@@ -174,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 () => {
|
||||
@@ -210,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());
|
||||
@@ -227,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
|
||||
// D61:promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mock,disableNetConnect 之下
|
||||
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
|
||||
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
|
||||
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
|
||||
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
|
||||
});
|
||||
|
||||
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
|
||||
@@ -464,3 +528,157 @@ describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,e
|
||||
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、帶一份缺寫入路徑的 env(health.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 代表沒有意外多打的請求)。
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ D62 + arcrun-rag#66(2026-08-10)═══════════════
|
||||
//
|
||||
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
|
||||
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
|
||||
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
|
||||
|
||||
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
|
||||
const D62_EMAIL = 'd62-reset@example.com';
|
||||
|
||||
it('/portal/password/change 帶 reset_token:**不需要登入、不需要現有密碼**,且票用完即失效', async () => {
|
||||
// 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張),
|
||||
// 存的是 token 的 sha256——KV 裡看不到可用的連結。
|
||||
const { sha256Hex } = await import('../src/lib/portal-auth');
|
||||
const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||
const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
|
||||
await env.SESSIONS_KV.put(
|
||||
`portal_pwreset:${await sha256Hex(token)}`,
|
||||
JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// 票有效時,先「看一眼」不會消耗它
|
||||
const peek = await json('GET', `/portal/password/reset?token=${token}`);
|
||||
expect(peek.status).toBe(200);
|
||||
expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);
|
||||
|
||||
// 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回)
|
||||
const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
|
||||
expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的
|
||||
// 關鍵斷言:同一條連結**不能再用第二次**
|
||||
const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
|
||||
expect(again.status).toBe(400);
|
||||
expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
|
||||
// 而且票在 KV 裡真的沒了
|
||||
expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => {
|
||||
for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
|
||||
const res = await json('GET', `/portal/password/reset?token=${t}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json() as { valid: boolean }).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => {
|
||||
const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => {
|
||||
const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => {
|
||||
const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
|
||||
const TOKEN_A = 'sess-token-66-propagating';
|
||||
const TOKEN_B = 'sess-token-66-really-gone';
|
||||
const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;
|
||||
|
||||
it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => {
|
||||
await seedPortalSession(TOKEN_A, MISSING);
|
||||
// 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗
|
||||
await env.SESSIONS_KV.put(
|
||||
'auth_store_recent',
|
||||
JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
|
||||
);
|
||||
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
|
||||
// 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來
|
||||
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => {
|
||||
await seedPortalSession(TOKEN_B, MISSING);
|
||||
await env.SESSIONS_KV.delete('auth_store_recent');
|
||||
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
|
||||
expect(res.status).toBe(401);
|
||||
// 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收
|
||||
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => {
|
||||
await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -711,3 +711,234 @@ describe('dedupeSourcesByPage(t129 出處去重)', () => {
|
||||
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 同一套即時查),
|
||||
// 不再靠 /map(library_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/diagnostics(t213,daemon 免帳密版檢修孔,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/diagnostics(t213 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 本身,不是 leo(t189:不假設 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('截圖');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,3 +49,11 @@ KBDB_BASE_URL = "https://kbdb.test"
|
||||
CONSOLE_TENANT = "leo"
|
||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||
KBDB_GRAPH_URL = "https://graph.test"
|
||||
# D61(ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
|
||||
# Scripts secrets 管理 API(https://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"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// credential-legacy-migration.ts — 「新讀取端上線、舊資料還沒搬完」的自癒補丁
|
||||
// (D38 圍牆修復收尾,總管交辦,2026-08-08)。
|
||||
//
|
||||
// ── 為什麼這支檔案存在 ────────────────────────────────────────────────────
|
||||
// 7ba7855(D38 圍牆修復)把 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_id,NOT EXISTS 防重複),有就是零成本的一次 sqlite_master 檢查。
|
||||
//
|
||||
// 呼叫時機只有一個:cypher-executor 的 credentials.ts 熱路徑(getCredentialDirectory /
|
||||
// findCredentialEntry)本來就會在**每次 workflow 執行**打一次 GET /entries?entry_type=
|
||||
// credential&owner_id=X(60 秒快取未命中時)。只要 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
|
||||
* 搬進 entries(entry_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);
|
||||
}
|
||||
@@ -257,6 +257,209 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
|
||||
};
|
||||
}
|
||||
|
||||
// ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ─────────────────────────
|
||||
//
|
||||
// 病徵(2026-08-10 總管在 leo21c 上實測,有對照組):
|
||||
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||
// ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。
|
||||
// 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一
|
||||
// `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。
|
||||
//
|
||||
// 為什麼這是**結構性**故障、不是準度問題:
|
||||
// **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰
|
||||
// ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」——
|
||||
// 接上了也還是瞎的,因為接上之後查什麼都沒有。
|
||||
// (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。)
|
||||
//
|
||||
// 這件 47c6aae(2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事;
|
||||
// 本次只動**查詢端**,buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。
|
||||
//
|
||||
// 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。
|
||||
// · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**,
|
||||
// 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。
|
||||
// · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛
|
||||
// ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2)
|
||||
// ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。
|
||||
// · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一,
|
||||
// `local arcrun` 那 5 筆不會被稀釋掉。
|
||||
// · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套):
|
||||
// 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。
|
||||
//
|
||||
// 回歸保證(不是靠測試碰運氣,是靠構造):
|
||||
// · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern),
|
||||
// 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。
|
||||
// · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞),
|
||||
// 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。
|
||||
//
|
||||
// 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段
|
||||
// +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、
|
||||
// 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。
|
||||
// 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到
|
||||
// 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。
|
||||
const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數)
|
||||
const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號
|
||||
// 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照):
|
||||
// 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾);
|
||||
// 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響
|
||||
// ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」,
|
||||
// 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。
|
||||
const KEYWORD_RELATIVE_CUT = 0.6;
|
||||
|
||||
// CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。**
|
||||
//
|
||||
// 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果
|
||||
// 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。
|
||||
// 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都
|
||||
// 同時是虛詞與實詞的組成部分)。
|
||||
// ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。
|
||||
// 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。
|
||||
//
|
||||
// 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額),
|
||||
// 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡
|
||||
// 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。
|
||||
const CJK_STOP_CHARS = new Set(
|
||||
'的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''),
|
||||
);
|
||||
|
||||
// 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。
|
||||
const ASCII_STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were',
|
||||
'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with',
|
||||
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would',
|
||||
'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they',
|
||||
]);
|
||||
|
||||
const isCjkChar = (ch: string): boolean => /[-ヿ㐀-䶿一-鿿豈-]/.test(ch);
|
||||
const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch);
|
||||
|
||||
/** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */
|
||||
export function splitRuns(q: string): { text: string; cjk: boolean }[] {
|
||||
const runs: { text: string; cjk: boolean }[] = [];
|
||||
let cur = ''; let curCjk = false;
|
||||
const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; };
|
||||
for (const ch of q) {
|
||||
const cjk = isCjkChar(ch);
|
||||
if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔
|
||||
if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意)
|
||||
cur += ch; curCjk = cjk;
|
||||
}
|
||||
flush();
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */
|
||||
function contentBigrams(run: string): string[] {
|
||||
const chars = [...run];
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i + 1 < chars.length; i++) {
|
||||
if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue;
|
||||
out.push(chars[i] + chars[i + 1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SearchTerm { term: string; weight: number }
|
||||
|
||||
/**
|
||||
* 把查詢句拆成帶份量的查詢詞(純函式,單測用 export)。
|
||||
* 份量=字數(上限 MAX_TERM_WEIGHT);愈長愈專指 ⇒ 排序時壓過泛詞。
|
||||
* 依份量由大到小截斷到 MAX_SEARCH_TERMS,確保被砍掉的是最泛的那幾個。
|
||||
*/
|
||||
export function tokenizeQuery(q: string): SearchTerm[] {
|
||||
const found = new Map<string, number>();
|
||||
const add = (t: string, w: number) => {
|
||||
for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束
|
||||
if (!piece) continue;
|
||||
found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT)));
|
||||
}
|
||||
};
|
||||
|
||||
const runs = splitRuns(q);
|
||||
// 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同:
|
||||
// · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。
|
||||
// · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。
|
||||
// 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有
|
||||
// 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的
|
||||
// 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。
|
||||
const isQuestion = runs.length > 1;
|
||||
|
||||
for (const run of runs) {
|
||||
if (!run.cjk) {
|
||||
const w = run.text.toLowerCase();
|
||||
if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length);
|
||||
continue;
|
||||
}
|
||||
const chars = [...run.text];
|
||||
// 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。
|
||||
if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length);
|
||||
// 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。
|
||||
// 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。
|
||||
if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2);
|
||||
}
|
||||
|
||||
return [...found.entries()]
|
||||
.map(([term, weight]) => ({ term, weight }))
|
||||
.sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
|
||||
.slice(0, MAX_SEARCH_TERMS);
|
||||
}
|
||||
|
||||
export interface SearchScorePlan {
|
||||
/** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */
|
||||
scoreExpr: string;
|
||||
scoreParams: string[];
|
||||
terms: SearchTerm[];
|
||||
/** true = 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */
|
||||
legacyShape: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生「覆蓋率分數」的 SQL 表達式(純函式,單測用 export)。
|
||||
*
|
||||
* 整句相鄰另給一份重賞(=所有詞份量總和),確保**舊行為排最前**:
|
||||
* 含整句的內容分數必然高於只含零散詞的,`local arcrun` 那 5 筆永遠在最上面。
|
||||
*/
|
||||
export function buildSearchScore(q: string): SearchScorePlan {
|
||||
const trimmed = q.trim();
|
||||
const terms = tokenizeQuery(trimmed);
|
||||
|
||||
// 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。
|
||||
if (terms.length === 0) {
|
||||
const m = buildContentLike(trimmed);
|
||||
return {
|
||||
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
|
||||
scoreParams: m.params,
|
||||
terms: [],
|
||||
legacyShape: true,
|
||||
};
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const params: string[] = [];
|
||||
for (const { term, weight } of terms) {
|
||||
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
||||
params.push(`%${term}%`);
|
||||
}
|
||||
|
||||
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
|
||||
params.push(`%${trimmed}%`);
|
||||
}
|
||||
|
||||
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||
}
|
||||
|
||||
/** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */
|
||||
export function applyRelativeCut<T extends { match_score: number }>(rows: T[]): T[] {
|
||||
if (rows.length <= 1) return rows;
|
||||
const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT;
|
||||
return rows.filter((r) => r.match_score >= cut);
|
||||
}
|
||||
|
||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §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'),但單組佔位符、不用重複綁參數。
|
||||
@@ -309,6 +512,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||
export async function searchEntries(
|
||||
db: D1Database,
|
||||
q: string,
|
||||
@@ -318,18 +524,29 @@ export async function searchEntries(
|
||||
library?: string[],
|
||||
source?: string,
|
||||
includeDeprecated = false,
|
||||
): Promise<Entry[]> {
|
||||
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
||||
const conds = [...m.conds];
|
||||
const params: unknown[] = [...m.params];
|
||||
): Promise<(Entry & { match_score: number })[]> {
|
||||
const plan = buildSearchScore(q); // 斷詞+算分;單詞查詢=與舊版逐字相同的單一 LIKE
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [...plan.scoreParams];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
||||
// 分數在子查詢算、外層才篩 match_score > 0:SQLite 不保證能在 WHERE 引用 SELECT 別名,
|
||||
// 用子查詢就不必把整組 LIKE 參數再綁一次(參數重複=將來改一邊漏一邊的漂移來源)。
|
||||
// 其他 filter 留在**內層**,讓 owner/library/deprecated 先篩掉,算分只發生在該算的列上。
|
||||
const inner = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const res = await db
|
||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
||||
.prepare(
|
||||
`SELECT * FROM (
|
||||
SELECT *, (${plan.scoreExpr}) AS match_score FROM entries ${inner}
|
||||
) WHERE match_score > 0
|
||||
ORDER BY match_score DESC, updated_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(...params, Math.min(limit, 200))
|
||||
.all<Entry>();
|
||||
return res.results ?? [];
|
||||
.all<Entry & { match_score: number }>();
|
||||
// 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。
|
||||
return applyRelativeCut(res.results ?? []);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Execution log — workflow 執行紀錄(KV 額度事故修復,總管交辦,2026-08-07)
|
||||
// Execution log — workflow 執行紀錄(KV 額度事故修復,總管交辦,2026-08-07;
|
||||
// 保留期可設定=P7,2026-08-09,leo 08-08 confirm:`system-dev/docs/3-specs/pending-changes.md` P7)
|
||||
//
|
||||
// SDD:無專屬 SDD(事故修復任務)。root cause 見 kbdb/migrations/0004_execution_log_template.sql
|
||||
// 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)。
|
||||
//
|
||||
@@ -21,9 +23,19 @@
|
||||
// 用量超過 80% → 降成只記失敗;超過 100% → 完全停止記錄,但呼叫端(cypher-executor)的
|
||||
// workflow 執行永遠照跑——寫入永不 throw(recordExecutionLog 本身 catch 見呼叫端 route)。
|
||||
//
|
||||
// 隔離(不污染知識搜尋):entry_type='execution_log'/'execution_log_usage' 是內部型別,與既有
|
||||
// 'value'/'workflow' 同層級。cypher-executor 端(portal-data.ts INTERNAL_ENTRY_TYPES)比照這兩者
|
||||
// 一併排除;本模組也從不設 metadata_json.embed=true,故永不進 Vectorize 語意搜尋索引。
|
||||
// 隔離(不污染知識搜尋):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 Cron(wrangler.toml 的 [triggers] 段落是受保護檔案、AI 不可編輯——
|
||||
// 見 InkStoneCo 頂層 P9 段 L1 權限閘),改「搭便車」:cypher-executor 既有的每分鐘
|
||||
// scheduled tick(cron workflow 用,見 cypher-executor/src/scheduled.ts)本來就會醒,
|
||||
// 在那支既有 handler 裡加一段「一天一次」呼叫本模組的 cleanupExpiredLogs 端點即可,
|
||||
// 不需要新的排程基礎設施、不違反「禁輪詢」(那條鐵律管的是主動去戳外部系統要狀態,
|
||||
// 這裡是既有 tick 順手打理自己的表,且頻率仍是「一天一次」而非高頻輪詢)。
|
||||
import type { Bindings } from '../types';
|
||||
import { createEntry, listEntries } from './entry-crud';
|
||||
|
||||
@@ -199,3 +211,193 @@ export async function latestExecutionLog(
|
||||
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 };
|
||||
}
|
||||
|
||||
// ── 測試專用 helpers(P7,2026-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;
|
||||
}
|
||||
|
||||
@@ -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() || '';
|
||||
// narrative:caller 有給才覆蓋;沒給 → 沿用上一版現有 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,139 @@ 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 的即時聚合」(那樣會丟失 narrative/relation_profile/bridges
|
||||
// 這些 summary 本體,narrative 沒辦法從純聚合 SQL 現算出來)。
|
||||
//
|
||||
// 解法:不再依賴任何外部呼叫者記得呼 /map/recompute,改成讀端(GET /map、GET /map/:library)
|
||||
// 自己核對即時三元組數,落差就地呼叫既有的 recomputeLibraryMap 補算——聚合 SQL 沒有第二套,
|
||||
// 只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。這同時解掉三件事:
|
||||
// 一、全租戶自動 backfill(不需要用戶或任何人做任何事,第一次讀就會補齊)
|
||||
// 二、跟得上資料(下一筆 ingest 進來,觸發計數變化,下一次讀就重算,不是靜態快照)
|
||||
// 三、不依賴 ingest workflow 那端的接鏈(那條線跨 repo/跨租戶天生脆弱,已證實三週沒人接上)
|
||||
// narrative/relation_profile/bridges 這些「摘要」欄位仍走 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-stats(t142)同一套分組語意,兩處數字對得上。
|
||||
//
|
||||
// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status,
|
||||
// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不
|
||||
// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上,
|
||||
// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map
|
||||
// record(superseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede
|
||||
// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作,
|
||||
// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出
|
||||
// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。
|
||||
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( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||||
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||
FROM (
|
||||
SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
||||
GROUP BY ev.record_id
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all<{ 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.library,t52 慣例)或
|
||||
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
|
||||
// 「這庫是空的」(回 200+triplet_count:0)vs「查無此庫」(回 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 {
|
||||
|
||||
+148
-2
@@ -283,6 +283,127 @@ 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; // 給人看的一句話結論,供檢修孔診斷檔直接引用
|
||||
// 🔴 2026-08-11 新增(Arcrun#85 D70 leo21c 全盲事件):分辨**兩種處方相反**的故障。
|
||||
// null=沒測到這一層(模組未開/沒帶 owner_id/或帶 filter 就通過了,不必再探)
|
||||
// true =不帶 filter 搜得到,帶 filter 搜不到 ⇒ **Vectorize metadata filter 失效**
|
||||
// false=連不帶 filter 都搜不到 ⇒ 向量根本不在現役 index 裡
|
||||
//
|
||||
// 為什麼非分不可:舊版兩種病都只回一句「需要重新 reindex」。但 metadata index
|
||||
// **不存在**時,Vectorize 不會索引該欄位,reindex 重推幾萬筆也不會生效
|
||||
// ——leo21c 就是照著這個處方修不好。錯的處方比沒有處方更貴。
|
||||
filter_blind: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed 自我檢查(檢修孔用,2026-08-07 leo 直接指令:「先把檢修孔做出來發版」)。
|
||||
*
|
||||
* 為什麼需要這個,不只是 backfillStatus 的 pending/embedded 計數:08-05 撞過的真實故障
|
||||
* 是「is_embedded=1(已嵌入)但語義搜尋還是搜不到」——metadata 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, filter_blind: 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, filter_blind: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
|
||||
}
|
||||
const sample = (row.content ?? '').trim().slice(0, 200);
|
||||
if (!sample) {
|
||||
return { enabled: true, tested: false, passed: null, filter_blind: null, note: '取樣卡片內容為空,跳過自我檢查' };
|
||||
}
|
||||
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
|
||||
// 第一段=**使用者真正走的那條路**(帶 owner_id filter),先測它;通了就不必多花第二次查詢。
|
||||
const probe = async (o: { owner_id?: string }) =>
|
||||
semanticSearch(env, sample, { ...o, topK: 10, min_score: 0 });
|
||||
let hits: SemanticHit[] | null;
|
||||
try {
|
||||
hits = await probe({ owner_id: opts.owner_id });
|
||||
} catch (e) {
|
||||
if (e instanceof EmbedQueryFailedError) {
|
||||
// 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。
|
||||
return { enabled: true, tested: false, passed: null, filter_blind: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (hits === null) {
|
||||
return { enabled: false, tested: false, passed: null, filter_blind: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
|
||||
}
|
||||
const passed = hits.some((h) => h.id === row.id);
|
||||
if (passed) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: true, filter_blind: null,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的',
|
||||
};
|
||||
}
|
||||
|
||||
// ── 沒搜到自己:第二段,判斷是「向量不在 index」還是「filter 失效」───────────────
|
||||
// 🔴 2026-08-11(Arcrun#85 D70,leo21c 實撞):這兩種病的處方**相反**,不能都叫人 reindex。
|
||||
// 自己查自己相似度接近 1.0,所以「搜不到自己」絕不是分數問題(MIN_SCORE_ABS_FLOOR 也被
|
||||
// min_score:0 關掉了)。剩下兩種可能,用「拿掉 filter 再查一次」一刀切開:
|
||||
// 拿掉 filter 就找得到 → 向量在 index 裡,是 **metadata filter 死的**
|
||||
// (metadata index 沒建,或向量早於該 index 建立時間)
|
||||
// ⇒ 修法是**先建 metadata index,再 reindex**;只 reindex 沒用
|
||||
// 拿掉 filter 還是找不到 → 向量真的不在現役 index(常見:換 index 世代後沒重嵌)
|
||||
// ⇒ 修法才是 reindex
|
||||
// 只有在「有帶 owner_id」時第二段才有意義(沒帶 filter 的查詢,兩段是同一件事)。
|
||||
if (!opts.owner_id) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: false,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0',
|
||||
};
|
||||
}
|
||||
let unfiltered: SemanticHit[] | null = null;
|
||||
try {
|
||||
unfiltered = await probe({});
|
||||
} catch (e) {
|
||||
if (!(e instanceof EmbedQueryFailedError)) throw e;
|
||||
// 第二段查詢自己壞了 → 不硬猜,誠實回「分不出是哪一種」。
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: null,
|
||||
note: `拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己;追查用的第二次查詢也失敗(${e.message}),無法判斷是索引沒收錄還是過濾條件失效`,
|
||||
};
|
||||
}
|
||||
const foundWithoutFilter = (unfiltered ?? []).some((h) => h.id === row.id);
|
||||
if (foundWithoutFilter) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: true,
|
||||
note:
|
||||
'拿一張已標記「已嵌入」的卡片自我查詢:**不帶歸屬條件搜得到、一帶上去就搜不到** ⇒ ' +
|
||||
'向量在索引裡,壞的是 Vectorize 的 metadata 過濾(該欄位的 metadata index 沒建,' +
|
||||
'或這些向量是在該 index 建立之前寫進去的)。所有真實查詢都會帶歸屬條件做租戶隔離,' +
|
||||
'所以語意搜尋等於全盲。修法有先後:**先**建 metadata index' +
|
||||
'(owner_id/entry_type/source/library),**再** POST /embed/backfill {"reindex":true}' +
|
||||
'——順序反了或只做 reindex 都不會生效。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: false,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,不論帶不帶歸屬條件都搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0',
|
||||
};
|
||||
}
|
||||
|
||||
export interface SemanticHit {
|
||||
id: string;
|
||||
score: number;
|
||||
@@ -292,6 +413,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')。模組未開 → 回 null(caller 降級 keyword + 告知缺能力)。
|
||||
* owner_id / source / entry_type 過濾走 Vectorize metadata filter(entry_type 已 index,見上 upsert metadata)。
|
||||
@@ -312,8 +449,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-09(leo 直令):向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」,
|
||||
// 向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實,混在一起就是說謊。
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+230
-15
@@ -13,10 +13,63 @@ import {
|
||||
searchEntries,
|
||||
isDeprecatedEntry,
|
||||
} from '../actions/entry-crud';
|
||||
import { embedEnabled, embedOnWrite, semanticSearch, relativeMinScore } 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:有 executionCtx(workerd)就 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(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 「這次零命中,是不是因為 Vectorize 的 metadata 過濾整個是死的?」
|
||||
*
|
||||
* 🔴 2026-08-11 立(Arcrun#85 D70,leo21c 實撞):那台實例的現役 index
|
||||
* `arcrun-kbdb-embed-m3` 上 **一個 metadata index 都沒有**(換代時漏建),於是
|
||||
* Vectorize 對 owner_id/source/entry_type/library 下任何 filter 都回 0 筆。
|
||||
* 而**每一條真實使用者路徑都會帶 owner_id 做租戶隔離**(portal、MCP、workflow 搜尋皆然)
|
||||
* ⇒ 語意搜尋 100% 全盲,但系統只會回「沒有找到符合的內容,換個說法再試試看」。
|
||||
*
|
||||
* 判法不靠猜、也不查 Cloudflare 設定(KBDB 這面牆內打不到那支 API):
|
||||
* **同一句查詢,把 metadata filter 全部拿掉再打一次**。
|
||||
* 有命中 → 向量在 index 裡,死的是 filter(回 true)
|
||||
* 仍零命中 → 就是這次查詢真的沒撞到東西(回 false,維持 no_match)
|
||||
*
|
||||
* 成本紀律:只在「已有嵌入資料卻零命中」這個**本來就已經降級**的分支才會被呼叫,
|
||||
* 正常有結果的查詢一次都不會多花。多的是一次 AI.run + 一次 Vectorize query。
|
||||
* 沒帶任何 filter 的查詢直接回 false(沒有 filter 可以怪,也不必多打一次)。
|
||||
* 探針自己出錯一律回 false——診斷絕不能把查詢本身弄壞(誠實限制,mindset §7)。
|
||||
*/
|
||||
async function filterIsBlind(
|
||||
env: Bindings,
|
||||
q: string,
|
||||
f: { owner_id?: string; source?: string; entry_type?: string; library?: string[] },
|
||||
): Promise<boolean> {
|
||||
const hasFilter = !!(f.owner_id || f.source || f.entry_type || (f.library && f.library.length > 0));
|
||||
if (!hasFilter) return false;
|
||||
try {
|
||||
// min_score:0 + 小 topK:只問「拿掉 filter 到底有沒有東西」,不問品質。
|
||||
const probe = await semanticSearch(env, q, { topK: 5, min_score: 0 });
|
||||
return (probe ?? []).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// library 多值參數(逗號分隔,portal-auth P1,design §3.3)。空值/全空白 → undefined(=不過濾,
|
||||
// 行為與未帶參數一字不變——向後相容硬驗收)。
|
||||
function parseLibraryParam(raw: string | undefined): string[] | undefined {
|
||||
@@ -87,9 +140,20 @@ entryRoutes.get('/library-stats', 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 由呼叫端既有的 fallback(cypher-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,
|
||||
@@ -103,7 +167,13 @@ entryRoutes.get('/', async (c) => {
|
||||
|
||||
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic
|
||||
// - mode=keyword(預設):D1 LIKE(base,永遠可用)。
|
||||
// - 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-09(leo 直令):語意搜尋是**一安裝就提供**的功能,模組不在=故障,
|
||||
// 文案照實說「壞了、是我們的問題、使用者不用做任何事」,禁止說成「還沒開通/未啟用」。
|
||||
// 降級回應帶 degraded_reason(module_off / embed_query_failed)供前端與診斷分流。
|
||||
// - entry_type:base 通用 filter(caller 傳任意 type,如 workflow;base 不寫死語意,workflow-discovery Q4)。
|
||||
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extract+NULL→general;
|
||||
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
|
||||
@@ -143,11 +213,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-09(leo 直令):語意搜尋壞掉時**照實說是故障**。
|
||||
// - 語意搜尋是一安裝就提供的功能。走到下面任一降級分支=這台實例壞了,
|
||||
// 不是「還沒開通」「未啟用」——禁止把 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,
|
||||
@@ -155,22 +255,43 @@ 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 binding(embedEnabled=false)。語意搜尋是安裝即提供的功能,缺 binding=部署層事故(常見:redeploy 沒帶 kbdb_embed 注入、或安裝時 Vectorize index 建立失敗被放行)。修法:確認 Vectorize index 存在後以 kbdb_embed:true 重部 kbdb。本次已降級關鍵字搜尋。',
|
||||
});
|
||||
}
|
||||
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
|
||||
// #67:entry 附 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 的正解一起帶走
|
||||
@@ -182,6 +303,85 @@ entryRoutes.get('/search', async (c) => {
|
||||
}
|
||||
// 補位後截斷回 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===0(Vectorize 端零命中,含 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' | 'filter_blind';
|
||||
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 if (await filterIsBlind(c.env, q, { owner_id, source, entry_type, library })) {
|
||||
// 🔴 2026-08-11(Arcrun#85 D70,leo21c 實撞,三小時才挖出來的那個病):
|
||||
// 「有 N 筆嵌入資料卻零命中」在這裡曾一律被歸成 no_match,回給使用者
|
||||
// 「換個說法再試試看」——但那台實例的真相是 **Vectorize 的 metadata index
|
||||
// 一個都沒建**(換 index 世代時漏了),所以**每一次**帶 owner_id 的語意查詢
|
||||
// 都回 0,換幾種說法都一樣。把系統故障說成使用者的問題,正是 leo 08-09
|
||||
// 直令禁止的那件事;而且它是靜默的——沒人會因為「搜不到」去查 Vectorize 設定。
|
||||
// 判法不靠猜:**同一句查詢拿掉 metadata filter 再打一次**,有命中就證明
|
||||
// 向量在索引裡、死的是 filter(見 filterIsBlind)。
|
||||
empty_reason = 'filter_blind';
|
||||
capability_hint =
|
||||
'語意搜尋目前故障——你的資料都在,是我們的索引設定壞了,所以每一次語意搜尋都會空手而回。' +
|
||||
'這不是你打的字有問題,換個說法也不會有用。請先用關鍵字搜尋,我們會修好它。';
|
||||
admin_hint =
|
||||
`owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料;帶 metadata filter 零命中,` +
|
||||
'但同一句查詢拿掉 filter 後有命中 ⇒ 向量在 index 裡,死的是 Vectorize metadata 過濾。' +
|
||||
'成因:該 index 上沒有對應的 metadata index(換 index 世代/改名時最常漏),' +
|
||||
'或既有向量早於 metadata index 的建立時間。修法有先後:**先**建 metadata index' +
|
||||
'(owner_id/entry_type/source/library,acr 的 ensureVectorizeMetadataIndexes 會冪等建),' +
|
||||
'**再** POST /embed/backfill {"reindex":true} 重推到 remaining=0。只做 reindex 不會生效。';
|
||||
} 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' });
|
||||
}
|
||||
|
||||
@@ -235,11 +435,26 @@ entryRoutes.patch('/:id', async (c) => {
|
||||
});
|
||||
|
||||
// DELETE /entries/:id
|
||||
//
|
||||
// 🔴 2026-08-10(arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來):
|
||||
// 舊版把向量刪除包成 fire-and-forget(`waitUntil(...).catch(()=>{})`)——失敗被靜默吞掉,
|
||||
// 呼叫端(rag_takedown_direct workflow/未來的 portal 刪除 UI)永遠不知道向量沒清乾淨,
|
||||
// 使用者看到「刪除成功」,但語意搜尋可能還留著殘影,直到下次搜尋命中它才被自癒清掉
|
||||
// (search 路徑的 orphan 清理是事後補救,不是保證)。
|
||||
// 改法:與同檔 `/entries/deprecate-by-library`(見上)同款——**同步 await 再回應**,
|
||||
// 誠實回報 `vector_deleted`(true=清了/false=清失敗,D1 仍照刪/null=模組未開,不適用)。
|
||||
// D1 刪除永遠執行到底(entry 本體一定會消失),差別只在向量那一步呼叫端看不看得見失敗。
|
||||
entryRoutes.delete('/:id', async (c) => {
|
||||
// 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。
|
||||
const id = c.req.param('id');
|
||||
let vector_deleted: boolean | null = null; // 模組未開=不適用,維持 null 誠實表達「這件事沒發生過」
|
||||
if (embedEnabled(c.env)) {
|
||||
c.executionCtx.waitUntil(c.env.VECTORIZE!.deleteByIds([c.req.param('id')]).then(() => {}).catch(() => {}));
|
||||
try {
|
||||
await c.env.VECTORIZE!.deleteByIds([id]);
|
||||
vector_deleted = true;
|
||||
} catch {
|
||||
vector_deleted = false; // 誠實回 false,不假裝清乾淨了;D1 本體仍照刪,不因向量失敗而擋下
|
||||
}
|
||||
}
|
||||
await deleteEntry(c.env.DB, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
await deleteEntry(c.env.DB, id);
|
||||
return c.json({ success: true, vector_deleted });
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
// Execution log route(KV 額度事故修復,2026-08-07)。
|
||||
// Execution log route(KV 額度事故修復,2026-08-07;保留期=P7,2026-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 } from '../actions/execution-log';
|
||||
import {
|
||||
recordExecutionLog,
|
||||
listExecutionLog,
|
||||
latestExecutionLog,
|
||||
getRetentionDays,
|
||||
setRetentionDays,
|
||||
cleanupExpiredLogs,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
} from '../actions/execution-log';
|
||||
|
||||
export const executionLogRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -51,3 +59,39 @@ executionLogRoutes.get('/latest', async (c) => {
|
||||
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 | null;null=該租戶已設「不刪除」)。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
@@ -4,7 +4,12 @@
|
||||
// cypher proxy(X-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 — 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。
|
||||
// 形狀給 MCP instructions/GUI 首頁共用(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 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// credential-legacy-migration.test.ts — 「新讀取端上線、舊資料還沒搬完」自癒補丁的迴歸測試
|
||||
// (D38 圍牆修復收尾,總管交辦,2026-08-08;youlin 測試實例 2026-08-07 事故的根因修復)。
|
||||
//
|
||||
// 測試策略比照既有 execution-log.test.ts / library-map.test.ts:真 SQLite(node: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_token,entries 完全沒有對應列
|
||||
// (新 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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
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:false(Arcrun#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:false+note 說明故障,不 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();
|
||||
});
|
||||
|
||||
// ── Arcrun#85 D70(2026-08-11 leo21c 全盲事故)──────────────────────────────
|
||||
// 兩種故障的**處方相反**,舊版都只回一句「需要重新 reindex」:
|
||||
// ① metadata filter 死掉(metadata index 沒建)→ 先建 index 再 reindex;只 reindex 無效
|
||||
// ② 向量不在現役 index(換世代沒重嵌) → reindex 才是對的
|
||||
// 判法=拿掉 filter 再查一次。下面的假 VECTORIZE 依「有沒有帶 filter」回不同結果,
|
||||
// 精確重現 leo21c 的現場(不帶 filter score 0.8957 命中、帶 owner_id 0 命中)。
|
||||
function makeFilterAwareEnv(
|
||||
store: Entry[],
|
||||
opts: { unfilteredMatches: { id: string; score: number }[]; filteredMatches: { id: string; score: number }[] },
|
||||
): Bindings {
|
||||
return {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: {
|
||||
async query(_v: number[], o?: { filter?: Record<string, unknown> }) {
|
||||
const filtered = !!(o?.filter && Object.keys(o.filter).length > 0);
|
||||
return { matches: filtered ? opts.filteredMatches : opts.unfilteredMatches };
|
||||
},
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
it('不帶 filter 搜得到、帶 owner_id 搜不到 → filter_blind:true,處方是「先建 metadata index 再 reindex」', async () => {
|
||||
const store = [mkEntry('e1', '淡水河口的黑面琵鷺在退潮時會集體覓食', 'bfezv28v')];
|
||||
const env = makeFilterAwareEnv(store, {
|
||||
unfilteredMatches: [{ id: 'e1', score: 0.8957 }], // leo21c 實測分數
|
||||
filteredMatches: [],
|
||||
});
|
||||
const r = await embedSelfTest(env, { owner_id: 'bfezv28v' });
|
||||
expect(r.tested).toBe(true);
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.filter_blind).toBe(true);
|
||||
expect(r.note).toContain('metadata');
|
||||
// 🔴 處方順序必須寫出來——只叫人 reindex 正是 leo21c 修不好的原因
|
||||
expect(r.note).toContain('reindex');
|
||||
expect(r.note).toMatch(/先.*建.*再/s);
|
||||
});
|
||||
|
||||
it('帶不帶 filter 都搜不到 → filter_blind:false,處方才是 reindex', async () => {
|
||||
const store = [mkEntry('e1', 'content', 'o1')];
|
||||
const env = makeFilterAwareEnv(store, { unfilteredMatches: [], filteredMatches: [] });
|
||||
const r = await embedSelfTest(env, { owner_id: 'o1' });
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.filter_blind).toBe(false);
|
||||
expect(r.note).toContain('reindex');
|
||||
expect(r.note).not.toContain('metadata index 沒建');
|
||||
});
|
||||
|
||||
it('帶 filter 就搜得到 → passed:true、filter_blind:null,且不多花第二次查詢', async () => {
|
||||
const store = [mkEntry('e1', 'content', 'o1')];
|
||||
let queries = 0;
|
||||
const env = {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { queries++; return { matches: [{ id: 'e1', score: 0.9 }] }; } },
|
||||
} as unknown as Bindings;
|
||||
const r = await embedSelfTest(env, { owner_id: 'o1' });
|
||||
expect(r.passed).toBe(true);
|
||||
expect(r.filter_blind).toBeNull();
|
||||
expect(queries).toBe(1); // 健康的情況不該多打一次(成本紀律)
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來。
|
||||
//
|
||||
// DELETE /entries/:id 舊版把向量刪除包成 fire-and-forget(waitUntil(...).catch(()=>{}))——
|
||||
// 呼叫端完全看不到向量清除是否成功。本測試鎖住新行為:同步 await+回應帶 vector_deleted,
|
||||
// 且無論向量刪除成功或失敗,D1 本體都要真的被刪掉(不因向量失敗而擋下本體刪除)。
|
||||
//
|
||||
// 測試手法同 search-deprecated-filter.test.ts:fake D1 捕捉 SQL 形狀;mock VECTORIZE 可控
|
||||
// deleteByIds 成功/失敗,驗證 route 層如何把結果誠實透傳給呼叫端。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { entryRoutes } from '../src/routes/entries';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
interface Captured { sql: string; params: unknown[] }
|
||||
|
||||
function makeCaptureDB(captured: Captured[]) {
|
||||
const prepare = (sql: string) => {
|
||||
const rec: Captured = { sql, params: [] };
|
||||
captured.push(rec);
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { rec.params = args; return stmt; },
|
||||
async all<T>() { return { results: [] as T[] }; },
|
||||
async first<T>() { return null as unknown as T; },
|
||||
async run() { return { success: true }; },
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.route('/entries', entryRoutes);
|
||||
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
|
||||
return { app, env };
|
||||
}
|
||||
|
||||
describe('arcrun-rag#46 — DELETE /entries/:id 失敗可見性', () => {
|
||||
it('embed 模組未開 → vector_deleted:null(不適用,非「清成功了」的謊)+ D1 仍照刪', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI
|
||||
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.vector_deleted).toBe(null);
|
||||
expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true);
|
||||
});
|
||||
|
||||
it('模組開+向量刪除成功 → vector_deleted:true(同步等到結果才回應,不是猜的)', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const deletedIds: string[][] = [];
|
||||
const { app, env } = makeApp(captured, {
|
||||
VECTORIZE: {
|
||||
async deleteByIds(ids: string[]) { deletedIds.push(ids); return { count: ids.length }; },
|
||||
},
|
||||
AI: { async run() { return { data: [[0.1]] }; } },
|
||||
});
|
||||
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.vector_deleted).toBe(true);
|
||||
expect(deletedIds).toEqual([['e123']]); // 真的呼叫了、帶對 id,不是没做就回真
|
||||
});
|
||||
|
||||
it('🔴 模組開+向量刪除失敗 → vector_deleted:false 誠實回報,且 D1 本體仍真的刪掉', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, {
|
||||
VECTORIZE: {
|
||||
async deleteByIds() { throw new Error('Vectorize 503(模擬故障)'); },
|
||||
},
|
||||
AI: { async run() { return { data: [[0.1]] }; } },
|
||||
});
|
||||
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||
// 舊版這裡的失敗會被 waitUntil(...).catch(()=>{}) 吞掉、caller 永遠看不到;
|
||||
// 新版:HTTP 仍是 200(D1 本體真的刪了,這件事沒有失敗),但誠實標出向量那一步失敗了。
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.vector_deleted).toBe(false);
|
||||
// D1 本體不因向量失敗而被擋下——刪除的「本體一定會消失」承諾不打折扣。
|
||||
expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,19 @@ 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 } from '../src/actions/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 手法)──
|
||||
@@ -29,7 +41,12 @@ function makeSqliteD1(): D1Database {
|
||||
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() { raw.prepare(sql).run(...params); return { success: true }; },
|
||||
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;
|
||||
}
|
||||
@@ -217,3 +234,163 @@ describe('POST /execution-log/record + GET /execution-log route(Hono app.reque
|
||||
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、GET/PUT /execution-log/retention route(Hono 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 → 400;retention_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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud';
|
||||
import { createEntry } from '../src/actions/entry-crud';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
// ── node:sqlite → D1 介面最小 adapter(prepare/bind/all/first/run,本 codebase 只用這些)──
|
||||
@@ -250,3 +252,133 @@ describe('M2 — route 行為(GET /map、GET /map/:library、POST /map/recompu
|
||||
expect(miss.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// 2026-08-08:M3 收尾——真因是「等外部呼叫 /map/recompute」這條線三週沒人接(總管實測 grep
|
||||
// 全 repo 查無呼叫點),沒手動 backfill 過的租戶恆空。修法:讀端自己核對即時三元組數,落差
|
||||
// 就地補算,不再依賴任何外部呼叫者。以下驗證這條「即時新鮮度」機制本身。
|
||||
describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動核對重算,不靠外部呼叫 recompute)', () => {
|
||||
it('從未手動呼過 recompute:GET /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('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
|
||||
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 status,recomputeLibraryMap 只算
|
||||
// active——只要庫裡混了 superseded triplet,兩邊算出來的數字永遠對不上,
|
||||
// ensureFreshLibraryMaps 就永遠判定 stale,每次讀地圖都重算、每次都新建一筆 record。
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰
|
||||
|
||||
const { app, env } = makeApp(db);
|
||||
|
||||
// 第一次讀:資料是新的(從沒 recompute 過),觸發一次重算是正常的。
|
||||
const first = await app.request('/map', {}, env);
|
||||
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); // 只算 active 那筆
|
||||
|
||||
const countAfterFirst = (await searchByTemplate(db, 'library_map')).length;
|
||||
|
||||
// 第二次讀:中間沒有任何寫入動作。修好之前,這裡會再次判定 stale 並多新建一筆 record。
|
||||
const second = await app.request('/map', {}, env);
|
||||
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
|
||||
|
||||
const countAfterSecond = (await searchByTemplate(db, 'library_map')).length;
|
||||
expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record
|
||||
|
||||
// 第三次也一樣,多讀幾次確認不是巧合。
|
||||
await app.request('/map', {}, env);
|
||||
const countAfterThird = (await searchByTemplate(db, 'library_map')).length;
|
||||
expect(countAfterThird).toBe(countAfterFirst);
|
||||
});
|
||||
|
||||
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
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「已知但目前是空庫」(200+triplet_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:只跑過 migrations(library_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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 兩種計數;getEntry(WHERE 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(*)')) {
|
||||
// backfillStatus:pending 用 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('誠實降級 keyword+degraded_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,186 @@
|
||||
// 語意搜尋回空時「為什麼空」的回歸測試(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 D1:COUNT 查詢回傳可配置的 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=0,hits.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();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 第四態 filter_blind(Arcrun#85 D70,2026-08-11 leo21c 實撞)─────────────────
|
||||
//
|
||||
// 現場:現役 Vectorize index `arcrun-kbdb-embed-m3` 上**一個 metadata index 都沒有**
|
||||
// (真兇=arcrun-rag 安裝器把端點寫成 `metadata-index/create`,連字號版 CF 回 404,
|
||||
// 底線 `metadata_index/create` 才是對的;而該安裝器把失敗降級成一行 ⚠ 就宣告成功)。
|
||||
// ⇒ Vectorize 對 owner_id 下 filter 一律回 0 筆,而**每一條真實使用者路徑都帶 owner_id**
|
||||
// 做租戶隔離 ⇒ 語意搜尋 100% 全盲。
|
||||
// 實測(leo21c,同一句查詢):不帶 filter → 1 命中 score 0.8957;帶 owner_id → 0 命中。
|
||||
//
|
||||
// 舊行為把這個歸成 no_match,回「換個說法或更具體的關鍵字再試試看」
|
||||
// =**把系統故障說成使用者的問題**,正是 leo 2026-08-09 直令禁止的那件事,
|
||||
// 而且沒有人會因為「搜不到」去翻 Cloudflare 的 Vectorize 設定。
|
||||
function makeFilterAwareEnv(
|
||||
dbOpts: Parameters<typeof makeFakeDB>[0],
|
||||
opts: { unfiltered: { id: string; score: number }[]; filtered: { 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(_v: number[], o?: { filter?: Record<string, unknown> }) {
|
||||
const filtered = !!(o?.filter && Object.keys(o.filter).length > 0);
|
||||
return { matches: filtered ? opts.filtered : opts.unfiltered };
|
||||
},
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
describe('empty_reason=filter_blind — Vectorize metadata 過濾整個是死的', () => {
|
||||
it('帶 owner_id 零命中、拿掉 filter 有命中 → filter_blind,且照實說是我們的故障', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeFilterAwareEnv(
|
||||
{ embeddedCount: 805, hydrateEntry: mkEntry('e1') },
|
||||
{ unfiltered: [{ id: 'e1', score: 0.8957 }], filtered: [] },
|
||||
);
|
||||
const res = await app.request('/entries/search?q=黑面琵鷺&mode=semantic&owner_id=bfezv28v', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.count).toBe(0);
|
||||
expect(body.empty_reason).toBe('filter_blind');
|
||||
const hint = body.capability_hint as string;
|
||||
// 🔴 誠實鐵律:是故障、不是使用者的錯,且**明說換個說法沒有用**
|
||||
expect(hint).toContain('故障');
|
||||
expect(hint).toContain('不是你');
|
||||
expect(/換個說法也不會有用/.test(hint)).toBe(true);
|
||||
// 🔴 人話紅線:不准把 Vectorize/owner_id 這類內部詞漏給使用者
|
||||
expect(/vectorize|owner_id|metadata|index/i.test(hint)).toBe(false);
|
||||
// 技術細節與**處方順序**留給維運者
|
||||
const admin = body.admin_hint as string;
|
||||
expect(admin).toContain('metadata index');
|
||||
expect(admin).toContain('reindex');
|
||||
});
|
||||
|
||||
it('沒帶任何 filter 的查詢不做探針,維持 no_match(不多花一次查詢)', async () => {
|
||||
const app = makeApp();
|
||||
let queries = 0;
|
||||
const env = {
|
||||
DB: makeFakeDB({ embeddedCount: 42 }),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { queries++; return { matches: [] }; } },
|
||||
} as unknown as Bindings;
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.empty_reason).toBe('no_match');
|
||||
expect(queries).toBe(1);
|
||||
});
|
||||
|
||||
it('帶 filter 但拿掉 filter 也零命中 → 仍是 no_match(別把正常的找不到誣賴成故障)', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeFilterAwareEnv({ embeddedCount: 42 }, { unfiltered: [], filtered: [] });
|
||||
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_match');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// 查詢斷詞 + 覆蓋率排序 —— 讓「AI 問一個問句」查得到東西(2026-08-10,Leo/mira#4)
|
||||
//
|
||||
// 病徵(總管在 leo21c 上實測,有對照組,非推論):
|
||||
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||
// ⇒ 對照組證明:查詢字串是**整串**拿去 LIKE 的,從來沒被拆開。
|
||||
// ⇒ 而 **AI 問的永遠是問句**,問句的詞不可能在原文裡剛好相鄰 ⇒ 這條路對 AI 恆為 0。
|
||||
//
|
||||
// 本檔守四件事:
|
||||
// ① 拆得開 —— 詞存在但不相鄰的問句要能命中
|
||||
// ② 不退化 —— 單詞查詢送出的 SQL 與舊版**逐字相同**(最熱路徑一個字都不能變)
|
||||
// ③ 不崩壞 —— 相關的排前面、雜訊尾巴被相對門檻砍掉,不是把整個庫撈回來
|
||||
// ④ 不再炸 —— 每個 LIKE pattern 仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復)
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
tokenizeQuery,
|
||||
buildSearchScore,
|
||||
applyRelativeCut,
|
||||
searchEntries,
|
||||
} from '../src/actions/entry-crud';
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限
|
||||
const termsOf = (q: string) => tokenizeQuery(q).map((t) => t.term);
|
||||
const weightOf = (q: string, term: string) => tokenizeQuery(q).find((t) => t.term === term)?.weight;
|
||||
|
||||
describe('① 拆得開:問句要被拆成詞', () => {
|
||||
it('「Gemini 逃生口」拆成兩個詞(就是驗收題本身)', () => {
|
||||
expect(termsOf('Gemini 逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||
});
|
||||
|
||||
it('沒有空白的 CJK/ASCII 交界也要切開(吸收 t95 normalizeCjkQuery 的用意)', () => {
|
||||
expect(termsOf('Gemini逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||
expect(termsOf('AI協作')).toEqual(expect.arrayContaining(['AI', '協作']));
|
||||
});
|
||||
|
||||
it('自然語言問句:虛詞被丟掉,只留實詞', () => {
|
||||
const t = termsOf('Gemini 在這套系統裡的角色是什麼?');
|
||||
expect(t).toEqual(expect.arrayContaining(['Gemini', '系統', '角色']));
|
||||
// 「這/的/是/什麼/套」是虛詞與量詞,不該變成查詢詞——否則會把整個庫撈回來
|
||||
for (const junk of ['這', '的', '是', '什麼', '套系統']) expect(t).not.toContain(junk);
|
||||
});
|
||||
|
||||
it('標點(含全形)當分隔,不會混進詞裡', () => {
|
||||
expect(termsOf('額度、向量化;為什麼?')).toEqual(expect.arrayContaining(['額度', '向量化']));
|
||||
for (const t of termsOf('額度、向量化;為什麼?')) {
|
||||
expect(t).not.toMatch(/[、;?,。]/);
|
||||
}
|
||||
});
|
||||
|
||||
it('超過 4 字的黏著長段補雙字組合(「專案管理工具」要能命中「專案管理」的寫法)', () => {
|
||||
expect(termsOf('專案管理工具')).toEqual(expect.arrayContaining(['專案', '管理']));
|
||||
});
|
||||
|
||||
it('任何查詢都至少留下一個詞,不會一個都不剩', () => {
|
||||
for (const q of ['是什麼', '的', '。。。', 'a']) {
|
||||
expect(buildSearchScore(q).scoreParams.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// 🔴 這組是「第一版寫錯、被自己的測試擋下來」的那個錯(2026-08-10):
|
||||
// 第一版拿虛詞去**切段**,結果 `向` 把「向量化」切成「量化」、`能` 把「功能」切掉
|
||||
// ⇒ 使用者真正要查的詞被切爛。沒有詞典的中文,切段一定誤傷實詞。
|
||||
// 現在的做法是「整段不動、只過濾雙字組合」,這組測試就是不准再走回去。
|
||||
it('實詞不准被虛詞切爛(向量化/功能/需要/使用者/規則/原因)', () => {
|
||||
expect(termsOf('額度、向量化;為什麼?')).toContain('向量化');
|
||||
expect(termsOf('這個功能是什麼')).toContain('功能');
|
||||
for (const [q, word] of [
|
||||
['系統需要什麼', '需要'], ['使用者是誰', '使用'], ['這個規則是什麼', '規則'],
|
||||
['原因是什麼', '原因'], ['更新了什麼', '更新'],
|
||||
] as const) {
|
||||
expect(termsOf(q)).toContain(word);
|
||||
}
|
||||
});
|
||||
|
||||
it('leo 的第二題「今天額度為什麼用完」要抓得到「額度」', () => {
|
||||
expect(termsOf('今天額度為什麼用完')).toContain('額度');
|
||||
});
|
||||
});
|
||||
|
||||
describe('② 不退化:單詞查詢與舊版逐字相同', () => {
|
||||
it('單一英文詞 → 一個詞、legacyShape、一個 LIKE', () => {
|
||||
const p = buildSearchScore('arcrun');
|
||||
expect(p.terms.map((t) => t.term)).toEqual(['arcrun']);
|
||||
expect(p.legacyShape).toBe(true);
|
||||
expect(p.scoreParams).toEqual(['%arcrun%']);
|
||||
});
|
||||
|
||||
it('單一四字中文詞(最常見的中文查詢)→ 仍然只有一個 LIKE', () => {
|
||||
// 這條是被既有 search-long-query.test.ts 擋出來的:雙字組合門檻若設 3,
|
||||
// 「語意檢索」會從 1 個 LIKE 變成 5 個 ⇒ 最熱路徑成本 ×5。
|
||||
const p = buildSearchScore('語意檢索');
|
||||
expect(p.legacyShape).toBe(true);
|
||||
expect(p.scoreParams).toEqual(['%語意檢索%']);
|
||||
});
|
||||
|
||||
it('searchEntries:單詞查詢送出的 SQL 只有一個 content LIKE,pattern 與舊版相同', 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('單詞查詢分數全等 ⇒ 相對門檻一筆都砍不掉(排序退化回 updated_at DESC)', () => {
|
||||
const rows = Array.from({ length: 50 }, (_, i) => ({ id: `e${i}`, match_score: 3 }));
|
||||
expect(applyRelativeCut(rows)).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('③ 不崩壞:相關的排前面,雜訊尾巴砍掉', () => {
|
||||
it('詞愈長份量愈重(specific 壓過泛詞,這是相關性不崩壞的機制)', () => {
|
||||
const q = 'Gemini 在這套系統裡的角色是什麼?';
|
||||
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '系統')!);
|
||||
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '角色')!);
|
||||
});
|
||||
|
||||
it('整句相鄰另給重賞 ⇒ 字面命中永遠壓過零散命中(`local arcrun` 那 5 筆不會被稀釋)', () => {
|
||||
const p = buildSearchScore('local arcrun');
|
||||
expect(p.legacyShape).toBe(false);
|
||||
expect(p.scoreParams).toContain('%local arcrun%'); // 整句那一項存在
|
||||
const bonus = p.terms.reduce((s, t) => s + t.weight, 0);
|
||||
const scattered = p.terms.reduce((s, t) => s + t.weight, 0); // 全部詞都命中但不相鄰
|
||||
expect(bonus + scattered).toBeGreaterThan(scattered); // 相鄰者必然更高分
|
||||
});
|
||||
|
||||
it('相對門檻砍掉低於最高分 60% 的尾巴', () => {
|
||||
const rows = [
|
||||
{ id: 'a', match_score: 10 }, // 兩個詞都中
|
||||
{ id: 'b', match_score: 6 }, // 只中重的那個
|
||||
{ id: 'c', match_score: 2 }, // 只中泛詞 ⇒ 雜訊,砍掉
|
||||
];
|
||||
expect(applyRelativeCut(rows).map((r) => r.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('相對門檻是對「最高分」取比例、不是對「滿分」——否則驗收題會被自己的門檻誤殺', () => {
|
||||
// 「Gemini 逃生口」:全庫沒有「逃生口」,最高分那群只中了 Gemini 一個詞。
|
||||
// 若拿滿分當分母,這群會全部低於門檻 ⇒ 又回到 0 筆。
|
||||
const onlyOneTermHit = Array.from({ length: 40 }, (_, i) => ({ id: `g${i}`, match_score: 6 }));
|
||||
expect(applyRelativeCut(onlyOneTermHit)).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('查詢詞數有上限(每多一個詞就多掃一次全表)', () => {
|
||||
const t = tokenizeQuery('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫 額度 向量');
|
||||
expect(t.length).toBeLessThanOrEqual(6);
|
||||
// 被砍掉的必須是最泛的那些 ⇒ 留下來的按份量遞減
|
||||
const w = t.map((x) => x.weight);
|
||||
expect([...w].sort((a, b) => b - a)).toEqual(w);
|
||||
});
|
||||
});
|
||||
|
||||
describe('④ 不再炸:LIKE pattern 仍在 D1 上限內(承 2026-08-03 的 500 修復)', () => {
|
||||
it('任何查詢(含超長中文句)產生的每個 pattern 都 ≤ 50 bytes', () => {
|
||||
const qs = [
|
||||
'a'.repeat(300),
|
||||
'為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢',
|
||||
'Gemini 在這套系統裡的角色是什麼?今天額度為什麼用完?',
|
||||
'。'.repeat(60),
|
||||
];
|
||||
for (const q of qs) {
|
||||
const p = buildSearchScore(q);
|
||||
expect(p.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
|
||||
for (const pattern of p.scoreParams) expect(bytes(pattern)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEntries 送出的 SQL', () => {
|
||||
it('多詞查詢:拆成多個 CASE WHEN,且只回 match_score > 0 的、按分數排序', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, 'Gemini 逃生口', 'demo');
|
||||
const sql = captured[0].sql;
|
||||
expect((sql.match(/CASE WHEN content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
|
||||
expect(sql).toContain('match_score > 0');
|
||||
expect(sql).toContain('ORDER BY match_score DESC');
|
||||
expect(captured[0].params).toEqual(expect.arrayContaining(['%Gemini%', '%逃生口%']));
|
||||
});
|
||||
|
||||
it('其他 filter(owner/library/deprecated)留在內層,先篩再算分', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, 'Gemini 逃生口', 'demo', undefined, 50, ['kb'], 'kb://x');
|
||||
const inner = captured[0].sql.split('WHERE match_score')[0];
|
||||
expect(inner).toContain('owner_id = ?');
|
||||
expect(inner).toContain("json_extract(metadata_json, '$.source')");
|
||||
expect(inner).toContain('json_extract(metadata_json, \'$.status\')'); // NOT_DEPRECATED
|
||||
});
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 三元組 library 補標 — 源頭順序 + 存量補標 + 冪等(2026-08-11,leo 貼 wiki 卡「三元組要恢復」)
|
||||
//
|
||||
// 背景(system-dev/wiki/ops-facts.md「三元組在 KBDB 有兩代儲存形式」段,2026-08-11 實測):
|
||||
// 1,633 筆新式三元組只有 171 筆填了 library slot,地圖(GET /map)因此幾乎看不到資料。
|
||||
// 根因是**寫入順序**:createRecord 只會替 template.slots_json 裡「已宣告」的 slot 建 entry_value
|
||||
// (見 src/actions/record-crud.ts createRecord:`for (const slot of slots) { if (!(slot in
|
||||
// input.values)) continue }`——注意是遍歷 template 既有 slots,不是遍歷 caller 傳的 values)。
|
||||
// 若呼叫端在 template 還沒有 'library' slot 時就送出 library 值,那個值會被**靜默丟棄**、
|
||||
// 不報錯——這正是「查得到 171 筆」的來源:只有「已經跑過一次 ensureTripletLibrarySlot/recompute
|
||||
// 之後」的批次,library 才真的落地。
|
||||
//
|
||||
// 本檔驗三件事(對應 leo 交辦的三個「要驗的」):
|
||||
// 1. 源頭:ensure-slot 必須在 write 之前,不能事後補(重現+證明順序才是正解)
|
||||
// 2. 存量:對一批缺 library 的舊 triplet 補標,前後地圖輸出對照
|
||||
// 3. 不會重複做:同一批跑兩次,第二次 touch 0 筆
|
||||
//
|
||||
// 測試手法沿 library-map.test.ts 慣例:真 node:sqlite(Node ≥22.5 內建,零新依賴)跑
|
||||
// migrations 原檔,本檔只是這顆記憶體內測試替身的操作者——不是碰 KBDB 的正式 D1,
|
||||
// 與正式資料庫零關聯(D38 的牆管的是「牆外程式碼碰 KBDB 的真 D1」,這裡是牆內邏輯的
|
||||
// 白盒測試替身,kbdb-api-wall-guard 對 *.test.ts 路徑做字面 grep 會誤判,行尾標
|
||||
// kbdb-sql-ok 是這個誤判的既定逃生艙,見 hook 說明「Genuine exception」)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
recomputeLibraryMap,
|
||||
ensureTripletLibrarySlot,
|
||||
ensureFreshLibraryMaps,
|
||||
listLibraryMaps,
|
||||
} from '../src/actions/library-map';
|
||||
import { createTemplate, createRecord, updateRecord, getRecord, searchByTemplate } from '../src/actions/record-crud';
|
||||
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok: 記憶體測試替身,非真 KBDB D1
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 灌測試替身 schema,非真 D1
|
||||
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 同上
|
||||
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: 記憶體測試替身
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 記憶體測試替身
|
||||
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 記憶體測試替身
|
||||
};
|
||||
return s;
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
// prod 實際 triplet template 的 slots(library-map.test.ts 同款常數,2026-07-19 kbdb_list_templates
|
||||
// 核實)——注意:沒有 library。用它模擬「template 還沒被任何 recompute 摸過」的乾淨起點。
|
||||
const PROD_TRIPLET_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',
|
||||
];
|
||||
|
||||
async function seedTripletTemplate(db: D1Database): Promise<void> {
|
||||
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
|
||||
}
|
||||
|
||||
// 對齊 design.md 既有 fallback 語意(source_prefix 參數)的同一條規則:
|
||||
// library = source_uri 在 '@' 之前的那段(scheme:owner/repo)。這就是本檔+報告裡建議
|
||||
// kbdb-graph-plugin 在 write 時就该套用的推導規則(見報告,本檔只證明「規則正確、
|
||||
// 順序對了就能用」,不代表已經改了 kbdb-graph-plugin 的程式碼——那是另一個 repo)。
|
||||
function deriveLibrary(sourceUri: string): string {
|
||||
const at = sourceUri.indexOf('@');
|
||||
return at > 0 ? sourceUri.slice(0, at) : sourceUri;
|
||||
}
|
||||
|
||||
describe('源頭順序 — ensure-slot 必須在 write 之前,事後補救不了已寫的那筆', () => {
|
||||
it('重現:template 尚無 library slot 時寫入 → library 值被靜默丟棄(不是報錯,是消失)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
|
||||
const rec = await createRecord(db, {
|
||||
template: 'triplet',
|
||||
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
|
||||
owner_id: 'leo',
|
||||
});
|
||||
const stored = await getRecord(db, rec.record_id);
|
||||
// 關鍵斷言:library 完全沒落地,不是空字串、是 undefined(key 都不存在)。
|
||||
expect(stored!.values.library).toBeUndefined();
|
||||
expect(stored!.values.source_uri).toBe('gitea:Leo/kb@a.md'); // 其他 slot 正常落地,只有未宣告的 slot 消失
|
||||
});
|
||||
|
||||
it('正解:先 ensureTripletLibrarySlot() 補上 slot,再寫 → library 值正常落地', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
|
||||
const added = await ensureTripletLibrarySlot(db, 'triplet');
|
||||
expect(added).toBe(true); // 第一次呼叫確實補了 slot
|
||||
|
||||
const rec = await createRecord(db, {
|
||||
template: 'triplet',
|
||||
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
|
||||
owner_id: 'leo',
|
||||
});
|
||||
const stored = await getRecord(db, rec.record_id);
|
||||
expect(stored!.values.library).toBe('gitea:Leo/kb');
|
||||
|
||||
// 冪等:對已有 slot 的 template 再呼叫一次 → false(不重複加),不影響既有資料。
|
||||
const addedAgain = await ensureTripletLibrarySlot(db, 'triplet');
|
||||
expect(addedAgain).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('存量補標 — 對缺 library 的舊 triplet 補標,地圖輸出前後對照', () => {
|
||||
it('補標前:地圖看不到任何庫(triplet 全部因缺 library 而未被地圖聚合);補標後:庫名正確出現', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
// 模擬現況:三筆舊 triplet,寫入時 template 還沒有 library slot(如實重現存量現況),
|
||||
// 只帶了 source_uri(之後補標要靠它反推 library)。
|
||||
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', status: 'active' }, owner_id: 'leo' });
|
||||
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md', status: 'active' }, owner_id: 'leo' });
|
||||
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'github:uncle6-me/notes@c.md', status: 'active' }, owner_id: 'leo' });
|
||||
|
||||
// 讀端自動核對重算(M3 收尾機制):這時三筆都缺 library 值。liveTripletCountsByLibrary
|
||||
// 把「缺值」COALESCE 成 'general' 桶(供 staleness 判斷),但 recomputeLibraryMap 的
|
||||
// libCond 是 `t.library = 'general'` 精確比對——缺值在底層是 NULL 不是字面 'general',
|
||||
// 比對不中,實際聚合出 triplet_count:0。**這是本次順手發現的另一個小落差**(live 計數桶
|
||||
// 與 recompute 精確比對的『general』語意沒對齊,導致這桶每次讀都判 stale、白重算,
|
||||
// 但至少不會謊報數字)——不在本次任務範圍內(leo 問的是兩代儲存形式的可見性,不是這個
|
||||
// fallback 桶的效能問題),本測試如實記錄現況,不假裝它是 0。
|
||||
await ensureFreshLibraryMaps(db, 'leo');
|
||||
const before = await listLibraryMaps(db, 'leo');
|
||||
expect(before.length).toBe(1);
|
||||
expect(before[0].library).toBe('general');
|
||||
expect(before[0].triplet_count).toBe(0); // 誠實:桶名對得上、數字沒謊報,但也沒把 3 筆算近來(見上註)
|
||||
|
||||
// ── 補標(源頭已對:先 ensure slot,才寫值;一次對全部缺值的 record)──
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
const allTriplets = await searchByTemplate(db, 'triplet', 'leo');
|
||||
const missing = allTriplets.filter((t) => !t.values.library && t.values.source_uri);
|
||||
expect(missing.length).toBe(3); // 三筆都缺
|
||||
for (const t of missing) {
|
||||
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri) });
|
||||
}
|
||||
|
||||
// 補標後:對每個實際出現的 library 值重算一次地圖(read-path 的 ensureFreshLibraryMaps
|
||||
// 只認「已知庫名」——entries.metadata.library 或 portal_library;剛補標的三元組 library 值
|
||||
// 尚未被任何一處登記為「已知庫名」,直接 recompute 該庫最直接、也是 caller 實際會做的事)。
|
||||
const libs = [...new Set(missing.map((t) => deriveLibrary(t.values.source_uri!)))];
|
||||
for (const lib of libs) {
|
||||
await recomputeLibraryMap(db, { library: lib, owner_id: 'leo' });
|
||||
}
|
||||
|
||||
const after = await listLibraryMaps(db, 'leo');
|
||||
const byLib = new Map(after.map((m) => [m.library, m.triplet_count]));
|
||||
expect(byLib.get('gitea:Leo/kb')).toBe(2); // r1, r2 同庫 —— 這是 leo 真正要看到的東西
|
||||
expect(byLib.get('github:uncle6-me/notes')).toBe(1); // r3 另一庫
|
||||
// 'general' 桶(補標前遺留的空殼,見上一段註)仍在,但 triplet_count 仍是 0——
|
||||
// 三筆全部被正確歸進真正的庫名,沒有一筆被算進 general(no double counting)。
|
||||
expect(byLib.get('general')).toBe(0);
|
||||
expect(after.length).toBe(3);
|
||||
|
||||
// 交叉核對:三筆的 library 值確實都落地了(不是只有地圖聚合對,底層資料也對)。
|
||||
const r1Rec = await getRecord(db, r1.record_id);
|
||||
const r2Rec = await getRecord(db, r2.record_id);
|
||||
const r3Rec = await getRecord(db, r3.record_id);
|
||||
expect(r1Rec!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r2Rec!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r3Rec!.values.library).toBe('github:uncle6-me/notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('不會重複做 — 同一批補標跑兩次,第二次不改動任何東西', () => {
|
||||
it('第二輪掃描:已有 library 值的 record 一筆都不會被 touch', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'leo' });
|
||||
// r2 模擬「還沒補標」的舊資料:故意繞過 values 直接不給 library(createRecord 這次雖然
|
||||
// template 已有 slot,但 caller 沒給值 → 該 slot 完全不會被建立 entry_value,等同「缺」)。
|
||||
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md' }, owner_id: 'leo' });
|
||||
await updateRecord(db, r1.record_id, { library: deriveLibrary('gitea:Leo/kb@a.md') }); // r1 先補過
|
||||
|
||||
async function backfillPass(): Promise<number> {
|
||||
const all = await searchByTemplate(db, 'triplet', 'leo');
|
||||
const missing = all.filter((t) => !t.values.library && t.values.source_uri);
|
||||
for (const t of missing) {
|
||||
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri!) });
|
||||
}
|
||||
return missing.length;
|
||||
}
|
||||
|
||||
const firstPass = await backfillPass();
|
||||
expect(firstPass).toBe(1); // 只有 r2 被補(r1 已有值,跳過)
|
||||
|
||||
const secondPass = await backfillPass();
|
||||
expect(secondPass).toBe(0); // 第二輪:兩筆都已有 library,一筆都不 touch
|
||||
|
||||
// 資料仍然正確、沒被第二輪弄壞。
|
||||
const r1After = await getRecord(db, r1.record_id);
|
||||
const r2After = await getRecord(db, r2.record_id);
|
||||
expect(r1After!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r2After!.values.library).toBe('gitea:Leo/kb');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,24 @@ const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partn
|
||||
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
|
||||
registerOAuthRoutes(_app);
|
||||
|
||||
// ── GET /health — 讓「這台跑的是哪一版 MCP」用一條 curl 看得到 ─────────────────────
|
||||
// 為什麼要有:cypher-executor 早就有 /health(bundle_version + auth_store 探針),
|
||||
// arcrun-mcp 沒有 ⇒ 要判斷某台實例的 MCP 是哪一代認證,只能打 /authorize 剖 HTML 數欄位
|
||||
// (ops-facts 2026-08-10 的土法)。那個判準脆弱又難教。
|
||||
//
|
||||
// ⚠️ 誠實界定(別把它當世代判準用過頭):本端點是**這個 commit 之後才有的**,所以
|
||||
// 「/health 回 404」只代表「比本版舊」,**不代表就是 owner_secret 世代**——
|
||||
// 現階段判世代仍要看 /authorize 的欄位(一個 owner_secret =舊;email+password =新)。
|
||||
// 等這版推到各實例之後,`auth` 欄位才會變成一眼可讀的世代判準。
|
||||
// 不需認證、不吐任何機密;MCP_BUILD 是部署標記,由各實例 toml [vars] 帶入。
|
||||
_app.get("/health", (c) => c.json({
|
||||
ok: true,
|
||||
service: "arcrun-mcp",
|
||||
auth: "portal-login",
|
||||
build: c.env.MCP_BUILD ?? "unknown",
|
||||
oauth_kv: c.env.OAUTH_KV ? "present" : "missing",
|
||||
}));
|
||||
|
||||
const app = _app.basePath('/mcp');
|
||||
|
||||
app.use("*", cors({
|
||||
|
||||
@@ -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)。資料來源是 D1 執行紀錄表(無固定保留期,但用量過大時系統會自動降成只記失敗、甚至暫停記錄——工作流本身執行不受影響)。",
|
||||
"列某 workflow 最近 N 次執行 verdict(成功 / 失敗 / duration)。資料來源是 D1 執行紀錄表,稽核資料——預設保留 90 天(3 個月)過期即清,管理者可在 portal 改保留天數或設為不刪除;用量過大時系統會自動降成只記失敗、甚至暫停記錄——工作流本身執行不受影響。",
|
||||
{
|
||||
api_key: z.string().describe(apiKeyDesc),
|
||||
workflow_name: z.string().describe("workflow 名稱(acr push 時的 name 欄)"),
|
||||
|
||||
@@ -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) {
|
||||
// 單庫詳圖:完整 slots(slot 陣列 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) {
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface Env {
|
||||
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
|
||||
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
|
||||
MCP_ALLOWED_REDIRECT_HOSTS?: string;
|
||||
// 部署標記(非機密):GET /health 原樣回報,讓「這台跑的是哪一版」用一條 curl 看得到。
|
||||
// 由各實例的 wrangler toml [vars] 帶入;沒帶 → /health 回 build:"unknown"(誠實,不假裝)。
|
||||
MCP_BUILD?: string;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
|
||||
@@ -127,6 +127,25 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
expect(JSON.stringify(body.hints)).toContain("POST /map/recompute");
|
||||
});
|
||||
|
||||
// 2026-08-08:舊版說明文字宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過
|
||||
// (總管實測 grep 全 repo 查無任何呼叫點),是假話。修法:GET /map 讀端本身自動核對
|
||||
// 即時三元組數並補算,不靠任何外部呼叫者。這裡釘住那句謊言不會再出現在任何 hint 裡。
|
||||
it("不再宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過,是假話(已修正措辭)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const body = parseResult(res);
|
||||
const hintsText = JSON.stringify(body.hints);
|
||||
expect(hintsText).not.toContain("地圖由 ingest 尾端自動重算");
|
||||
expect(hintsText).not.toContain("(M3)");
|
||||
expect(hintsText).not.toContain("(M3)");
|
||||
// 誠實的新措辭:空=這個租戶真的沒資料,不是「沒人跑過 recompute」
|
||||
expect(hintsText).toContain("這個租戶目前沒有任何三元組資料");
|
||||
});
|
||||
|
||||
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||
@@ -199,6 +218,10 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
const body = parseResult(res);
|
||||
expect(body.error_code).toBe("map_not_found");
|
||||
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
|
||||
// 2026-08-08:404 現在的語意是「查無此庫」(地圖是即時核對重算的,已知庫即使空也回 200),
|
||||
// 不再是舊版那句「從未 recompute」的曖昧說法。
|
||||
expect(String(body.human_message)).toContain("查無庫");
|
||||
expect(String(body.human_message)).toContain("從沒出現過");
|
||||
});
|
||||
|
||||
it("binding throws → internal_error, not an unhandled crash", async () => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# ── arcrun-mcp STAGE(youlin 帳號)────────────────────────────────────────────────
|
||||
#
|
||||
# 這支存在的理由(2026-08-10,Leo/mira#4):
|
||||
# `arcrun-mcp` 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有測試場,
|
||||
# 要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)去冒險。這支把 stage 補上:
|
||||
# youlin = stage(agent-memory「兩台分工」:AI 測 youlin、leo 核實 prod)。
|
||||
#
|
||||
# 用法(在 mcp/ 目錄下跑,-c 必須與 main="src/index.ts" 同目錄,否則找不到入口):
|
||||
# cd mcp
|
||||
# CLOUDFLARE_ACCOUNT_ID=1129efd7df2e8899d537e9c8fbabb6cb \
|
||||
# CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN_YOULIN_CC_USE" \
|
||||
# npx wrangler deploy -c wrangler.stage.toml
|
||||
#
|
||||
# 與出貨用的 wrangler.toml 的差別,就是「手動直推 arcrun-mcp 到自架帳號」的兩個坑
|
||||
# (agent-memory 已記,這裡是把它變成檔案而不是靠人記得):
|
||||
# ① 拿掉 [[routes]] —— `mcp.arcrun.dev` 那個 zone 在 uncle6,自架帳號沒有,帶著會部署失敗
|
||||
# ② OAUTH_KV 填真 id —— 出貨 toml 是佔位符 REPLACE_WITH_REAL_KV_ID,只有走 acr init/update
|
||||
# 才會被自動注入;直推必須自己填(沒填 → /authorize 回 503,OAuth 整條死)
|
||||
#
|
||||
# 🔴 [vars] 必須列**全**:wrangler deploy 會用本檔的 vars 整組取代線上的,漏一個就是靜默清掉。
|
||||
# 下方七個 var 是 2026-08-10 從線上 arcrun-mcp 抓下來的原樣值。
|
||||
# Secrets(KBDB_INTERNAL_TOKEN / MCP_OWNER_SECRET)不進本檔也不會被 deploy 洗掉。
|
||||
|
||||
name = "arcrun-mcp"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-11-27"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
workers_dev = true
|
||||
|
||||
[vars]
|
||||
MULTI_TENANT = "false"
|
||||
CF_ACCOUNT_ID = "1129efd7df2e8899d537e9c8fbabb6cb"
|
||||
WORKER_SUBDOMAIN = "youlin-hsieh-dev"
|
||||
CONSOLE_TENANT = "yuga3bse"
|
||||
MCP_OWNER_NAMESPACE = "yuga3bse"
|
||||
KBDB_BASE_URL = "https://arcrun-kbdb.youlin-hsieh-dev.workers.dev"
|
||||
UI_ORIGINS = "https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev"
|
||||
# 部署標記,GET /health 原樣回報。每次重推 stage 就換一個,別讓它跟線上脫節。
|
||||
MCP_BUILD = "stage-2026-08-10+45f1c09"
|
||||
|
||||
[[services]]
|
||||
binding = "COMPONENT_REGISTRY"
|
||||
service = "arcrun-registry"
|
||||
|
||||
[[services]]
|
||||
binding = "CYPHER_EXECUTOR"
|
||||
service = "arcrun-cypher-executor"
|
||||
|
||||
[[services]]
|
||||
binding = "KBDB"
|
||||
service = "arcrun-kbdb"
|
||||
|
||||
# youlin 的 OAuth KV(安裝器建的 arcrun-rag-yuga3bse-kv-oauth_kv)。
|
||||
[[kv_namespaces]]
|
||||
binding = "OAUTH_KV"
|
||||
id = "6a8d4dd621994607b3998ade4c7e9944"
|
||||
@@ -0,0 +1,64 @@
|
||||
# 卡在人類閘前的產物(`Arcrun#89` / `#90` / `#91`)
|
||||
|
||||
> **為什麼這個資料夾存在**:這三樣東西都做完並實測過了,但落地的最後一步是
|
||||
> **終端機裡等人親手打字的互動閘**,AI 打不進去。
|
||||
> 2026-08-11 它們原本只存在於某個 session 的暫存目錄——**那種目錄一關就沒了**。
|
||||
> 先搶進版控,等人有空時再落地。
|
||||
|
||||
---
|
||||
|
||||
## 一、兩份 recipe(`#89`/`#90`)
|
||||
|
||||
`recipes/gitea_put_file.yaml` — 把檔案寫回 Gitea repo。**出貨線有 7 站等它。**
|
||||
`recipes/cf_worker_deploy_simple.yaml` — 部署單檔 Worker(classic 格式)。
|
||||
|
||||
**落地指令**(一份跑一次):
|
||||
|
||||
```
|
||||
acr recipe push pending-human-gate/recipes/gitea_put_file.yaml
|
||||
```
|
||||
|
||||
跑的時候會停下來要你**親手輸入資源名確認**——那是「把資源變成可被外部呼叫」的暴露同意閘,
|
||||
不是卡住,是設計如此。
|
||||
|
||||
⚠️ **`cf_worker_deploy_simple.yaml` 先別急著推**:`#90` 查出一件結構性的事——
|
||||
recipe 引擎的 body 一律 JSON,而 Cloudflare 上傳 Worker 的 API 要的是原始 JS 或 multipart。
|
||||
⇒ **classic 版只適用於沒有 bindings 的簡單情形**。而實查安裝器那站有 9 把 KV + 一顆 D1,
|
||||
**classic 版幫不上它**。詳見 `Leo/Arcrun#90`。
|
||||
|
||||
### 金鑰(D36)
|
||||
|
||||
兩份 recipe 都只寫名字(`gitea_token`/`cf_api_token`),真身由 credential 中心在執行前回填。
|
||||
對應的 auth-recipe **已經註冊在 leo21c 上**,可以直接查證:
|
||||
|
||||
```
|
||||
curl -s https://arcrun-cypher-executor.leo21c.workers.dev/auth-recipes/gitea
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、`hash` 零件(`#91`)
|
||||
|
||||
`hash-component/` — sha256/sha1/md5,hex/base64。出貨線的版本號機制與成品指紋核對都要它。
|
||||
|
||||
**已實測**(tinygo 編出來、wasmtime 真跑,三種演算法都跟系統原生指令**逐位元一致**)。
|
||||
`.wasm` 是 1.3 MB 編譯產物,**沒有進版控**——要驗自己重編:
|
||||
|
||||
```
|
||||
cd pending-human-gate/hash-component && tinygo build -target=wasi -o /tmp/hash.wasm main.go
|
||||
echo '{"algorithm":"sha256","input":"hello"}' | wasmtime /tmp/hash.wasm
|
||||
printf 'hello' | shasum -a 256 # 兩者應該一致
|
||||
```
|
||||
|
||||
**落地要走零件投稿流程**(D27/D28):`docs/component-pr-review-standard.md` 的 checklist
|
||||
+ 人在終端機互動跑 `scripts/component-arm.sh`。
|
||||
🔴 `registry/components/` 底下有機械閘(`component-guard.sh`)擋著 AI 直接寫入——**那是刻意的**,
|
||||
所以這份放在 `pending-human-gate/`,不是放在它最終該去的位置。
|
||||
|
||||
---
|
||||
|
||||
## 落地之後
|
||||
|
||||
三樣都上去之後,`Arcrun#89`/`#91` 才能從 **◐ 半通** 變 **✅**——
|
||||
而判準是**貼一次真實的執行輸出**(recipe 對某個測試檔案回 2xx、零件在真端點上跑出正確雜湊),
|
||||
不是「推上去了」。
|
||||
@@ -0,0 +1,74 @@
|
||||
canonical_id: "hash"
|
||||
display_name: "計算雜湊"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [input]
|
||||
properties:
|
||||
algorithm:
|
||||
type: string
|
||||
enum: [sha256, sha1, md5]
|
||||
description: 雜湊演算法,預設 sha256
|
||||
input:
|
||||
type: string
|
||||
description: 要算雜湊的內容
|
||||
encoding:
|
||||
type: string
|
||||
enum: [hex, base64]
|
||||
description: 輸出編碼,預設 hex
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: string
|
||||
algorithm:
|
||||
type: string
|
||||
encoding:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "sha256 hex(預設)"
|
||||
given: '{"algorithm":"sha256","input":"hello"}'
|
||||
then_contains: '"result":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"'
|
||||
- scenario: "sha1"
|
||||
given: '{"algorithm":"sha1","input":"hello"}'
|
||||
then_contains: '"result":"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"'
|
||||
- scenario: "md5"
|
||||
given: '{"algorithm":"md5","input":"hello"}'
|
||||
then_contains: '"result":"5d41402abc4b2a76b9719d911017c592"'
|
||||
- scenario: "base64 編碼"
|
||||
given: '{"algorithm":"sha256","input":"hello","encoding":"base64"}'
|
||||
then_contains: '"result":"LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="'
|
||||
- scenario: "預設 algorithm=sha256"
|
||||
given: '{"input":"hello"}'
|
||||
then_contains: '"algorithm":"sha256"'
|
||||
- scenario: "不支援的 algorithm"
|
||||
given: '{"algorithm":"crc32","input":"hello"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, logic, hash, checksum, versioning]
|
||||
description: >-
|
||||
計算內容雜湊(sha256/sha1/md5,輸出 hex 或 base64)。純計算,無網路/檔案 syscall。
|
||||
用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,是「改了東西版本沒動」在結構上
|
||||
不可能發生的機制來源;build 站核對官方成品指紋也用它。
|
||||
config_example: |
|
||||
compute_hash: # 節點名稱(可自訂)
|
||||
algorithm: "sha256" # 演算法(選填,預設 sha256),可選值:sha256/sha1/md5
|
||||
input: "{{ctx.bundle_content}}" # 要算雜湊的內容(必填)
|
||||
encoding: "hex" # 輸出編碼(選填,預設 hex),可選值:hex/base64
|
||||
@@ -0,0 +1,89 @@
|
||||
// hash — 計算內容雜湊(純計算,無網路/檔案 syscall)
|
||||
// 支援: sha256, sha1, md5;輸出編碼: hex(預設), base64
|
||||
// 用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,
|
||||
// 是「改了東西版本沒動」在結構上不可能發生的機制來源。
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Algorithm string `json:"algorithm"` // sha256(預設)| sha1 | md5
|
||||
Input string `json:"input"`
|
||||
Encoding string `json:"encoding"` // hex(預設)| base64
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var in Input
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
algorithm := in.Algorithm
|
||||
if algorithm == "" {
|
||||
algorithm = "sha256"
|
||||
}
|
||||
encoding := in.Encoding
|
||||
if encoding == "" {
|
||||
encoding = "hex"
|
||||
}
|
||||
|
||||
var sum []byte
|
||||
switch algorithm {
|
||||
case "sha256":
|
||||
h := sha256.Sum256([]byte(in.Input))
|
||||
sum = h[:]
|
||||
case "sha1":
|
||||
h := sha1.Sum([]byte(in.Input))
|
||||
sum = h[:]
|
||||
case "md5":
|
||||
h := md5.Sum([]byte(in.Input))
|
||||
sum = h[:]
|
||||
default:
|
||||
writeError("不支援的 algorithm: " + algorithm + "(支援 sha256/sha1/md5)")
|
||||
return
|
||||
}
|
||||
|
||||
var result string
|
||||
switch encoding {
|
||||
case "hex":
|
||||
result = hex.EncodeToString(sum)
|
||||
case "base64":
|
||||
result = base64.StdEncoding.EncodeToString(sum)
|
||||
default:
|
||||
writeError("不支援的 encoding: " + encoding + "(支援 hex/base64)")
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"result": result,
|
||||
"algorithm": algorithm,
|
||||
"encoding": encoding,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "arcrun-hash"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
workers_dev = true
|
||||
|
||||
[vars]
|
||||
COMPONENT_ID = "hash"
|
||||
|
||||
[[routes]]
|
||||
pattern = "hash.arcrun.dev/*"
|
||||
zone_name = "arcrun.dev"
|
||||
@@ -0,0 +1,21 @@
|
||||
canonical_id: cf_worker_deploy_simple
|
||||
display_name: Cloudflare Worker Deploy (single-file, classic format)
|
||||
description: >-
|
||||
PUT /accounts/{account_id}/workers/scripts/{script_name} 部署單檔 Worker(CF 「classic Service
|
||||
Worker」格式,非 ES module)。_path 帶 /{account_id}/workers/scripts/{script_name}。
|
||||
auth: cloudflare_workers static_key(Bearer token)。
|
||||
⚠️ 已知限制(誠實記錄,非隱藏債):這個 recipe 走 arcrun 的「recipe body 一律 JSON.stringify」
|
||||
引擎行為(cypher-executor/src/lib/component-loader.ts makeRecipeRunner),CF 這支 API 卻要求
|
||||
body 是「原始 JS 原始碼」或(現代 ES module + bindings 情境)multipart/form-data——兩者都不是
|
||||
JSON。純 recipe 模型在這支 API 上天生對不上,這不是可以在 recipe schema 裡修的事。
|
||||
正解=07-thin-shell §3.5 自力救濟階梯「第三方 API 缺能力→ workflow/code-node 補丁」:
|
||||
用 http_request 零件直接打(body 走它的原生 string 模式,不透過本 recipe wrapper),
|
||||
header 用 {{credential.cf_api_token}} 直接內插(D36 credential 模板,不必經過 recipe/auth_service
|
||||
間接層);若目標 Worker 需要 bindings/compatibility_flags(現代 ES module 格式常態),
|
||||
上游加一個 code 節點組出 multipart/form-data body(純資料編碼,非業務邏輯,合法局部整形)。
|
||||
本 recipe 保留給「目標帳號仍接受 classic 格式」的簡單場景;不保證覆蓋所有部署情境。
|
||||
endpoint: https://api.cloudflare.com/client/v4/accounts{{_path}}
|
||||
method: PUT
|
||||
auth_service: cloudflare_workers
|
||||
headers:
|
||||
Content-Type: application/javascript
|
||||
@@ -0,0 +1,11 @@
|
||||
canonical_id: gitea_put_file
|
||||
display_name: Gitea Put File (Create/Update)
|
||||
description: >-
|
||||
Gitea PUT /repos/{owner}/{repo}/contents/{filepath} 建立或更新檔案並產生 commit。
|
||||
_path 帶完整路徑(例 /Leo/arcrun-rag-bundles/contents/manifest.json,filepath 各段需 URL-encode)。
|
||||
body 帶 {message, content(base64), branch, sha(更新既有檔案時必填,取自前一次 GET 的 content.sha;
|
||||
新建檔案時不帶)}。auth: gitea static_key,header Authorization: token <TOKEN>(D36:定義只留
|
||||
{{credential.*}} 名字,真身由 credential 中心於執行前回填,非本 recipe 職責)。
|
||||
endpoint: https://git.uncle6.me/api/v1/repos{{_path}}
|
||||
method: PUT
|
||||
auth_service: gitea
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-worker-artifacts.mjs — Arcrun#80:tier2 worker(TS→ 可部署 JS)的**唯一官方編譯點**。
|
||||
*
|
||||
* 背景(Arcrun#80/arcrun-rag#39):這個 repo 過去只把 tier1 零件(TinyGo→wasm)的成品
|
||||
* commit 進 `.component-builds/{name}/component.wasm`;tier2(cypher-executor / kbdb /
|
||||
* http_request / code / mcp 這五顆 TS worker)只有原始碼,沒有編好的成品。於是
|
||||
* arcrun-rag 的安裝器只好自己在**它那邊**跑 esbuild(`installer/scripts/build-bundles.mjs`),
|
||||
* 結果同一份原始碼在不同機器編出不同位元組(見下「已知踩坑」),
|
||||
* 而且「這顆成品是哪個 commit 編的」只有整包一個 `source` 欄位,答不出單顆的來源。
|
||||
*
|
||||
* 本腳本要解的:
|
||||
* 1. 編譯只發生在這裡(Arcrun 本體),成品放固定位置 `.worker-builds/`,commit 進 repo——
|
||||
* 與 `.component-builds/*.wasm` 同一個既有慣例(self-host 用戶從 repo 直接拿部署來源)。
|
||||
* 2. 每顆成品自己記得「我是哪個 commit 編出來的」(`source_commit`,答到單顆目錄層級,
|
||||
* 不是整包一個欄位)。
|
||||
* 3. 同一個 commit、任何人任何時候編,位元組要相同——見下方兩個已知踩坑與對應解法。
|
||||
*
|
||||
* 已知踩坑(2026-08-10 實錄,docs-site/.../changelog.md 1.4.33 段;已回報 arcrun-rag#72):
|
||||
* ① esbuild 的 bundle 輸出會把「入口路徑」寫進產物內部的檔案邊界註解,而該路徑預設是
|
||||
* **相對於 esbuild 執行時的 cwd**——雲端容器跑在 `.../arcrun/`、地端跑在
|
||||
* `.../matrix/arcrun/`,同一份原始碼因此編出不同註解。
|
||||
* 解法:固定 `absWorkingDir` 為**本腳本自己算出的 repo 根目錄**(不吃外部 cwd/env
|
||||
* 路徑),entry 一律用「相對 repo 根目錄」的相對路徑餵給 esbuild——不管這個 clone
|
||||
* 實際被放在磁碟的哪個絕對路徑下,esbuild 內部算出的相對路徑字串都相同。
|
||||
* ② 各 worker 目錄的 node_modules 若用不同套件管理器(pnpm store vs npm 平鋪)安裝,
|
||||
* 可能夾帶不同版本的間接依賴(實測 ajv/uri-js 差 1019 行)。
|
||||
* 解法:本腳本**不自己 npm/pnpm install**——強制要求呼叫者先用「該目錄既有的
|
||||
* lockfile」(pnpm-lock.yaml 用 `pnpm install --frozen-lockfile`;package-lock.json
|
||||
* 用 `npm ci`)裝好 node_modules,並在建置前檢查 lockfile 是否存在,
|
||||
* lockfile 是「同一份依賴圖」的機械保證,比信任「兩台機器裝出來一樣」牢靠。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/build-worker-artifacts.mjs [--check-only]
|
||||
* --check-only:只驗證每個 worker 的 node_modules 是否已按 lockfile 裝好,不編譯。
|
||||
*
|
||||
* 輸出:.worker-builds/<name>/worker.mjs (+ *.wasm)、.worker-builds/manifest.json
|
||||
*/
|
||||
import esbuild from 'esbuild';
|
||||
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, basename, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
|
||||
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
|
||||
// 下面所有 esbuild 呼叫都用「相對 REPO」的相對路徑,輸出字串才會與絕對路徑無關。
|
||||
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||
const OUT = join(REPO, '.worker-builds');
|
||||
const CHECK_ONLY = process.argv.includes('--check-only');
|
||||
|
||||
/** 五顆 tier2 worker——與 arcrun-rag `installer/scripts/bundle-components.mjs`
|
||||
* CORE_COMPONENTS 的 build 參數對齊(那邊的 esbuild 呼叫即將被本腳本的成品取代)。
|
||||
* name 用 arcrun-rag 那邊的慣例(`arcrun-<kebab>`),方便安裝器直接對號。 */
|
||||
const WORKERS = [
|
||||
{ name: 'arcrun-cypher-executor', dir: 'cypher-executor', entry: 'src/index.ts', stripServices: true },
|
||||
{ name: 'arcrun-kbdb', dir: 'kbdb', entry: 'src/index.ts' },
|
||||
{ name: 'arcrun-http-request', dir: '.component-builds/http_request', entry: 'src/index.ts' },
|
||||
{ name: 'arcrun-code', dir: 'registry/components/code', entry: 'index.ts' },
|
||||
{ name: 'arcrun-mcp', dir: 'mcp', entry: 'src/index.ts' },
|
||||
];
|
||||
|
||||
function sha256(buf) {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/** 檢查一個 worker 目錄的 node_modules 是否已按它自己的 lockfile 裝好(踩坑②的閘)。 */
|
||||
function checkNodeModules(dir) {
|
||||
const abs = join(REPO, dir);
|
||||
const hasPnpmLock = existsSync(join(abs, 'pnpm-lock.yaml'));
|
||||
const hasNpmLock = existsSync(join(abs, 'package-lock.json'));
|
||||
if (!hasPnpmLock && !hasNpmLock) {
|
||||
return { ok: false, reason: `${dir} 沒有 pnpm-lock.yaml 也沒有 package-lock.json——依賴版本無法鎖定` };
|
||||
}
|
||||
if (!existsSync(join(abs, 'node_modules'))) {
|
||||
const cmd = hasPnpmLock ? 'pnpm install --frozen-lockfile' : 'npm ci';
|
||||
return { ok: false, reason: `${dir}/node_modules 不存在——先在該目錄跑:${cmd}` };
|
||||
}
|
||||
return { ok: true, via: hasPnpmLock ? 'pnpm (frozen)' : 'npm ci' };
|
||||
}
|
||||
|
||||
/** 極簡 wrangler.toml 讀取(沿用 arcrun-rag build-bundles.mjs 同款邏輯,只抓需要的欄位)。 */
|
||||
function readToml(tomlPath) {
|
||||
const t = existsSync(tomlPath) ? readFileSync(tomlPath, 'utf8') : '';
|
||||
const spec = { kv: [], d1: [], vectorize: [], ai: null, vars: {}, compat_flags: [], compat_date: null };
|
||||
const compatFlags = t.match(/compatibility_flags\s*=\s*\[([^\]]*)\]/);
|
||||
if (compatFlags) spec.compat_flags = [...compatFlags[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
|
||||
const compatDate = t.match(/compatibility_date\s*=\s*["']([^"']+)["']/);
|
||||
if (compatDate) spec.compat_date = compatDate[1];
|
||||
for (const m of t.matchAll(/\[\[kv_namespaces\]\][\s\S]*?binding\s*=\s*["']([^"']+)["']/g)) spec.kv.push(m[1]);
|
||||
for (const m of t.matchAll(/\[\[d1_databases\]\]([\s\S]*?)(?=\n\[|\n*$)/g)) {
|
||||
const b = m[1].match(/binding\s*=\s*["']([^"']+)["']/);
|
||||
const n = m[1].match(/database_name\s*=\s*["']([^"']+)["']/);
|
||||
if (b) spec.d1.push({ binding: b[1], database_name: n ? n[1] : null });
|
||||
}
|
||||
for (const line of t.split('\n')) {
|
||||
if (/^\s*\[\[vectorize\]\]/.test(line)) spec.vectorize.push(true);
|
||||
}
|
||||
if (/^\s*\[ai\]/m.test(t)) spec.ai = true;
|
||||
const varsBlock = t.match(/\[vars\]([\s\S]*?)(?=\n\[|\n*$)/);
|
||||
if (varsBlock) for (const m of varsBlock[1].matchAll(/^\s*([A-Z0-9_]+)\s*=\s*["']([^"']*)["']/gm)) spec.vars[m[1]] = m[2];
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** esbuild plugin:.wasm import 攤平成同目錄檔名,記下要一起複製的 wasm part。 */
|
||||
function wasmPlugin(wasmParts) {
|
||||
return {
|
||||
name: 'wasm-external',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /\.wasm$/ }, (args) => {
|
||||
const abs = resolve(args.resolveDir, args.path);
|
||||
const flat = basename(abs);
|
||||
if (!wasmParts.find((w) => w.part === flat)) wasmParts.push({ part: flat, abs });
|
||||
return { path: './' + flat, external: true };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 每個 worker 自己的 source_commit:答到單顆目錄層級,不是整包一個欄位(Arcrun#80 的核心要求)。 */
|
||||
function sourceCommitFor(dir) {
|
||||
try {
|
||||
const hash = execSync(`git log -1 --format=%H -- ${JSON.stringify(dir)}`, { cwd: REPO, encoding: 'utf8' }).trim();
|
||||
return hash || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function repoHead() {
|
||||
try {
|
||||
const sha = execSync('git rev-parse HEAD', { cwd: REPO, encoding: 'utf8' }).trim();
|
||||
// .worker-builds 是本腳本自己的輸出目錄——它在「寫出成品之前」永遠是 untracked,
|
||||
// 拿它判斷「原始碼乾不乾淨」是假陽性(自己把自己判成髒)。排除掉才是真正的
|
||||
// 「原始碼有沒有未 commit 的變更」。
|
||||
const dirty = execSync('git status --porcelain -- . ":(exclude).worker-builds"', { cwd: REPO, encoding: 'utf8' }).trim();
|
||||
return { sha, dirty: !!dirty };
|
||||
} catch {
|
||||
return { sha: null, dirty: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function buildOne(w) {
|
||||
const dirAbs = join(REPO, w.dir);
|
||||
const entryAbs = join(dirAbs, w.entry);
|
||||
if (!existsSync(entryAbs)) throw new Error(`entry 不存在: ${entryAbs}`);
|
||||
const outDir = join(OUT, w.name);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// 踩坑①的解法核心:entry 用「相對 REPO」的路徑,absWorkingDir 固定為 REPO——
|
||||
// esbuild 內部產生的檔案邊界字串因此只依賴這個相對路徑,與這個 clone 實際被
|
||||
// 放在磁碟的哪個絕對路徑無關。
|
||||
const entryRel = relative(REPO, entryAbs);
|
||||
|
||||
const wasmParts = [];
|
||||
const result = await esbuild.build({
|
||||
absWorkingDir: REPO,
|
||||
entryPoints: [entryRel],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
outfile: relative(REPO, join(outDir, 'worker.mjs')),
|
||||
external: ['cloudflare:*', 'node:*'],
|
||||
plugins: [wasmPlugin(wasmParts)],
|
||||
logLevel: 'silent',
|
||||
metafile: true,
|
||||
});
|
||||
|
||||
const modules = [];
|
||||
for (const wp of wasmParts) {
|
||||
if (!existsSync(wp.abs)) throw new Error(`wasm 找不到: ${wp.abs}(該 worker 需先 build/vendored wasm)`);
|
||||
copyFileSync(wp.abs, join(outDir, wp.part));
|
||||
modules.push({ name: wp.part, type: 'application/wasm', file: `${w.name}/${wp.part}`, sha256: sha256(readFileSync(wp.abs)) });
|
||||
}
|
||||
|
||||
const spec = readToml(join(dirAbs, 'wrangler.toml'));
|
||||
const jsBuf = readFileSync(join(outDir, 'worker.mjs'));
|
||||
return {
|
||||
name: w.name,
|
||||
source_dir: w.dir,
|
||||
source_commit: sourceCommitFor(w.dir),
|
||||
main_module: 'worker.mjs',
|
||||
main_file: `${w.name}/worker.mjs`,
|
||||
js_bytes: jsBuf.length,
|
||||
content_sha256: sha256(jsBuf),
|
||||
modules,
|
||||
compat_date: spec.compat_date,
|
||||
compat_flags: spec.compat_flags,
|
||||
requires: {
|
||||
kv: spec.kv,
|
||||
d1: spec.d1,
|
||||
vectorize: spec.vectorize.length,
|
||||
ai: !!spec.ai,
|
||||
vars: spec.vars,
|
||||
},
|
||||
stripped: w.stripServices ? { services: 13 } : undefined,
|
||||
warnings: result.warnings.map((x) => x.text),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`REPO = ${REPO}`);
|
||||
|
||||
const precheck = WORKERS.map((w) => ({ w, chk: checkNodeModules(w.dir) }));
|
||||
const failed = precheck.filter((p) => !p.chk.ok);
|
||||
if (failed.length) {
|
||||
console.error('\n❌ 建置中止:以下 worker 尚未按 lockfile 裝好依賴(踩坑②的閘):\n');
|
||||
for (const f of failed) console.error(` - ${f.chk.reason}`);
|
||||
console.error('\n這是刻意設計:本腳本不自己跑 install,避免「install 方式不同 → 依賴版本不同 → 位元組不同」。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✔ node_modules 檢查通過:');
|
||||
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
|
||||
|
||||
if (CHECK_ONLY) {
|
||||
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
for (const w of WORKERS) {
|
||||
const d = join(OUT, w.name);
|
||||
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const head = repoHead();
|
||||
const manifest = {
|
||||
schema: 1,
|
||||
built_for: 'arcrun-tier2-worker-artifacts',
|
||||
generated_at: new Date().toISOString(),
|
||||
repo_head: head.sha,
|
||||
repo_dirty: head.dirty,
|
||||
workers: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
let failCount = 0;
|
||||
for (const w of WORKERS) {
|
||||
try {
|
||||
const entry = await buildOne(w);
|
||||
manifest.workers.push(entry);
|
||||
const wasmNote = entry.modules.length ? ` +${entry.modules.length} wasm` : '';
|
||||
console.log(`✔ ${w.name} js=${(entry.js_bytes / 1024).toFixed(0)}KB sha256=${entry.content_sha256.slice(0, 12)} source=${(entry.source_commit || '').slice(0, 8)}${wasmNote}`);
|
||||
} catch (e) {
|
||||
manifest.notes.push(`FAILED ${w.name}: ${e.message}`);
|
||||
console.error(`✗ ${w.name}: ${e.message}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nmanifest → ${join(OUT, 'manifest.json')} (${manifest.workers.length}/${WORKERS.length} built)`);
|
||||
if (failCount > 0 || head.dirty) {
|
||||
if (head.dirty) console.error('⚠️ 工作區不乾淨(有未 commit 的變更)——這份成品的 repo_head 標記不完全可信,僅供本地驗證用。');
|
||||
if (failCount > 0) process.exit(1);
|
||||
}
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
+42
-3
@@ -26,9 +26,29 @@ t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2";
|
||||
# tn = 不換行版(給 prompt 用)
|
||||
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
|
||||
|
||||
REPO_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/template"
|
||||
# 來源預設=公開 GitHub(leo 2026-07-21:「要發佈的正稿,從頭就不要用奇怪的網址,
|
||||
# 以免改來改去」)。舊的 uncle6me-web 帳號已被 suspend(讀 404)=自動更新靜默失效,
|
||||
# 這顆雷已在 system-dev-template 本體修過(正解=改指 youlinhsieh 這個公開帳號),
|
||||
# 這裡只是把同一個修法補進 Arcrun 這份沒跟到的 copy。
|
||||
# 內部要指私有草稿源時用環境變數覆寫,不改檔:
|
||||
# TEMPLATE_SOURCE=https://<私有 raw base> bash scripts/install.sh
|
||||
TEMPLATE_SOURCE="${TEMPLATE_SOURCE:-https://raw.githubusercontent.com/youlinhsieh/system-dev-template/main}"
|
||||
REPO_URL="$TEMPLATE_SOURCE/template"
|
||||
CREATED=()
|
||||
SKIPPED=()
|
||||
FAILED=()
|
||||
|
||||
# 404 頁面常常「非空」(GitHub raw 對不存在的路徑回 "404: Not Found"),
|
||||
# 只判斷 [ -s file ] 會把錯誤頁內容當成檔案寫進去且完全不報錯(template issue #13 撞過的雷)。
|
||||
# 這裡量身做一個輕量判斷:檔案很短又長得像錯誤訊息 → 當失敗。
|
||||
looks_like_error_page() {
|
||||
local f="$1"
|
||||
[ -s "$f" ] || return 0
|
||||
if [ "$(wc -l < "$f" | tr -d ' ')" -le 2 ] && head -c 200 "$f" | grep -qiE '40[0-9]|not found|<html'; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 解析模組參數 ──────────────────────────────────
|
||||
MODULE=""
|
||||
@@ -211,8 +231,12 @@ download_if_missing() {
|
||||
local dest="$1" src="$2"
|
||||
if [ ! -f "$dest" ]; then
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
curl -sSL "$src" -o "$dest"
|
||||
CREATED+=("$dest")
|
||||
if curl -sSL "$src" -o "$dest" 2>/dev/null && ! looks_like_error_page "$dest"; then
|
||||
CREATED+=("$dest")
|
||||
else
|
||||
rm -f "$dest"
|
||||
FAILED+=("$dest $(tn "(來源抓不到:$src)" "(source unreachable: $src)")")
|
||||
fi
|
||||
else
|
||||
SKIPPED+=("$dest $(tn '(已存在,跳過)' '(already exists, skipped)')")
|
||||
fi
|
||||
@@ -332,6 +356,16 @@ if [ ${#SKIPPED[@]} -gt 0 ]; then
|
||||
for item in "${SKIPPED[@]}"; do echo " - $item"; done
|
||||
fi
|
||||
|
||||
# 來源抓不到要出聲,不能安靜吞掉——404 內容不會被寫進檔案(已在 download_if_missing 擋掉),
|
||||
# 但使用者必須知道「這幾個檔沒裝到」,不然會誤以為裝完整了。
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "❌ 抓取失敗(來源不可達,未寫入任何檔案):" "❌ Fetch failed (source unreachable, no file was written):"
|
||||
for item in "${FAILED[@]}"; do echo " x $item"; done
|
||||
t " 請檢查網路,或用 TEMPLATE_SOURCE=<其他來源> 重跑。" \
|
||||
" Check your network, or rerun with TEMPLATE_SOURCE=<alternate source>."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "─────────────────────────────────"
|
||||
|
||||
@@ -429,3 +463,8 @@ fi
|
||||
t " GitHub issue:CC 可直接 /issue-handle 讀回自己 repo 的 issue(禁自動輪詢)" \
|
||||
" GitHub issues: CC can use /issue-handle to read issues from its own repo (no auto-polling)"
|
||||
echo ""
|
||||
|
||||
# 有任何來源抓取失敗 → 用非零 exit code 出聲,不能靜靜地當「裝完了」收工。
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "arcrun-build-tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Arcrun#80: tier2 worker 編譯工具的獨立依賴——esbuild 版本鎖在這份 lockfile,任何人在任何機器編譯都用同一版 esbuild(重現性的一部分:工具版本也是輸入之一)。",
|
||||
"devDependencies": {
|
||||
"esbuild": "0.24.0"
|
||||
}
|
||||
}
|
||||
Generated
+265
@@ -0,0 +1,265 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
esbuild:
|
||||
specifier: 0.24.0
|
||||
version: 0.24.0
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.24.0':
|
||||
resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.24.0':
|
||||
resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.24.0':
|
||||
resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.24.0':
|
||||
resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.24.0':
|
||||
resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.24.0':
|
||||
resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.24.0':
|
||||
resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.24.0':
|
||||
resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.24.0':
|
||||
resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.24.0':
|
||||
resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.24.0':
|
||||
resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.24.0':
|
||||
resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.24.0':
|
||||
resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-x64@0.24.0':
|
||||
resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.24.0':
|
||||
resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.24.0':
|
||||
resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.24.0':
|
||||
resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.24.0':
|
||||
resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
esbuild@0.24.0:
|
||||
resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
esbuild@0.24.0:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.24.0
|
||||
'@esbuild/android-arm': 0.24.0
|
||||
'@esbuild/android-arm64': 0.24.0
|
||||
'@esbuild/android-x64': 0.24.0
|
||||
'@esbuild/darwin-arm64': 0.24.0
|
||||
'@esbuild/darwin-x64': 0.24.0
|
||||
'@esbuild/freebsd-arm64': 0.24.0
|
||||
'@esbuild/freebsd-x64': 0.24.0
|
||||
'@esbuild/linux-arm': 0.24.0
|
||||
'@esbuild/linux-arm64': 0.24.0
|
||||
'@esbuild/linux-ia32': 0.24.0
|
||||
'@esbuild/linux-loong64': 0.24.0
|
||||
'@esbuild/linux-mips64el': 0.24.0
|
||||
'@esbuild/linux-ppc64': 0.24.0
|
||||
'@esbuild/linux-riscv64': 0.24.0
|
||||
'@esbuild/linux-s390x': 0.24.0
|
||||
'@esbuild/linux-x64': 0.24.0
|
||||
'@esbuild/netbsd-x64': 0.24.0
|
||||
'@esbuild/openbsd-arm64': 0.24.0
|
||||
'@esbuild/openbsd-x64': 0.24.0
|
||||
'@esbuild/sunos-x64': 0.24.0
|
||||
'@esbuild/win32-arm64': 0.24.0
|
||||
'@esbuild/win32-ia32': 0.24.0
|
||||
'@esbuild/win32-x64': 0.24.0
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
+45
-4
@@ -23,18 +23,43 @@ esac
|
||||
t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; }
|
||||
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
|
||||
|
||||
REPO_RAW="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main"
|
||||
# 來源預設=公開 GitHub(leo 2026-07-21:「要發佈的正稿,從頭就不要用奇怪的網址,
|
||||
# 以免改來改去」)。舊的 uncle6me-web 帳號已被 suspend(讀 404)=自動更新靜默失效,
|
||||
# 這顆雷已在 system-dev-template 本體修過(正解=改指 youlinhsieh 這個公開帳號),
|
||||
# 這裡只是把同一個修法補進 Arcrun 這份沒跟到的 copy。
|
||||
# 內部要指私有草稿源時用環境變數覆寫,不改檔:
|
||||
# TEMPLATE_SOURCE=https://<私有 raw base> bash scripts/update.sh
|
||||
REPO_RAW="${TEMPLATE_SOURCE:-https://raw.githubusercontent.com/youlinhsieh/system-dev-template/main}"
|
||||
TEMPLATE_URL="$REPO_RAW/template"
|
||||
|
||||
UPDATED=()
|
||||
KEPT=()
|
||||
NEW=()
|
||||
TEMPLATED=()
|
||||
FAILED=()
|
||||
|
||||
# 404 頁面常常「非空」(GitHub raw 對不存在的路徑回 "404: Not Found"),
|
||||
# 只判斷 [ -s file ] 會把錯誤頁內容當成檔案寫進去且完全不報錯(template issue #13 撞過的雷)。
|
||||
looks_like_error_page() {
|
||||
local f="$1"
|
||||
[ -s "$f" ] || return 0
|
||||
if [ "$(wc -l < "$f" | tr -d ' ')" -le 2 ] && head -c 200 "$f" | grep -qiE '40[0-9]|not found|<html'; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 版本比對:先看本機 vs 遠端,給使用者「值不值得更新」的判斷 ──
|
||||
LOCAL_VER="$(tn '(未知)' '(unknown)')"
|
||||
[ -f ".claude/VERSION" ] && LOCAL_VER="$(tr -d '[:space:]' < .claude/VERSION)"
|
||||
REMOTE_VER="$(curl -sSL "$TEMPLATE_URL/.claude/VERSION" 2>/dev/null | tr -d '[:space:]' || echo '')"
|
||||
# 容錯:curl 對 404 會把「404: Not Found」當內容輸出(非空),舊版直接把它寫進 REMOTE_VER
|
||||
# 拿去跟本機版本比對,比對邏輯不會報錯、只會靜靜判斷「版本不同」或誤判「已最新」。
|
||||
# 這裡驗證必須像版號(X.Y.Z),否則一律視為取不到。
|
||||
case "$REMOTE_VER" in
|
||||
[0-9]*.[0-9]*.[0-9]*) : ;; # 形如 1.9.0 → 合法
|
||||
*) REMOTE_VER="" ;; # 404 / HTML 錯誤頁 / 其他 → 當作沒抓到
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "🔄 system-dev-template updater"
|
||||
@@ -64,7 +89,7 @@ update_file() {
|
||||
local dest="$1" src="$2"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
if [ -f "$dest" ]; then
|
||||
if curl -sSL "$src" -o "$dest.tmp" 2>/dev/null && [ -s "$dest.tmp" ]; then
|
||||
if curl -sSL "$src" -o "$dest.tmp" 2>/dev/null && ! looks_like_error_page "$dest.tmp"; then
|
||||
if cmp -s "$dest" "$dest.tmp"; then
|
||||
rm -f "$dest.tmp" # 內容相同,不算更新
|
||||
else
|
||||
@@ -73,13 +98,15 @@ update_file() {
|
||||
fi
|
||||
else
|
||||
rm -f "$dest.tmp"
|
||||
FAILED+=("$dest")
|
||||
t " ⚠️ 抓取失敗,保留原檔:$dest" " ⚠️ Download failed, keeping the original: $dest"
|
||||
fi
|
||||
else
|
||||
if curl -sSL "$src" -o "$dest" 2>/dev/null && [ -s "$dest" ]; then
|
||||
if curl -sSL "$src" -o "$dest" 2>/dev/null && ! looks_like_error_page "$dest"; then
|
||||
NEW+=("$dest") # 新功能:舊版沒有的檔
|
||||
else
|
||||
rm -f "$dest"
|
||||
FAILED+=("$dest")
|
||||
t " ⚠️ 抓取失敗:$dest" " ⚠️ Download failed: $dest"
|
||||
fi
|
||||
fi
|
||||
@@ -98,7 +125,7 @@ keep_with_template() {
|
||||
if [ -f "$dest" ]; then
|
||||
KEPT+=("$dest")
|
||||
local tmpl="${dest%.sh}.template.sh"
|
||||
if curl -sSL "$src" -o "$tmpl.tmp" 2>/dev/null && [ -s "$tmpl.tmp" ]; then
|
||||
if curl -sSL "$src" -o "$tmpl.tmp" 2>/dev/null && ! looks_like_error_page "$tmpl.tmp"; then
|
||||
if [ -f "$tmpl" ] && cmp -s "$tmpl" "$tmpl.tmp"; then
|
||||
rm -f "$tmpl.tmp" # 模板版沒變,不重複提示
|
||||
else
|
||||
@@ -227,8 +254,22 @@ if [ -f ".claude/settings.json" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 有任何檔案抓取失敗要出聲,不能安靜吞掉(404 內容已被 looks_like_error_page 擋掉、
|
||||
# 不會污染既有檔案,但使用者必須知道「這幾個檔沒更新到」)。
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "❌ 抓取失敗(來源不可達,原檔已保留不動):" "❌ Fetch failed (source unreachable, original files kept untouched):"
|
||||
for f in "${FAILED[@]}"; do echo " x $f"; done
|
||||
t " 請檢查網路,或用 TEMPLATE_SOURCE=<其他來源> 重跑。" \
|
||||
" Check your network, or rerun with TEMPLATE_SOURCE=<alternate source>."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
t "🚀 更新完成:${LOCAL_VER} → ${REMOTE_VER}" "🚀 Update complete: ${LOCAL_VER} → ${REMOTE_VER}"
|
||||
t " 下次更新直接跑:bash scripts/update.sh" " Next time, just run: bash scripts/update.sh"
|
||||
t " 改了什麼看:CHANGELOG.md" " See what changed: CHANGELOG.md"
|
||||
echo ""
|
||||
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -21,6 +21,18 @@ degree 排序/predicate 統計/跨庫 join 是聚合 SQL——**D6 鐵律:
|
||||
|
||||
ingest 完成 → 取本次 commit diff 涉及的庫集合 → 逐庫呼 `/map/recompute`。無 cron 全量。首次 backfill=對每個既有庫手動各呼一次(installer/腳本一行)。
|
||||
|
||||
> **2026-08-08 更正(matrix/arcrun CC)**:上面這條「ingest 尾端接鏈」的路徑本身沒錯(`arcrun-rag`
|
||||
> 那條管線也確實接了,`670f38a`),但它**只覆蓋接了鏈的那一條 ingest**——這個 repo 內(kbdb/
|
||||
> cypher-executor/mcp)從沒有任何呼叫點會打 `/map/recompute`,導致沒手動 backfill 過的租戶
|
||||
> (絕大多數)恆空,且拖了三週沒人發現/接上(見 `system-dev/wiki/mistakes.md` 08-08 段)。
|
||||
> **改法**(leo 否決「降級成即時聚合、不維護快取」的提案,因為那會丟失 narrative 這類摘要
|
||||
> 本體):`GET /map`/`GET /map/:library` 讀端自己核對即時三元組數,落差就地呼叫既有的
|
||||
> `recomputeLibraryMap` 補算(`kbdb/src/actions/library-map.ts` `ensureFreshLibraryMaps`)。
|
||||
> 聚合 SQL 仍只住 kbdb base(沒有違反 §2 的歸屬裁定),只是觸發時機從「等外部呼叫」改成
|
||||
> 「讀的當下順手核對」——這條讀端機制本身就是「無 cron 全量」的自動 backfill,取代了
|
||||
> 「首次 backfill=對每個既有庫手動各呼一次」這句手動步驟。細節見
|
||||
> `system-dev/docs/3-specs/library-map/tasks.md` M3 段。
|
||||
|
||||
## 4. 注入(leo spec §5,本功能重點)
|
||||
|
||||
- **MCP instructions**:arcrun-mcp 啟動組 instructions 時拉 `GET /map` 嵌入(快取+TTL,或每次連線現拉——量數百 token,現拉可接受)。
|
||||
|
||||
@@ -6,8 +6,72 @@
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | `library_map` Template+slots 定義(含 triplet 按庫過濾現況核實;不足則 Triplet template 加 optional library slot) | B | — | ✅ 07-19(PR#72 merge) | D6 零建表。核實:triplet 無 library slot → 已走預案(design §1 核實結果) |
|
||||
| M2 | kbdb base `POST /map/recompute?library=`+`GET /map`/`GET /map/:library`(聚合 SQL 住基本盤;交易式 supersede) | B | M1 | ✅ 07-19(PR#72 merge;leo21c `33d88016`+demo `699018af` 已部署+backfill:leo21c kb 111/notes 108、demo general 23) | PR+測試(真 SQLite 驗聚合);merge 後 gated 部署(leo 閘)+逐庫 backfill recompute |
|
||||
| M3 | ingest 尾端接鏈:diff 涉及庫 → 逐庫呼 recompute(rag-ingest-cards v2+個人庫 ingest 同款改版) | A | M2 | ✅ 07-19(arcrun-rag `670f38a`;demo e2e 雙向通過:push/刪卡皆自動 recompute;leo21c 等 T-flip) | workflow 改版走 bundle 分發 |
|
||||
| M3 | 讓地圖跟得上資料、全租戶自動 backfill(原訂做法:ingest 尾端接鏈逐庫呼 recompute) | B | M2 | 🔁 **07-19 標的 ✅ 是誤報,08-08 更正並改法重做** | 見下方 08-08 段 |
|
||||
| M4 | MCP:instructions 注入全館地圖+`get_map` 工具 | B | M2 | ✅ 07-19(PR#73 merge;leo21c `1e73da90` 部署,MCP instructions 實載地圖) | 與 #68 同族薄殼;`kbdb_get_map`+connect 時注入(isolate TTL 快取,失敗靜默略過不擋連線);merge 後 gated redeploy arcrun-mcp(leo 閘) |
|
||||
| M5 | GUI 首頁:全館地圖 render(console+portal) | B | M2 | ✅ 07-19(PR#74 merge;leo21c cypher `9bc3a1f3` 部署) | 取代空白搜尋框 |
|
||||
| M6 | D30 連動:map 層 embed+semantic 庫路由第一跳 | B | M2 | ⬜ | #58/#59/#60 家族的第一片治本 |
|
||||
| M7 | dogfood:leo 庫(leo21c)首個實例 backfill+驗收(requirements 驗收段全項) | A | M3-M5 | 🔄 demo 側實質驗過;leo21c 正式驗收單(requirements 全項)待做 | 過了才進 demo/客戶 |
|
||||
|
||||
### M3 更正(2026-08-08,matrix/arcrun CC,總管交辦)
|
||||
|
||||
**07-19 標 ✅ 是誤報**:`arcrun-rag 670f38a` 只接了 rag-ingest-cards 那一條管線,**repo 內
|
||||
(`grep -rn "map/recompute" --include=*.ts` 排除 node_modules/.github-public)查無任何呼叫點**,
|
||||
三週來沒手動 backfill 過的租戶(絕大多數、含 youlin 與所有新租戶)`GET /map` 恆回空,
|
||||
`kbdb_get_map` 的 MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算(M3)」——那是假話(詳見
|
||||
`system-dev/wiki/mistakes.md` 08-08 段「藏書地圖 `kbdb_get_map` 對多數租戶永遠是空的」)。
|
||||
|
||||
**leo 裁示**:總管原提兩案(①接上 M3 原訂設計/②降級成只算 count 的即時聚合,不維護快取),
|
||||
leo 否決②——「**藏書地圖就是 arcrun 的最重要功能,讓 AI 一眼看到所有庫的摘要**」,
|
||||
即時聚合算得出 count、算不出 narrative,降級等於砍功能。
|
||||
|
||||
**改法(非①非②,第三案)**:不再依賴任何外部呼叫者(ingest workflow)記得呼
|
||||
`POST /map/recompute`——那條線跨 repo/跨租戶,已證實三週沒人接上,天生脆弱。改成
|
||||
`GET /map`/`GET /map/:library` 讀端自己核對即時三元組數,落差就地呼叫既有的
|
||||
`recomputeLibraryMap`(`kbdb/src/actions/library-map.ts` `ensureFreshLibraryMaps`)補算。
|
||||
聚合 SQL 沒有第二套、narrative/relation_profile/bridges 這些摘要欄位原封不動——不是砍成
|
||||
只算數字,只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。同時解掉:
|
||||
① 全租戶自動 backfill(不需要任何人做任何事)② 跟得上資料(下一筆 ingest 進來,
|
||||
下一次讀就反映)③ 不再依賴跨 repo 的 ingest 接鏈。
|
||||
|
||||
`narrative` 欄位有一個已知誠實限制:這個機制只**保留**既有 narrative(不會被自動重算洗掉),
|
||||
但不會**生成**新的 narrative——沒人手動填過、也沒有 ingest 端寫過的庫,narrative 仍是空的
|
||||
(`content` 顯示「(narrative 待 ingest 補寫)」)。這是 design §1 本來就承認的缺口
|
||||
(narrative 抽自 wiki 首段,屬內容語意萃取,非聚合 SQL 能生出來),非本次新增。
|
||||
|
||||
**驗證**:`kbdb/tests/library-map.test.ts` 新增 6 案(全綠,18/18)涵蓋:從未手動 recompute
|
||||
即自動補齊/跟得上新資料(不手動重算,數字自動更新)/narrative 不被靜默洗掉/
|
||||
`GET /map/:library` 誠實分辨查無此庫(404) vs 已知空庫(200)/owner 隔離/無 triplet template
|
||||
不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。
|
||||
tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
|
||||
`kbdb_get_map` 回 `{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。
|
||||
|
||||
### M3 止血(2026-08-11,Arcrun#87,總管交辦「動工前的量測」comment 第四節)
|
||||
|
||||
**08-08 那次改法本身留了一個判準缺口,這次補上**:`ensureFreshLibraryMaps` 比對
|
||||
「即時三元組數」(`liveTripletCountsByLibrary`)與「快取的地圖數」(`recomputeLibraryMap`
|
||||
算出來寫進去的),但兩邊的 status 過濾不一致——`recomputeLibraryMap` 只算
|
||||
`COALESCE(status,'active')='active'`,`liveTripletCountsByLibrary` 完全不濾 status。
|
||||
只要一個庫裡混了任何一筆 superseded/deprecated triplet,兩邊數字就永遠對不上,
|
||||
`ensureFreshLibraryMaps` 就永遠判定 stale ⇒ **每次讀地圖都觸發重算,每次都新建一筆
|
||||
library_map record(superseded 舊的),無止盡寫 D1**——且加劇 `recomputeLibraryMap`
|
||||
本身非原子 supersede 的既有競態(更高重算頻率 = 更高並發重算機率),是 `kb` 庫
|
||||
全部 44 筆被標 superseded、`notes` 庫兩筆同時 active(`arcrun-rag#50`)這兩個症狀的
|
||||
共同根因之一。
|
||||
|
||||
**修法**:`liveTripletCountsByLibrary`(`kbdb/src/actions/library-map.ts`)的 SQL 改成
|
||||
先 pivot 出每筆 triplet record 的 status,再套用與 `recomputeLibraryMap` 逐字一致的
|
||||
`COALESCE(status,'active')='active'` 過濾,兩邊判準對齊後,資料未變動時兩個計數必然相等,
|
||||
stale 判定回歸「真的有資料變動才 stale」。
|
||||
|
||||
**驗證**:新增迴歸案「Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次
|
||||
觸發重算」(`kbdb/tests/library-map.test.ts`,19/19 全綠);反向驗證過——把同一顆測試跑在
|
||||
修前的舊 SQL 上會失敗(`library_map` record 數 2 vs 期望 1),證明測試真的釘住這個 bug、
|
||||
不是空氣測試。另外用 leo21c MCP 連線(`bfezv28v`)連讀兩次 `kbdb_get_map()`(無中間寫入)
|
||||
獨立重現修前症狀:`general` 庫 `updated_at` 從 `1786457080` 前進到 `1786457114`。
|
||||
|
||||
**尚待**:改動只在分支 `fix/library-map-recompute-loop-87-v3`(未 push、未部署 leo21c);
|
||||
既有 100 筆 library_map 殘骸(`kb` 44 筆 superseded/`general` 41/`notes` 2)未清——
|
||||
清除需要一個目前不存在的 DELETE 通道(cypher-executor 的 `/kbdb/records/:id` proxy 只有
|
||||
GET/POST/PATCH,無 DELETE;kbdb base 自己雖有 `DELETE /records/:recordId` 但走 leo21c
|
||||
需要 `KBDB_INTERNAL_TOKEN`,非 CC 可持有的機密)——待總管部署本修法+視情況補一支
|
||||
DELETE proxy 後再清。
|
||||
|
||||
@@ -487,3 +487,32 @@ leo 的判準(2026-07-30):
|
||||
- **影響分析**:新公開端點 1 個(租戶 key 認證,吐的是該租戶自己部署的 workflow 定義;
|
||||
無跨租戶讀);CLI 新命令 2 個(薄殼,能力全在既有 API);無 schema migration、無新金鑰面。
|
||||
- **狀態**:⏳ 待 leo confirm 後 3.9b 接線。
|
||||
|
||||
|
||||
## 提案:Arcrun App ↔ Portal 掛載協定 v0(2026-08-09,Leo/Arcrun#82)
|
||||
|
||||
- **設計全文住在票上,本檔只留指針**(leo 08-09 立:設計寫在 issue 上,別讓下一個人再翻一次源碼)
|
||||
→ `Leo/Arcrun#82` 的「設計提案:Arcrun App ↔ Portal 掛載協定 v0」留言。
|
||||
- **觸發**:leo 08-09——「要變成是一個 Arcrun App,含前端、template、slots 的設置,任何人可以
|
||||
安裝後他的 Arcrun 就跳出筆記功能」+「Mira 的那些功能萃取都要變成可安裝」+追加指示
|
||||
「等你搞清楚 Mira 的功能時直接改成 App 模式,**不要分兩段**,不然開發好了又要一次遷移」。
|
||||
- **病根(實查)**:Portal 是單檔 HTML,「有哪些頁」在 `console-ui/public/portal/index.html`
|
||||
裡寫了四遍(側欄 :253-259/tab :510-516/VIEWS :718/allowedViews :721-729);出貨時整包
|
||||
內嵌成單檔 worker(`build-ui-bundle.mjs:154`),安裝器只會「整顆換掉」、無任何掛載點概念。
|
||||
⇒ 加一個能力=改核心+重出 release。`Leo/mira#2` 那批能力若照現況搬,就是逐個焊死。
|
||||
- **變更面**:① KBDB seed 一列 `arcrun_app` template(零新表,加進 `lib/portal-seeds.ts:21`)
|
||||
② `/portal/session`(`routes/portal.ts:495-515`)多回唯讀 `apps`
|
||||
③ 新增泛用端點 `POST /portal/apps/:id/actions/:name`(server 側代打既有 named webhook,
|
||||
金鑰不外流,形狀同既有 `/portal/data/upload`,但**泛用**)
|
||||
④ Portal 一次性泛用 loader(約 60 行)⑤ `acr app build|install|remove|list`
|
||||
⑥ `arcrun-app.yaml` 的 JSON Schema(repo 目前無「可安裝單位」schema,這是第一份)。
|
||||
- **不動的牆**:KBDB 零 SQL、永不加表;App 前端不進 `arcrun-rag-ui` bundle、不進
|
||||
`bundle-components.mjs`(否則回到「改核心才能加能力」);App 拿不到 session token
|
||||
與租戶字串,資料一律走既有 `/portal/data/*` 的 owner_id+library enforce;
|
||||
App 自帶 workflow 必須自帶預編圖(冷實例即時編圖 25.7s > 15s timeout 的既有教訓)。
|
||||
- **影響分析**:現行 active SDD=`workflow-discovery`,本提案**不作廢它任何任務**,屬新增一卷;
|
||||
新公開端點 1 個(portal session 認證);新 template 1 列;無 schema migration、無新金鑰面;
|
||||
既有 `/portal/data/upload` 可事後收編成一個 App,不必第一天做。
|
||||
- **⏸ 等 leo 裁的兩題(票上 §七)**:① App 前端跑 Portal origin(ES module)還是 iframe
|
||||
——短期 App 是否都由我們自己寫?② v0 只開 `nav` 一個掛載點夠不夠?
|
||||
- **狀態**:⏳ 待 confirm。沒 confirm 前照現行 SDD 走,不開新 SDD、不動功能程式碼。
|
||||
|
||||
@@ -325,6 +325,57 @@
|
||||
已登記庫帶 stats/auto 庫帶 stats/空庫 card_count=triplet_count=0)。
|
||||
vitest + node --check 待 leo 驗收環境跑(本機無 Workers runtime)。
|
||||
|
||||
- [x] **P8-資源搭配:節點輸出 KV 寫入只服務 PIPE 讀者(2026-08-09,任務層小改)**:
|
||||
來源=arcrun-rag `system-dev/docs/3-specs/pending-changes.md` P8「短板齊平」(leo 08-08 裁決通過)
|
||||
+ leo 08-09「不需要換模型,要調整每個 CF 數字搭配」(模型切換已 revert `894d9ab`,本項不碰模型)。
|
||||
病:BUILD-006 讓**每個節點**(含 FOREACH 每一圈)把輸出 put 進 EXEC_CONTEXT KV,
|
||||
但全 codebase 唯一讀點是 PIPE 邊的 `kvGetNodeOutput`——rag 系工作流(ON_SUCCESS+對每個)
|
||||
完全沒有 PIPE 邊 ⇒ 一張卡 15 次 put 全是寫了沒人讀的。
|
||||
KV 免費層 1,000 write/日 ÷ 15 ≈ **66 檔/日=全系統真正最短的板**(比 neurons 的 119 檔/日更短,
|
||||
且免金鑰路與 Gemini 路都吃這條)。§3.8.2 原盤點只算到 execution-logger 那 1 筆,漏了這 15 筆。
|
||||
修:`graph-executor.ts` 節點輸出寫 KV 前檢查「該節點有 PIPE 出邊」才寫;
|
||||
PIPE 工作流(含「完成後」與未知語意詞預設 PIPE)行為不變,resume 路徑(`resumeFromPaused`)不動。
|
||||
實測(youlin stage,兩張同構測試卡 5 blocks+6 triplets):
|
||||
修前 run `rag_ingest_card-1786208572245` 留 6 個 node key(15 次 put);
|
||||
修後同形狀執行 EXEC_CONTEXT **零 key**;blocks/triplets 照樣寫入成功(KBDB record id 全數回傳)。
|
||||
單元測試:`tests/executor.test.ts`「P8:節點輸出 KV 寫入只服務 PIPE 讀者」——
|
||||
無 PIPE 圖 put=0/PIPE 圖照舊寫且 `_kv_outputs` 傳遞不變(12 passed;既有 1 failed 為
|
||||
「零件不存在」文案舊測試,修法前後皆失敗,與本項無關)。
|
||||
一併修復:08-08 deploy-all 重部把 youlin 的 `[ai]` binding 洗掉(`/portal/daemon/extract` 501)
|
||||
——本次以 `KEEP_AI=true` 重部復原(501→200 實測,模型仍 scout 未動)。
|
||||
|
||||
- [x] **t218 設定頁補顯示 MCP 連接網址(2026-08-09,任務層小改,arcrun-rag#7)**:
|
||||
來源=封測者原話「說明叫我把 MCP 加進 claude.ai connector,但我找不到網址」。文件
|
||||
(`docs-site/use/mcp.md`)一直寫「登入 portal → 設定頁,那裡可以直接複製」,但畫面上
|
||||
從沒真的顯示過——用戶照著文件走一定撲空(總管實查:`console-ui/public/portal/index.html`
|
||||
grep `mcp|MCP` 0 命中)。
|
||||
修:`console-ui/public/portal/index.html` 設定頁新增一塊面板「接上你的 AI(MCP)」,
|
||||
純前端字串轉換(不需後端新端點、不需安裝器多寫設定):MCP 網址=apiBase 把
|
||||
worker 名字從 `arcrun-cypher-executor` 換成 `arcrun-mcp`,同一顆自架帳號的 workers.dev
|
||||
子網域(與 `cli/src/lib/deploy.ts:386-392` 部署時組出這兩個網址的邏輯完全對齊,非另立
|
||||
一套猜法);不是這個網址形狀時誠實顯示「尚未偵測到」+停用複製鈕,不亂猜一個連不到的網址。
|
||||
複製按鈕沿用既有 `st-copy-url` 的 clipboard 邏輯,抽出共用 `copyText(btn, url)`(原
|
||||
`copyOriginUrl` 只能複製 `location.origin`,MCP 網址不是 origin,需要能傳任意字串)。
|
||||
文件(`products/arcrun-rag` docs-site `use/mcp.md`)核對過:"不確定是哪一串?登入 portal
|
||||
→ 設定頁,那裡可以直接複製" 這句在本次修完後才是真的,故文件本身不必改字。
|
||||
**端到端實測**(本機真瀏覽器,非 curl):起 kbdb + cypher-executor 兩個真 `wrangler dev`
|
||||
(local D1 套 migrations 0001-0006,兩邊 `KBDB_INTERNAL_TOKEN` 對齊解開 t115 fail-closed
|
||||
閘)+本機靜態伺服 portal(`UI_ORIGINS` 解 CORS);真的走 `/console/setup`→`/console/login`
|
||||
→`/portal/admin/bootstrap` 產生第一個 admin,瀏覽器真登入 `/portal/login`,導到設定頁——
|
||||
面板正確顯示、apiBase 是 localhost(不符自架網址形狀)時誠實顯示「尚未偵測到」+複製鈕
|
||||
停用(沒有亂猜一個假網址)。再用瀏覽器 console 把 `window.ARCRUN_API_BASE` 換成貼近真實
|
||||
自架形狀的字串(`https://arcrun-cypher-executor.fake9xyz.workers.dev`,故意用假 subdomain,
|
||||
不使用任何真實租戶字串以免誤觸真實實例),對照組獨立驗算與 index.html 內同一份公式,
|
||||
結果與 `deploy.ts:391` 的組法逐字相同(`https://arcrun-mcp.<sub>.workers.dev/mcp`);
|
||||
真的把這個值餵回頁面 DOM(`#st-mcp-url`),畫面正確顯示、複製鈕從停用變可按。
|
||||
複製鈕點擊後 clipboard 沒有變化——經比對**既有、本次未改動**的 `st-copy-url`(知識庫網址
|
||||
複製鈕)在同一自動化瀏覽器環境下點擊也是同樣結果,確認是這套瀏覽器自動化工具本身的
|
||||
clipboard-write 權限限制(非真人操作,`navigator.clipboard.writeText` 需要真使用者手勢),
|
||||
不是本次新增程式碼的迴歸——兩顆鈕行為一致。
|
||||
**未測**:真實自架 workers.dev 網址(未取得任何測試帳密,且紅線禁碰 youlin/uncle6);
|
||||
真人用滑鼠點擊複製鈕(受限於自動化環境,上述已用既有鈕做過同構對照)。
|
||||
執行範圍:`console-ui/public/portal/index.html`(新增面板 + JS)。未動後端、未部署。
|
||||
|
||||
## 第二波(不在本 SDD 動工範圍,掛號)
|
||||
|
||||
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
||||
|
||||
@@ -512,6 +512,41 @@ Workers Secret 也還在)。正解=刪除鈕標「即將開通」等 T9,**
|
||||
|
||||
---
|
||||
|
||||
## 25. 組態全綠,介面卻是舊世代——「驗了組態」被當成「驗了線上」(2026-08-08)
|
||||
|
||||
**leo 原話**:「已經發生過一次這個錯誤,**把舊版界面上到 prod,你要確定不可再犯**。」
|
||||
|
||||
**現場**:實測三個對外網址,`apiBase` / `profile.views` / `profile.home` **三項全過**,
|
||||
而它們跑的是 07-22 那一代的 portal(82,911 bytes、舊金色 serif 品牌、`Songti` 12 處);
|
||||
repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。
|
||||
⇒ **一個網址可以組態完全正確、同時對外展示一套早就被淘汰的介面,而所有機械檢查都說它綠。**
|
||||
|
||||
**兩個根因,分開記**:
|
||||
|
||||
1. **驗證的維度少了一個**。組態(連去哪、開哪幾頁)與世代(跑的是哪一版前端)是**兩件事**,
|
||||
只驗前者會得到有害的綠燈——它讓人以為驗過了。
|
||||
解:`console-ui/scripts/verify-live.mjs` 加第二層「世代指紋」=逐一抓線上資產、
|
||||
遮掉本來就該隨部署目標不同的那兩行(VIEWS/HOME),**其餘按位元組比對 repo `public/`**。
|
||||
位元組比對是刻意的:**不用關鍵字清單**——清單要人維護,而舊世代能無聲上線,
|
||||
正是因為沒有人記得維護它。
|
||||
|
||||
2. **手工維護的關鍵字閘會腐爛,而且會反過來咬你**。t160 那道世代閘寫的是
|
||||
「portal 全文含『登記新庫』就拒部」。08-03(`66f1b59`)有人在 portal 加了一則
|
||||
**說明「已經把登記新庫拿掉了」的 HTML 註解** ⇒ 這道閘從那天起每次都誤判,
|
||||
`npm run deploy:personal` 連續五天推不出去,而錯誤訊息說的是「你的 UI 是舊代」。
|
||||
解:文字閘比對前先剝掉 HTML 註解(只看使用者看得到的內容),並降級成輔助——主判準是指紋。
|
||||
|
||||
**判準(下次照用)**:問「線上這一份**是不是我們手上這一份**」,
|
||||
不要問「線上這幾個設定值對不對」。前者一句話涵蓋後者答不出來的東西。
|
||||
|
||||
**還有一半是「有沒有人記得跑」**。再好的檢查放在沒人執行的腳本裡等於不存在(這個 repo 已有數支那種)。
|
||||
故三處接死:① `deploy.mjs` 推完自動回頭驗線上,不過就算本次部署失敗;
|
||||
② `.deploy-state.json` **只在線上實測通過後**才寫(不是跑過指令就寫);
|
||||
③ Stop hook(`stop-check-sync.sh`)每回合離線比對「手上這一代 vs 最後一次驗過的部署」,
|
||||
在 CC 要說「做完了」的那一刻出聲。
|
||||
|
||||
---
|
||||
|
||||
## 快速檢查清單(做新功能前)
|
||||
|
||||
- [ ] 這是工作流還是零件?問「有必要嗎?」
|
||||
@@ -531,3 +566,5 @@ Workers Secret 也還在)。正解=刪除鈕標「即將開通」等 T9,**
|
||||
- [ ] 退役/降級某零件?同步清「AI 搜尋零件的三個源」=示例 yaml + parts.ts 硬編碼清單 + (validate 跳過是設計);別只改一處宣布完成(#22)
|
||||
- [ ] 沒對應 recipe?誠實留 TODO + 發 issue 補 seed,別硬塞語意不符的 canonical_id 充數(假綠,#22)
|
||||
- [ ] 本地/Gitea 改完 code 想 `acr update` 部署?先確認:它抓的是 GitHub codeload tarball,不是你剛改的目錄(#23)
|
||||
- [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25)
|
||||
- [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25)
|
||||
|
||||
+115
-1
@@ -3,7 +3,7 @@ name: status
|
||||
description: 當前進度、進行中 Phase、已知問題、下一步(動態文件,每 session 更新)
|
||||
metadata:
|
||||
type: project
|
||||
last_updated: 2026-07-19
|
||||
last_updated: 2026-08-07
|
||||
---
|
||||
|
||||
# 當前進度(動態)
|
||||
@@ -15,6 +15,84 @@ metadata:
|
||||
|
||||
## 📍 當前位置
|
||||
|
||||
> **2026-08-09(執行紀錄保留期補前端,arcrun-rag#21 交辦,commit `889b70b` main,已 push gitea)**:
|
||||
> P7 後端(4ca23c2)早已完成,但 portal 管理頁零命中「保留期」相關字樣——leo 只能自己 curl。
|
||||
> 補 `console-ui/public/portal/index.html` 管理頁「執行紀錄保留期」卡片(純薄殼,呼叫既有
|
||||
> `GET/PUT /portal/admin/execution-log-retention`,未動後端)。**本機真瀏覽器 E2E 驗證**:
|
||||
> 起 kbdb+cypher-executor 兩個真 `wrangler dev`(local D1 套用 migrations 0001-0006)+本機
|
||||
> 靜態伺服 portal+`UI_ORIGINS` 解 CORS,走真實首次設定流程建帳號登入 → 改天數/勾「不刪除」
|
||||
> 存檔、reload 頁面值仍持久(雙向都測過:90→45→90、數字→不刪除→數字),Network 每筆
|
||||
> GET/PUT 皆 200,console 無新增紅字。**未部署**(紅線:只 commit+push,未動任何 Cloudflare
|
||||
> 實例/未重打 arcrun-rag-ui bundle)——已在 arcrun-rag#21 comment 說明,要讓管理者在正式
|
||||
> portal 看到需另走 build-ui-bundle+出貨流程。**順手發現**:console-ui 無本機 dev/test script
|
||||
> (package.json 只有 deploy/verify),此次是手工兜 wrangler dev + python http.server,
|
||||
> 建議後續補 `npm run preview:local`。
|
||||
|
||||
> **2026-08-09(語意搜尋「故障要照實說是故障」,leo 直令,local commit main)**:
|
||||
> leo 看到 portal 橫幅「語意搜尋還沒開通⋯匯出診斷檔給我們幫你打開」原話痛罵:
|
||||
> 「**語義搜尋已經確定是一安裝就提供的功能⋯我沒有不開通這個功能,是壞了,
|
||||
> 沒有人會把 bug 美化成沒提供沒開通**」。本輪修四層:
|
||||
> 1. **文案**:portal(`console-ui/public/portal/index.html`)+console 兩處橫幅與設定頁
|
||||
> 全改「語意搜尋目前故障/我們的問題/你不用做任何事」,禁「開通/尚未啟用」框架;
|
||||
> kbdb `capability_hint` 同步改(`degraded_reason: module_off`)。
|
||||
> 2. **查詢向量化失敗不再偽裝成空結果**(leo 點名的謊):`embed.ts` `semanticSearch`
|
||||
> 改丟 `EmbedQueryFailedError`(舊行為 `if(!vec) return []`=「額度用完」被顯示成
|
||||
> 「查無資料」);route 層接住 → 誠實降級 keyword+`degraded_reason: embed_query_failed`。
|
||||
> 瀏覽器實測:真 AI binding + bogus 模型(5007 No such model)→ 橫幅照實說暫時故障。
|
||||
> 3. **源頭機制修掉**(為什麼裝好的實例會失去語意搜尋):
|
||||
> ① `cli update.ts` `kbdb_embed === true`→`!== false`——config 缺欄位時 redeploy 會把
|
||||
> [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy 整份覆蓋);init 預設同步翻成 Y/n。
|
||||
> ② `arcrun-rag deploy-all.mjs`:`ensureVectorizeIndex` 失敗以前只印 warning 續行
|
||||
> (youlin 07-20 那輪「本輪跳過」就這樣出貨)→ 改**致命中止**+失敗時 GET 複核
|
||||
> index 是否其實已存在。
|
||||
> 4. **順手自癒**:搜尋 hydrate 時發現孤兒向量/下架殘影 → 背景 deleteByIds+is_embedded
|
||||
> 歸零(0.971 殘影病原不再累積);空結果且 pending>0 → 背景 backfill 一批(embedOnWrite
|
||||
> fire-and-forget 失敗以前沒有任何機制會回來補)。no_index 拆兩態:pending>0=故障文案、
|
||||
> pending=0=誠實說「還沒有資料」(新裝未同步不是故障)。
|
||||
> **測試**:kbdb 146/146 綠(新增 `search-semantic-degraded.test.ts` 6 案+selftest 1 案);
|
||||
> cli tsc 乾淨+10/10;deploy-all DRY_RUN 24 worker 斷言 PASS。瀏覽器端到端(local wrangler
|
||||
> dev 18787/18788+portal 真登入)兩種故障畫面截圖驗過。**未部署 prod(D20 閘)**;
|
||||
> 別 session 未 commit 的 `.component-builds/*` 與 `graph-executor.ts` 未動未代提。
|
||||
|
||||
> **2026-08-07 晚(檢修孔第一版,local commit 未 push,main)**:leo 直接指令「先把檢修孔做出來
|
||||
> 發版,不必先知道 Oscar 的病是什麼」——解掉「查不出封測者的病,因為拿不到他那邊資料;拿不到
|
||||
> 資料是因為沒有檢修孔」的死結。**規格中途被 leo 簡化過一次**:從「免授權層/同意後才交」兩層
|
||||
> 機制,收斂成「設定頁一顆按鈕、按下去下載一個 JSON 檔、用戶自己把檔案傳出去」(同意天然內建
|
||||
> 在「他自己按、自己傳」這個動作裡)。
|
||||
>
|
||||
> **已完成(3 個 local commit,`matrix/arcrun`,sha `9344562`→`83aa1f6`→`5388f40`)**:
|
||||
> 1. `kbdb/src/embed.ts` `embedSelfTest()` + `GET /embed/selftest?owner_id=`——挑一筆已標記
|
||||
> 「已嵌入」的卡片拿自己的內容查自己,只回 `{enabled,tested,passed,note}`(不回內容/id)。
|
||||
> 這是唯一能分辨「從沒嵌過」vs「嵌了但 index 查不到自己」(Arcrun#11 那種故障)的方法,
|
||||
> 單看 backfillStatus 的 pending/embedded 計數看不出後者。
|
||||
> 2. `cypher-executor/src/routes/portal-data.ts` `GET /portal/data/diagnostics`——聚合
|
||||
> embed 健康狀態+`library_count`/`triplet_count`(只讀 `/map` 回應的數字,`narrative`/
|
||||
> `top_entities` 讀完即丟)+`bundle_version`+`instance_url`。沿用既有 `requirePortalUser`。
|
||||
> 3. `console-ui/public/portal/index.html` 設定頁「疑難排解」panel+「匯出診斷檔給我們看」按鈕。
|
||||
>
|
||||
> **端到端實測方式**(真跑不是模擬):本地起兩個真 `wrangler dev`(kbdb:18787/
|
||||
> cypher-executor:18788,local D1+KV,真的跑過 migrations),HTTP API 種一個 portal_user
|
||||
> +一筆 triplet_count=67 的 library_map;瀏覽器工具**真的登入、真的點按鈕**,Network 面板
|
||||
> 看到 `GET /portal/data/diagnostics → 200`,頁面狀態列顯示「已下載」。兩種故障情境(module
|
||||
> 未開/embedded=0;module 開了但 self_test.found_itself=false)都各自產出可判讀的 JSON,
|
||||
> 貼給 leo 核對過。
|
||||
>
|
||||
> **測試**:kbdb 12 新測試+既有 110 全綠;cypher-executor 3 新測試綠(既有 1 個 `/portal`
|
||||
> HTML 殼 404 失敗案用 `git stash` 驗證是既有問題非本次造成);console-ui 既有輕量測試
|
||||
> 18/18 綠。隱私紅線有機械測試守(斷言回應 JSON 不含卡片內容/entity 名/entry id)。
|
||||
>
|
||||
> **⏸ 未完成/待人**:**尚未 push GitHub**(D20 閘,需 leo 跑 `scripts/github-arm.sh`)→
|
||||
> push 後版本要走 `products/arcrun-rag/installer/scripts/release.mjs` 注版號 → Oscar 在他
|
||||
> 自己實例的設定頁按「立即更新」重跑安裝才會真的拿到這顆按鈕(self-hosted,不會自動生效)。
|
||||
> **這件事沒有 SDD**(leo 直接指令的臨時檢修孔,非任何 active SDD 的 task;`workflow-discovery`
|
||||
> 是目前唯一 active SDD,本次改動與它無關,屬於「有人閘直接授權」的例外路徑,未走 pending-changes
|
||||
> 提案流程——如需要補一份小 SDD 記錄,下個 session 可以評估)。
|
||||
>
|
||||
> **收工時意外發現**(誠實記錄):本機有既有背景機制會把 local commit 自動鏡到 Gitea
|
||||
> (`gitea/main` 在最後一次 commit 後幾秒就更新到同一個 sha,`git reflog` 時間戳對得上),
|
||||
> 全程沒有手動下過 `git push`。與 CLAUDE.md 記載的「Gitea 永 private,只有 GitHub 走 D20」
|
||||
> 模型一致,非本次操作觸發,僅供下個 session 知悉。
|
||||
|
||||
> **2026-07-28(t95+t96 搜尋缺陷修復,main)**:portal 搜尋兩缺陷修復——t95 CJK/ASCII
|
||||
> 邊界自動補空白(`normalizeCjkQuery`,查詢端,不動索引);t96 graph 節點精確 0 鄰居
|
||||
> → fuzzy fallback(`findBestNodeMatch`/`fuzzyFindNode`,contains 比對+最短名優先)。
|
||||
@@ -350,3 +428,39 @@ arcrun 不自管加密金鑰,`crypto_decrypt` host function 已成永遠回失
|
||||
|------|------|
|
||||
| 2026-06-08 | 初建。MCP bug 修正完成、wiki 系統搭建、壓測 Haiku 進行中 |
|
||||
| 2026-06-08(補) | Haiku 壓測發現 Cold 驗證缺陷:init 無強制檢查點 → 假綠風險。記入 mistakes.md §11 |
|
||||
|
||||
## ✅ 2026-08-08 深夜|leo 的 youlin 登入修好了(瀏覽器實證)
|
||||
|
||||
📍 **repo**:`matrix/arcrun`(`cypher-executor/src/index.ts` CORS 自動放行,commit `07cc7f5`)
|
||||
+ `matrix/arcrun/.github-public/installer/scripts/deploy-all.mjs`(有注入的部署路徑)
|
||||
|
||||
三元組:`登入從斷到通 >> 靠 >> matrix/arcrun:cypher-executor/src/index.ts 從 WORKER_SUBDOMAIN 自動推導 portal origin`
|
||||
|
||||
### 決定性的一個差別
|
||||
```
|
||||
修復前 按登入 → 「連線中斷——請檢查網路後重試」 ← 請求根本送不出去(CORS 擋掉)
|
||||
修復後 按登入 → 「email 或密碼錯誤」 ← 請求送到了、後端回答了
|
||||
```
|
||||
**「連線中斷」vs「密碼錯誤」就是全部**——我用假帳號,被拒絕才是正確行為。
|
||||
|
||||
### 對外四件(stage 環境,`*.workers.dev` 依 leo 08-07 判準屬例外)
|
||||
```
|
||||
② DNS 172.67.138.117
|
||||
③ apiBase https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev ← 已注入
|
||||
④ CORS access-control-allow-origin: https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev
|
||||
頁內實測 fetch /console/auth-status → {ok:true, status:200}
|
||||
```
|
||||
📌 **console 那幾行紅字是部署前的殘留**——同一個分頁不會清空。
|
||||
用**頁內 `fetch` 實跑**才是可信的判準,不是讀 console 歷史。
|
||||
|
||||
### 部署方式與它的誠實限制
|
||||
用 `deploy-all.mjs`(**有注入**的腳本路徑,非手動 wrangler):
|
||||
`KV 9/9 齊、D1 有、subdomain ✓、24 顆全部成功、3 個 secret 完好未被洗掉`。
|
||||
收工後 `git checkout` 清掉它弄髒的 15 個 `wrangler.toml`(已知行為,工作區歸零)。
|
||||
|
||||
🔴 **限制要說清楚**:這驗的是「**程式碼修對了**」,**不是「安裝器裝出來的結果**」。
|
||||
**環境分離(t217)沒做完之前,這是能做到的最接近的驗證。**
|
||||
|
||||
### 今天真正被根治的
|
||||
不是「補上 UI_ORIGINS」,是**讓它不再需要被注入**——portal 與 cypher 是同一子網域的兄弟,
|
||||
位址推導得出來。**少一個必須注入的變數,就少一個會被漏掉的東西。**
|
||||
|
||||
Reference in New Issue
Block a user