Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 179dd60571 | |||
| 9ff933ba98 | |||
| 2fcae722e7 | |||
| cac874601f | |||
| b223a69884 | |||
| fad5da0e17 | |||
| 13155a1d7b | |||
| bb548b6fdf | |||
| e05518a2b4 | |||
| 21293568d5 | |||
| 53b05c6d3d | |||
| f87d0e92f4 | |||
| ba152bc83a | |||
| 89b80ff90e | |||
| 10d150ac2b | |||
| a24f2912eb | |||
| 1791ffa497 | |||
| 296fb01247 | |||
| f1370e2275 | |||
| d58a6e152d | |||
| 793a94ecb5 | |||
| cbeddf7535 | |||
| d7c6bd0680 | |||
| a5e4caf5cb | |||
| 45a546a686 | |||
| e69d6bbc03 | |||
| b302c03ea8 | |||
| c497ec418e | |||
| 6985bf4850 | |||
| 8e10f1d83e | |||
| 3eb8b31f2b | |||
| 8cee9c9f76 | |||
| 7e3ca4c1a1 | |||
| af3edff856 | |||
| c5d696556e | |||
| 5919c6b90f | |||
| 525faaf5d0 | |||
| 37e13fc9bf | |||
| 674e1b4fa2 | |||
| b6ef0f07dc | |||
| 1d6dde4a01 | |||
| 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
|
||||
|
||||
@@ -142,6 +142,37 @@ SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/`
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 第六類:租戶字串來源(Arcrun#108/#105 同族)
|
||||
|
||||
### 6.1 靜態租戶字串不得用於資料面過濾
|
||||
**知識資料面的 `owner_id`(三元組/entries/records/藏書地圖/工作流 KV)必須與寫入端同源。**
|
||||
寫入端只有一個真相源=使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace,
|
||||
CLI push/小幫手上傳/MCP 都用它)。讀取端拿另一份手抄的環境變數預設值 → 全被過濾掉。
|
||||
|
||||
實害:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` 讓 leo 的 **1854 條三元組被過濾成 0 個庫**
|
||||
(#108);前一天 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` 是同一句話(#105)。
|
||||
|
||||
**規則**:
|
||||
1. `cypher-executor/src/lib/tenant.ts` 是租戶字串的**唯一產地**。
|
||||
`CONSOLE_TENANT` / `ARCRUN_NAMESPACE` 只能在該檔被讀取。
|
||||
2. 知識資料面用 `knowledgeOwner(env)`(回 `TenantId`),過濾一律經
|
||||
`ownerQuery()` / `ownerField()`——它們只吃 `TenantId`,`tsc` 就擋掉「隨手一個 string」。
|
||||
3. 帳號層用 `accountTenant(env)`(回 `string`,**刻意不是 TenantId**):帳號子 namespace
|
||||
`{tenant}::portal` 與 cypher 自己寫的設定用它,型別上不可能流進知識資料面。
|
||||
4. 身分解析路徑上**不准有字面預設值**。解析不到 → 丟 `TenantUnresolvedError`,
|
||||
誠實回「讀不到」(不是「你沒有」,#100 同一條)。
|
||||
|
||||
**機械強制**(規則存在但沒機制驗證=它會再犯第三次):
|
||||
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編 tier2 成品前先掃,違規 → **編不出成品**。
|
||||
- 本機自查:`cd cypher-executor && npm run check:tenant`(`npm test` 也會先跑它)。
|
||||
- 規則本體:`cypher-executor/scripts/tenant-source-rules.mjs`(純函式);
|
||||
閘自己的測試:`cypher-executor/tests/tenant-gate.test.ts`(壞例子會擋+合法寫法零誤攔)。
|
||||
|
||||
> 尚未接上 PreToolUse hook(`.claude/hooks/` 為受保護檔案,需人類加入)。
|
||||
> 要加的話:檢查器已備妥 `--stdin <相對路徑>` 模式,可在寫入前擋。
|
||||
|
||||
## Hook Block 訊息格式
|
||||
|
||||
當 hook 擋住一個操作時,訊息格式統一為:
|
||||
|
||||
@@ -99,6 +99,38 @@ CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」
|
||||
|
||||
---
|
||||
|
||||
## 3.6 自舉例外:能力該「只實作一次」,但不一定要是 HTTP API(2026-08-12 立)
|
||||
|
||||
> 立這條的原因:`Arcrun#97`(更新把使用者的工作流與登入弄不見)的修法一開始寫在
|
||||
> `cli/src/lib/resource-resolver.ts` ——**能力住在介面層,違反 §0**。
|
||||
> 後果不是理論:**安裝器(arcrun-rag)拿不到它,於是同一個 bug 只修了一半**,
|
||||
> 走 `acr` 的人有保護、走 `install.arcrun.dev` 的人沒有——**而所有真實用戶走後者**。
|
||||
> leo 2026-08-12:「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
|
||||
|
||||
修法(PR #111)把它搬到 **`shared/resource-rule/`:一份零依賴 ESM**,
|
||||
`acr` 與安裝器共用。**它刻意不是 cypher 的 API 端點**,三個理由:
|
||||
|
||||
| 為什麼不放 API | 說明 |
|
||||
|---|---|
|
||||
| **自舉** | 這條規則要在「決定怎麼裝」的當下用得到,而安裝器的工作正是把 cypher 生出來。放進 cypher = 要先有雞才能有蛋。 |
|
||||
| **輸入是使用者自己的帳號狀態** | 判斷依據是使用者 CF 帳號上的綁定。送去平台託管的 worker 換答案 ⇒ ①「能不能安裝」綁在平台是否活著 ②使用者的帳號拓撲交給第三方。 |
|
||||
| **它根本不需要是服務** | 這是**純函式**,唯一的 IO 由呼叫端注入。**§0 要求「能力只實作一次」,不是「能力一定要是 HTTP」。** |
|
||||
|
||||
🔴 **所以本檔 §0 的正確讀法是**:能力**只准有一份**,且**不准住在任何單一介面裡**。
|
||||
「放 API」是達成它的**常見手段**,不是唯一手段。
|
||||
**判準仍然是那句口訣**:「這段邏輯換一個介面要不要重寫?」要 → 它是能力。
|
||||
|
||||
📌 **給下一個人**:看到 `shared/` 底下的純函式**不要「修正」成 API 端點**——
|
||||
先讀 `shared/resource-rule/README.md §2`,那裡記著評估過並否決的其他形態
|
||||
(共用 npm 套件=自舉問題換位置;做成零件=要用 TinyGo 重寫一次,那才是第二份實作)。
|
||||
|
||||
📌 **打包例外**:`acr` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒
|
||||
`cli/` 下必須有一份**逐位元組副本**。那不是第二份實作——
|
||||
`scripts/sync-resource-rule.mjs --check` 一有漂移就 exit 1,且 `build`/`test` 都會先跑它
|
||||
(同 `cli/harness/` 的既有慣例)。**手改副本 = build 紅 = publish 擋下。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 統一帳號來源(薄殼共用同一身份)
|
||||
|
||||
所有薄殼讀**同一份**身份設定:
|
||||
|
||||
+28
@@ -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
|
||||
@@ -48,7 +52,31 @@ scripts/__pycache__/
|
||||
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
|
||||
*.sql
|
||||
backup-*.sql
|
||||
# 🔴 但 migration 不是備份,它是**要出貨的程式碼**(2026-08-12 實撞):
|
||||
# 上面那條 `*.sql` 的用意是擋 D1 匯出(整庫全量資料=機敏),卻連 migration 一起吃掉。
|
||||
# 後果:0001-0004 因為在該規則之前就 commit 所以還在,**0005/0006 從此沒進過版控**
|
||||
# ⇒ 更新指令從 Gitea 抓 main,那兩個檔根本不在那裡 ⇒ 每個用戶都會收到
|
||||
# 「✗ D1 migration: 部署物缺 kbdb/migrations/0005…」——**不是誰忘了推,是規則吃掉的**。
|
||||
# ⇒ 與 `.component-builds/**/component.wasm` 同慣例(見 rules/05-deploy-convention.md
|
||||
# 「WASM 來源」段),用否定規則放行。備份檔仍由 `backup-*.sql` 與目錄位置擋住。
|
||||
!kbdb/migrations/*.sql
|
||||
|
||||
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
||||
.github-public/
|
||||
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-12T16:25:19.210Z",
|
||||
"repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 588587,
|
||||
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
|
||||
"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": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 149797,
|
||||
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
|
||||
"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": 80079,
|
||||
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
|
||||
"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": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1179487,
|
||||
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"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
|
||||
|
||||
+4
-3
@@ -8,11 +8,12 @@
|
||||
"main": "./dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:harness && npm run check:harness && tsc",
|
||||
"build": "npm run build:harness && npm run check:harness && npm run check:rule && tsc",
|
||||
"build:harness": "node scripts/build-harness-skill.mjs",
|
||||
"check:harness": "node scripts/check-harness-generation.mjs",
|
||||
"check:rule": "node ../scripts/sync-resource-rule.mjs --check",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node --test \"tests/**/*.test.ts\"",
|
||||
"test": "npm run check:rule && node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
|
||||
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -44,6 +45,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);教材不該把它教成可用的邊', /不要寫|不存在|沒有這種|❌|非法/],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+30
-46
@@ -10,7 +10,6 @@ import chalk from 'chalk';
|
||||
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
|
||||
import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
downloadAndDeploy,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
@@ -135,7 +134,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
|
||||
|
||||
/**
|
||||
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
|
||||
* 驗 token → 建 KV(冪等,數量見 REQUIRED_KV_NAMESPACES)→ 查 subdomain → 下載 release 部署 Worker
|
||||
* 驗 token → 查 subdomain → 下載部署物 → 解析資源(沿用既有/必要才新建)→ 部署 Worker
|
||||
* → seed auth+api recipe → 寫 config → 印手動 secret 提示。
|
||||
* SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
|
||||
*/
|
||||
@@ -185,41 +184,13 @@ async function initSelfHosted(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 建 KV namespace(冪等)
|
||||
// 2. KV / D1 / Vectorize 不在這裡預先建(Arcrun#97)。
|
||||
// 舊版在這一步「照名字 ensure」一輪再往下傳,acr update 沿用同一段程式碼
|
||||
// ⇒ 對一台安裝器裝出來的實例(資源名字不同)等於每次更新都重建一整套空的綁上去。
|
||||
// 現在資源解析統一在 downloadAndDeploy 內:**先看已部署的 worker 綁著什麼**,
|
||||
// 對得上就沿用、確定沒人綁過才建、說不準就停手。init 走 mode:'init'(允許從零建起)。
|
||||
// 不建 R2:R2 是 dead storage(registry-canon Phase 1.5),且 CF R2 首次啟用強制綁信用卡,
|
||||
// 違背 arcrun「開源免費自架,Workers + KV 免費額度即可運行」核心理念(壓測 2026-06-04 #3)。
|
||||
const kvNamespaceIds: Record<string, string> = {};
|
||||
try {
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
process.stdout.write(chalk.gray(` → KV ${title}...`));
|
||||
const id = await cf.ensureKvNamespace(title, existing);
|
||||
kvNamespaceIds[title] = id;
|
||||
console.log(chalk.green(' ✓'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
|
||||
// (kbdb-base SDD Q4). idempotent: reuse if exists.
|
||||
let d1DatabaseId = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
|
||||
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
const em = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
|
||||
if (/auth/i.test(em)) {
|
||||
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
|
||||
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
|
||||
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
|
||||
} else {
|
||||
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用)
|
||||
let workerSubdomain = '';
|
||||
@@ -230,22 +201,35 @@ 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 部署)...'));
|
||||
// selfHosted: true → deploy 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 MCP 401)。
|
||||
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
|
||||
const deploy = await downloadAndDeploy(deployCtx);
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, selfHosted: true, kbdbEmbed };
|
||||
const deploy = await downloadAndDeploy(deployCtx, 'main', { mode: 'init', api: cf });
|
||||
|
||||
// 資源解析喊停(例:這台其實已經裝過、但某顆綁著的資源不見了)→ 什麼都沒建、什麼都沒部。
|
||||
if (deploy.blocked) {
|
||||
console.log(chalk.yellow('\n ⚠ 安裝沒有進行,你的 Cloudflare 帳號維持原樣。\n'));
|
||||
console.log(' ' + deploy.message.split('\n').join('\n '));
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 實際用上的資源(沿用既有的,或這次新建的)——寫 config / 驗收都以這份為準,不再自己查名字。
|
||||
const kvNamespaceIds = deployCtx.kvNamespaceIds ?? {};
|
||||
const d1DatabaseId = deployCtx.d1DatabaseId ?? '';
|
||||
const cypherUrl = deploy.cypherExecutorUrl
|
||||
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
|
||||
// self-hosted 自己的 MCP worker URL(mcp-account-source §3:.mcp.json 指自己,不 fallback 官方)。
|
||||
@@ -289,8 +273,8 @@ async function initSelfHosted(
|
||||
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
|
||||
const verify = await verifyInstall({
|
||||
cf,
|
||||
requiredKv: REQUIRED_KV_NAMESPACES,
|
||||
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
|
||||
kvNamespaceIds,
|
||||
d1DatabaseId: d1DatabaseId || undefined,
|
||||
cypherUrl,
|
||||
});
|
||||
printPreflight('安裝驗收(裝完檢查)', verify.items);
|
||||
@@ -300,7 +284,7 @@ async function initSelfHosted(
|
||||
}
|
||||
|
||||
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
|
||||
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
|
||||
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${Object.keys(kvNamespaceIds).length} KV,免費額度即可,無需綁卡)`));
|
||||
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
|
||||
|
||||
|
||||
+51
-39
@@ -14,11 +14,10 @@
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
namespaceHasKnowledge,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
|
||||
@@ -44,52 +43,65 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
|
||||
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
|
||||
|
||||
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個。
|
||||
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
|
||||
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥。
|
||||
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
|
||||
const kvNamespaceIds: Record<string, string> = {};
|
||||
try {
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// D1(KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限)
|
||||
// → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。
|
||||
// update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。
|
||||
let d1DatabaseId = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...'));
|
||||
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
const em = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(` ⚠ ${em}`));
|
||||
if (/auth/i.test(em)) {
|
||||
console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update'));
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Arcrun#97:這裡**曾經**先「照名字 ensure」一輪 KV + D1 再往下傳。
|
||||
// binding 名(WEBHOOKS)被當成 CF 上的資源標題去找,安裝器建的資源不叫那個名字
|
||||
// ⇒ 每次都對不上 ⇒ 每次都新建一顆空的綁上去 ⇒ 使用者的工作流/登入/子庫從畫面上消失。
|
||||
// 現在資源解析整段搬進 downloadAndDeploy:先讀「你已部署的 worker 現在綁著什麼」再決定,
|
||||
// 而且是**下載完、看得到這版要哪些 binding 之後**才決定,不再由這裡預先造一批。
|
||||
const ctx: DeployContext = {
|
||||
accountId: config.cloudflare_account_id,
|
||||
apiToken: config.cf_api_token,
|
||||
workerSubdomain: extractSubdomain(config.cypher_executor_url),
|
||||
kvNamespaceIds,
|
||||
d1DatabaseId: d1DatabaseId || undefined,
|
||||
// self-hosted → 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
|
||||
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
|
||||
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
|
||||
// 語義查詢開關(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 });
|
||||
// Arcrun#108:把「你的知識住在哪個命名空間」同步給雲端——但**先驗再寫**。
|
||||
//
|
||||
// 病灶:你 push 工作流、小幫手上傳知識、MCP 查詢,用的都是 config 的 `api_key`;
|
||||
// 而 cypher 讀藏書地圖/搜尋/工作流時,過濾用的 owner_id 來自 worker 的環境變數
|
||||
// (repo toml 帶的官方預設 `CONSOLE_TENANT = "leo"`)。兩個來源對不上 ⇒ 你的東西全被濾掉。
|
||||
//
|
||||
// 為什麼不無條件寫:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。
|
||||
// 無條件蓋成本機 api_key,會把一台**原本正常**的實例指向空的那一格
|
||||
// ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」。所以查得到才寫,查不到就不碰。
|
||||
if (config.api_key && config.cypher_executor_url) {
|
||||
process.stdout.write(chalk.gray(' → 核對雲端要用哪個知識命名空間...'));
|
||||
const hasKnowledge = await namespaceHasKnowledge(config.cypher_executor_url, config.api_key);
|
||||
if (hasKnowledge === true) {
|
||||
ctx.knowledgeNamespace = config.api_key;
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_NAMESPACE = ${config.api_key}(這個命名空間底下查得到你的知識庫)`));
|
||||
} else if (hasKnowledge === false) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(` ${config.api_key} 底下目前查不到任何知識庫 → 這趟不動雲端的命名空間設定`));
|
||||
console.log(chalk.gray(' (若藏書地圖是空的,請把這行連同 acr update 的輸出一起回報)'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(' 問不到實例(可能正在啟動或版本較舊)→ 這趟不動雲端的命名空間設定'));
|
||||
}
|
||||
}
|
||||
|
||||
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
|
||||
//(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
|
||||
|
||||
// 資源解析階段喊停:什麼都沒建、什麼都沒部。原文照印,然後非零離開——
|
||||
// 不能混進「部分失敗」的黃字裡帶過(那正是使用者不會發現的那種失敗)。
|
||||
if (result.blocked) {
|
||||
console.log(chalk.yellow('\n ⚠ 更新沒有進行,你的實例維持原樣。\n'));
|
||||
console.log(' ' + result.message.split('\n').join('\n '));
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.implemented) {
|
||||
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
|
||||
|
||||
+59
-58
@@ -3,6 +3,9 @@
|
||||
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
|
||||
*/
|
||||
|
||||
import { createCloudflareResourceApi } from './resource-rule/cf-resource-api.mjs';
|
||||
import type { ResourceApi, ScriptBindings } from './resource-resolver.js';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
export interface CfKvClientOptions {
|
||||
@@ -83,31 +86,26 @@ export class CfKvClient {
|
||||
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
|
||||
*/
|
||||
export class CfAccountClient {
|
||||
private accountBase: string;
|
||||
private headers: Record<string, string>;
|
||||
export class CfAccountClient implements ResourceApi {
|
||||
/**
|
||||
* `ResourceApi` 的七個方法**全部委派**給共用規則附的那支 client
|
||||
* (`shared/resource-rule/cf-resource-api.mjs`)。
|
||||
*
|
||||
* 🔴 為什麼不是在這裡自己實作一份:判斷一致還不夠,**看到的東西**也要一致。
|
||||
* 兩條路各自寫一份 CF client,只要有一邊把 404 當錯誤、漏了 per_page、少認一種
|
||||
* 欄位名,那一邊就會「看不到既有綁定」——而看不到既有綁定的下一步,依規則就是新建。
|
||||
* Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。
|
||||
*/
|
||||
private readonly rule: ReturnType<typeof createCloudflareResourceApi>;
|
||||
|
||||
constructor(accountId: string, apiToken: string) {
|
||||
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
this.rule = createCloudflareResourceApi({ accountId, apiToken });
|
||||
}
|
||||
|
||||
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...this.headers, ...(init?.headers ?? {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => null) as
|
||||
| { success: boolean; result: T; errors?: Array<{ message: string }> }
|
||||
| null;
|
||||
if (!res.ok || !data?.success) {
|
||||
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
|
||||
throw new Error(`CF API ${path} 失敗:${msg}`);
|
||||
}
|
||||
return data.result;
|
||||
const { ok, status, result, error } = await this.rule.cfRaw(path, init);
|
||||
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
|
||||
@@ -116,51 +114,54 @@ export class CfAccountClient {
|
||||
await this.cf<{ id: string; name: string }>('');
|
||||
}
|
||||
|
||||
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
|
||||
async listKvNamespaces(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ id: string; title: string }>>(
|
||||
'/storage/kv/namespaces?per_page=100',
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
|
||||
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listKvNamespaces());
|
||||
const found = known.get(title);
|
||||
if (found) return found;
|
||||
|
||||
const result = await this.cf<{ id: string; title: string }>(
|
||||
'/storage/kv/namespaces',
|
||||
{ method: 'POST', body: JSON.stringify({ title }) },
|
||||
);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||||
async getWorkersSubdomain(): Promise<string> {
|
||||
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
|
||||
return result.subdomain;
|
||||
}
|
||||
|
||||
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified).
|
||||
async listD1Databases(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
|
||||
const map = new Map<string, string>();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
// ── 以下七支=`ResourceApi`,一律委派共用規則,**這個檔案不得自己實作** ────────────
|
||||
// (`shared/resource-rule/cf-resource-api.mjs`;委派而非複製的理由見本 class 開頭)
|
||||
|
||||
/** 讀一顆已部署 worker 現在綁著哪些資源——使用者那側的事實(Arcrun#97 的唯一真相源)。 */
|
||||
getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
return this.rule.getScriptBindings(script);
|
||||
}
|
||||
|
||||
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listD1Databases());
|
||||
const found = known.get(name);
|
||||
if (found) return found;
|
||||
const result = await this.cf<{ uuid: string; name: string }>(
|
||||
'/d1/database',
|
||||
{ method: 'POST', body: JSON.stringify({ name }) },
|
||||
);
|
||||
return result.uuid;
|
||||
/** 帳號上現有的 KV namespace(title → id)。判斷「綁著的那顆還在不在」用。 */
|
||||
listKvNamespaces(): Promise<Map<string, string>> {
|
||||
return this.rule.listKvNamespaces();
|
||||
}
|
||||
|
||||
/** 帳號上現有的 D1(name → uuid)。 */
|
||||
listD1Databases(): Promise<Map<string, string>> {
|
||||
return this.rule.listD1Databases();
|
||||
}
|
||||
|
||||
/** 帳號上現有的 Vectorize index 名單。 */
|
||||
listVectorizeIndexes(): Promise<string[]> {
|
||||
return this.rule.listVectorizeIndexes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 無條件新建一顆 KV namespace。
|
||||
*
|
||||
* 🔴 Arcrun#97:**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路。
|
||||
* 要不要建,一律先經過 planResources;那裡只有在「確定沒有任何已部署的 worker
|
||||
* 綁過這個 binding」時才會排進 create。
|
||||
*/
|
||||
createKvNamespace(title: string): Promise<string> {
|
||||
return this.rule.createKvNamespace(title);
|
||||
}
|
||||
|
||||
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */
|
||||
createD1Database(name: string): Promise<string> {
|
||||
return this.rule.createD1Database(name);
|
||||
}
|
||||
|
||||
/** 新建 KBDB embed 用的 Vectorize index。沒有 ensure 版本,理由同上(Arcrun#97)。 */
|
||||
createVectorizeIndex(name: string): Promise<string> {
|
||||
return this.rule.createVectorizeIndex(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
|
||||
+542
-70
@@ -20,6 +20,19 @@ import { tmpdir, homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
import { CfAccountClient } from './cf-api.js';
|
||||
import {
|
||||
applyResourcePlan,
|
||||
bindingKey,
|
||||
parseWranglerRequirements,
|
||||
planResources,
|
||||
ResourcePlanBlocked,
|
||||
TABLE_KIND,
|
||||
type BindingRequirement,
|
||||
type ResourceApi,
|
||||
type ResourceKind,
|
||||
type ResolvedResource,
|
||||
} from './resource-resolver.js';
|
||||
|
||||
/** 部署狀態 manifest:記錄上次成功部署每個 worker 的內容指紋(content hash),
|
||||
* 讓 acr update 跳過未變動的 worker(壓測 2026-06-12:22/23 成功後重跑仍全部
|
||||
@@ -85,6 +98,119 @@ function giteaToken(): string | undefined {
|
||||
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本標籤的「發行頻道」來源(Arcrun#106)。
|
||||
*
|
||||
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
|
||||
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
|
||||
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
|
||||
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
|
||||
*/
|
||||
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
|
||||
|
||||
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
|
||||
export const CLI_MANAGED_VARS = [
|
||||
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
|
||||
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
|
||||
'MULTI_TENANT', // 由 selfHosted 注入
|
||||
'KBDB_BASE_URL', // 由 workerSubdomain 組
|
||||
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp)
|
||||
'ARCRUN_BUNDLE_COMMIT',
|
||||
] as const;
|
||||
|
||||
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
|
||||
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
|
||||
|
||||
/** 這趟部署要烙上去的版本標籤。 */
|
||||
export interface BundleStamp {
|
||||
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
|
||||
version: string;
|
||||
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
|
||||
commit?: string;
|
||||
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
|
||||
*
|
||||
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
|
||||
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
|
||||
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
|
||||
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
|
||||
*
|
||||
* 誠實邊界(mindset §7,這段要留著):
|
||||
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
|
||||
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
|
||||
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
|
||||
* (`ARCRUN_BUNDLE_COMMIT`/`/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
|
||||
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
|
||||
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
|
||||
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
|
||||
*/
|
||||
export async function resolveBundleStamp(
|
||||
ref: string,
|
||||
commit?: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<BundleStamp> {
|
||||
const short = commit ? commit.slice(0, 7) : ref;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { release?: string } | null;
|
||||
const release = String(body?.release ?? '').trim();
|
||||
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver(${release || '空'})`);
|
||||
return {
|
||||
version: release,
|
||||
commit,
|
||||
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''})`,
|
||||
};
|
||||
} catch (e) {
|
||||
const version = `${today}+${short}`;
|
||||
return {
|
||||
version,
|
||||
commit,
|
||||
note:
|
||||
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)})` +
|
||||
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `ref`(branch / tag / sha)解析成確切的 commit sha(Arcrun#106)。
|
||||
*
|
||||
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
|
||||
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
|
||||
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
|
||||
*/
|
||||
export async function resolveGiteaCommit(
|
||||
ref: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string | undefined> {
|
||||
const headers = buildDownloadHeaders();
|
||||
const tryUrls = [
|
||||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
|
||||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
|
||||
];
|
||||
for (const url of tryUrls) {
|
||||
try {
|
||||
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) continue;
|
||||
const body = (await res.json()) as
|
||||
| { commit?: { id?: string } }
|
||||
| Array<{ sha?: string }>
|
||||
| null;
|
||||
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
|
||||
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
|
||||
} catch {
|
||||
/* 換下一種問法;全都問不到就回 undefined */
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
|
||||
* Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
|
||||
@@ -107,7 +233,15 @@ export function buildDownloadHeaders(token = giteaToken()): Record<string, strin
|
||||
}
|
||||
|
||||
/**
|
||||
* init 要建立的 KV namespace(title)。
|
||||
* arcrun 各 worker 會用到的 KV **binding 名**清單。
|
||||
*
|
||||
* 🔴 Arcrun#97 之後,這份清單**不再是「要去 CF 上建的資源標題」**——
|
||||
* 真正要哪些綁定,是部署當下從每份 wrangler.toml 讀出來的(parseWranglerRequirements),
|
||||
* 要不要建則由 resource-resolver 依「已部署的 worker 綁著什麼」決定。
|
||||
* 這裡保留成一份**文件與離線測試用的期望清單**(測試會比對 toml 沒有漏綁),
|
||||
* 不再被任何執行路徑拿去「照名字 ensure」。
|
||||
*
|
||||
* 原始出處保留如下:
|
||||
* 前 7 個權威來源:.claude/rules/01-tech-stack.md 資料儲存表(cypher-executor 用)。
|
||||
* SUBMISSIONS_KV:registry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
|
||||
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
|
||||
@@ -151,8 +285,11 @@ export interface DeployContext {
|
||||
accountId: string;
|
||||
apiToken: string;
|
||||
workerSubdomain: string;
|
||||
kvNamespaceIds: Record<string, string>; // title → id
|
||||
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
|
||||
/** binding → KV namespace id。**由 downloadAndDeploy 內部的資源解析填入,呼叫端不要自己給**
|
||||
* (Arcrun#97:呼叫端「照名字 ensure 一輪再傳進來」正是把使用者實例洗空的那條路)。*/
|
||||
kvNamespaceIds?: Record<string, string>;
|
||||
/** KBDB Base D1 id;同上,由資源解析填入。*/
|
||||
d1DatabaseId?: string;
|
||||
// self-hosted 單租戶旗標。true(self-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars],
|
||||
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
|
||||
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
|
||||
@@ -161,6 +298,44 @@ export interface DeployContext {
|
||||
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
|
||||
// base 維持 LIKE keyword(free-tier 友善)。
|
||||
kbdbEmbed?: boolean;
|
||||
/**
|
||||
* Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`),
|
||||
* 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。
|
||||
*
|
||||
* **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)——
|
||||
* 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。
|
||||
*/
|
||||
knowledgeNamespace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」)
|
||||
*
|
||||
* 打的是實例自己的 `GET /kbdb/map?owner_id=<ns>`(cypher 既有的純轉發端點,CLI 平常就在用
|
||||
* 這條路 + `X-Arcrun-API-Key`)。回傳:
|
||||
* true = 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
|
||||
* false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空)
|
||||
* null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事
|
||||
*
|
||||
* 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
|
||||
*/
|
||||
export async function namespaceHasKnowledge(
|
||||
cypherUrl: string,
|
||||
namespace: string,
|
||||
): Promise<boolean | null> {
|
||||
if (!cypherUrl || !namespace) return null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
|
||||
{ headers: { 'X-Arcrun-API-Key': namespace } },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
|
||||
if (!body || !Array.isArray(body.libraries)) return null;
|
||||
return body.libraries.length > 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +365,11 @@ export interface DeployResult {
|
||||
cypherExecutorUrl?: string;
|
||||
mcpUrl?: string; // self-hosted 自己的 MCP worker URL(mcp-account-source §3)
|
||||
message: string;
|
||||
/** true = 資源解析階段就喊停(Arcrun#97),**一顆資源沒建、一個 worker 沒部**。
|
||||
* 呼叫端要以非零結束並把 message 原文印出來,不要當成一般部分失敗帶過。*/
|
||||
blocked?: boolean;
|
||||
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
|
||||
resources?: Map<string, ResolvedResource>;
|
||||
}
|
||||
|
||||
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
|
||||
@@ -219,12 +399,17 @@ export function wranglerAvailable(): boolean {
|
||||
export async function downloadAndDeploy(
|
||||
ctx: DeployContext,
|
||||
ref = 'main',
|
||||
opts: { force?: boolean } = {},
|
||||
opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
|
||||
): Promise<DeployResult> {
|
||||
const mode = opts.mode ?? 'update';
|
||||
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
|
||||
// 1. 下載 + 解壓 Gitea archive tarball
|
||||
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
|
||||
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
|
||||
const commit = await resolveGiteaCommit(ref);
|
||||
let root: string;
|
||||
try {
|
||||
root = await downloadRepoTarball(ref);
|
||||
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
|
||||
} catch (e) {
|
||||
return {
|
||||
implemented: true,
|
||||
@@ -262,28 +447,184 @@ export async function downloadAndDeploy(
|
||||
}
|
||||
|
||||
const failures: string[] = [];
|
||||
const allDirs = [...tier1, ...tier2];
|
||||
|
||||
// 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等),
|
||||
// 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
|
||||
// 失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
|
||||
if (ctx.kbdbEmbed) {
|
||||
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
|
||||
//
|
||||
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」。
|
||||
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
|
||||
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
|
||||
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
|
||||
//
|
||||
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
|
||||
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
|
||||
// → 整趟停手,**在動任何東西之前**。
|
||||
//
|
||||
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
|
||||
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
|
||||
const requirements: BindingRequirement[] = [];
|
||||
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
|
||||
const dirScript = new Map<string, string>(); // dir → worker script 名(#106:var 沿用要逐顆對號)
|
||||
for (const dir of allDirs) {
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
if (!existsSync(tomlPath)) continue;
|
||||
const raw = readFileSync(tomlPath, 'utf8');
|
||||
tomlPreviews.set(dir, raw);
|
||||
const preview = renderWranglerToml(raw, ctx, new Map());
|
||||
const parsed = parseWranglerRequirements(preview);
|
||||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||||
dirScript.set(dir, parsed.script);
|
||||
for (const b of parsed.bindings) {
|
||||
requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = new Map<string, ResolvedResource>();
|
||||
let liveVars = new Map<string, Record<string, string>>();
|
||||
if (requirements.length > 0) {
|
||||
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
|
||||
let plan;
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
|
||||
await ensureVectorizeIndex(ctx);
|
||||
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
|
||||
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
|
||||
await ensureVectorizeMetadataIndexes(ctx);
|
||||
plan = await planResources(api, requirements, mode);
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message:
|
||||
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
|
||||
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
if (plan.blockers.length > 0) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message:
|
||||
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
|
||||
plan.blockers.map((b) => ` • ${b}`).join('\n') +
|
||||
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
resolved = await applyResourcePlan(api, plan);
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
const detail = e instanceof ResourcePlanBlocked
|
||||
? e.blockers.map((b) => ` • ${b}`).join('\n')
|
||||
: ` • ${raw}`;
|
||||
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
|
||||
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
|
||||
const hint = /d1/i.test(raw) && /auth/i.test(raw)
|
||||
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
|
||||
: '';
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
liveVars = plan.liveVars;
|
||||
console.log(chalk.green(' ✓'));
|
||||
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
|
||||
const created = [...resolved.values()].filter((r) => r.origin === 'created');
|
||||
if (adopted.length > 0) {
|
||||
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
|
||||
for (const r of adopted) console.log(chalk.gray(` = ${r.binding} → ${r.value}(讀自 ${r.from})`));
|
||||
}
|
||||
if (created.length > 0) {
|
||||
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
|
||||
for (const r of created) console.log(chalk.yellow(` + ${r.binding} → ${r.value}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
|
||||
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
|
||||
ctx.kvNamespaceIds = Object.fromEntries(
|
||||
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
|
||||
);
|
||||
ctx.d1DatabaseId =
|
||||
resolved.get(bindingKey('d1', 'DB'))?.value
|
||||
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
|
||||
|
||||
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
|
||||
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
|
||||
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
|
||||
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
|
||||
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
|
||||
if (vectorizeIndex) {
|
||||
try {
|
||||
process.stdout.write(chalk.gray(` → 語義查詢 metadata index(${vectorizeIndex})...`));
|
||||
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2.8 var(plain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
|
||||
//
|
||||
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
|
||||
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
|
||||
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
|
||||
//
|
||||
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
|
||||
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
|
||||
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
|
||||
//
|
||||
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
|
||||
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
|
||||
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
|
||||
const extraVarsByDir = new Map<string, Record<string, string>>();
|
||||
let stamp: BundleStamp | undefined;
|
||||
if (dirScript.size > 0) {
|
||||
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
|
||||
if (needStamp) {
|
||||
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
|
||||
stamp = await resolveBundleStamp(ref, commit);
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
|
||||
}
|
||||
const preservedTotal: string[] = [];
|
||||
for (const [dir, script] of dirScript) {
|
||||
const raw = tomlPreviews.get(dir);
|
||||
if (!raw) continue;
|
||||
const keep = preservedVars(liveVars.get(script), raw);
|
||||
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
|
||||
const vars: Record<string, string> = { ...keep };
|
||||
if (stamp && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
|
||||
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
|
||||
}
|
||||
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
|
||||
//
|
||||
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
|
||||
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
|
||||
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
|
||||
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
|
||||
//
|
||||
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
|
||||
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
|
||||
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
|
||||
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
|
||||
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
|
||||
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||||
}
|
||||
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
|
||||
}
|
||||
if (preservedTotal.length > 0) {
|
||||
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
|
||||
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
|
||||
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
|
||||
const allDirs = [...tier1, ...tier2];
|
||||
let deployed = 0;
|
||||
let skipped = 0;
|
||||
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
|
||||
@@ -296,7 +637,7 @@ export async function downloadAndDeploy(
|
||||
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
|
||||
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
|
||||
try {
|
||||
injectWranglerConfig(tomlPath, ctx);
|
||||
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
|
||||
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
|
||||
const hash = dirContentHash(dir, ctx.accountId);
|
||||
if (manifest[label] === hash) {
|
||||
@@ -434,35 +775,6 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=1024 / metric=cosine,對齊 bge-m3)。
|
||||
* ⚠️ 這行別寫成 `**dimensions=1024**/metric`——`*` 緊接 `/` 會提早關掉 block comment(實撞 TS1127)。
|
||||
* 維度必須與 `kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 一致——不一致時 upsert 直接被 CF 拒絕。
|
||||
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
|
||||
*/
|
||||
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: KBDB_VECTORIZE_INDEX,
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (res.ok) return;
|
||||
// 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
|
||||
| null;
|
||||
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) return;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
|
||||
|
||||
@@ -471,9 +783,12 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
|
||||
* Vectorize v2:要對某 metadata 欄位下 filter,必須先為該欄建 metadata index,否則帶過濾的語意查詢一律回 0。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`(indexType=string)。
|
||||
* 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。
|
||||
*
|
||||
* 🔴 index 名由呼叫端傳入(= 資源解析沿用到的那顆),**不是**寫死 KBDB_VECTORIZE_INDEX:
|
||||
* 使用者實例上那顆 index 叫什麼是他那側的事實,我們把 metadata index 建到「他真的在用的那顆」上。
|
||||
*/
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
|
||||
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -499,11 +814,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void>
|
||||
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
|
||||
*
|
||||
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
|
||||
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
|
||||
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const url = buildArchiveUrl(ref, bust);
|
||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`));
|
||||
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
|
||||
const label = fromRef ? `${fromRef} → ${ref.slice(0, 7)}` : ref;
|
||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 10–30 秒,視網速)...`));
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。
|
||||
@@ -596,21 +913,115 @@ export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: stri
|
||||
* - 每個 worker toml 都有 `workers_dev = true` → strip routes 後純靠 workers.dev URL,自架可達。
|
||||
* - R2(`[[r2_buckets]]`)是 dead storage(registry-canon Phase 1.5),且綁卡違背開源免費 → 一併移除。
|
||||
*/
|
||||
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
function injectWranglerConfig(
|
||||
tomlPath: string,
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
original?: string,
|
||||
extraVars: Record<string, string> = {},
|
||||
): void {
|
||||
if (!existsSync(tomlPath)) return;
|
||||
let toml = readFileSync(tomlPath, 'utf8');
|
||||
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
|
||||
const toml = original ?? readFileSync(tomlPath, 'utf8');
|
||||
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
|
||||
}
|
||||
|
||||
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
|
||||
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
|
||||
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
|
||||
if (!id) continue;
|
||||
const re = new RegExp(
|
||||
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
|
||||
'g',
|
||||
);
|
||||
toml = toml.replace(re, `$1${id}$2`);
|
||||
/**
|
||||
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var(Arcrun#106)。
|
||||
*
|
||||
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
|
||||
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
|
||||
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
|
||||
* 沿用等於拿舊值蓋掉正解。
|
||||
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
|
||||
*
|
||||
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
|
||||
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"`/`WORKER_SUBDOMAIN` 之類是**官方 prod 的值**,
|
||||
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
|
||||
*/
|
||||
export function preservedVars(
|
||||
live: Record<string, string> | undefined,
|
||||
toml: string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!live) return out;
|
||||
const managed = new Set<string>(CLI_MANAGED_VARS);
|
||||
for (const key of Object.keys(live).sort()) {
|
||||
if (managed.has(key)) continue;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
|
||||
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
|
||||
out[key] = live[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
|
||||
function readVar(toml: string, key: string): string | undefined {
|
||||
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
|
||||
function tomlEscape(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一組 var 寫進 toml 的 `[vars]`(Arcrun#106)。純函式。
|
||||
*
|
||||
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
|
||||
* 1. 已有未註解的同名行 → 換值
|
||||
* 2. 只有被註解掉的同名行 → 取消註解並填值
|
||||
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
|
||||
*/
|
||||
export function applyVars(toml: string, vars: Record<string, string>): string {
|
||||
let out = toml;
|
||||
for (const key of Object.keys(vars).sort()) {
|
||||
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
|
||||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
|
||||
const value = tomlEscape(vars[key]);
|
||||
// 🔴 一律用「函式版 replace」:值裡若有 `$&`/`$1` 這種字元,字串版 replace 會把它當成
|
||||
// 反向參照展開,寫出來的就不是使用者那個值了。
|
||||
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
|
||||
out = out.replace(
|
||||
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
|
||||
(_m, head: string, tail: string) => `${head}${value}${tail}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
|
||||
out = out.replace(
|
||||
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
|
||||
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\[vars\]\s*$/m.test(out)) {
|
||||
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
|
||||
continue;
|
||||
}
|
||||
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
|
||||
*
|
||||
* 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是
|
||||
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
|
||||
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
|
||||
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
|
||||
*
|
||||
* `extraVars`(Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
|
||||
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
|
||||
*/
|
||||
export function renderWranglerToml(
|
||||
toml: string,
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
extraVars: Record<string, string> = {},
|
||||
): string {
|
||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
@@ -629,14 +1040,6 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
|
||||
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
/(database_id\s*=\s*")[^"]*(")/,
|
||||
`$1${ctx.d1DatabaseId}$2`,
|
||||
);
|
||||
}
|
||||
|
||||
// self-hosted:注入 MULTI_TENANT="false" 到 [vars](mcp-account-source §5.5)。
|
||||
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
|
||||
// 只對有 [vars] 的 worker(mcp / cypher-executor)生效;其餘無 [vars] 的不動。
|
||||
@@ -668,7 +1071,76 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
|
||||
}
|
||||
|
||||
writeFileSync(tomlPath, toml, 'utf8');
|
||||
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
|
||||
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
|
||||
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
|
||||
toml = applyVars(toml, extraVars);
|
||||
|
||||
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
|
||||
// 空 map = 預覽模式,這步什麼也不做。
|
||||
return applyResolvedBindings(toml, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解析好的資源 id 寫進對應的 binding 區塊。
|
||||
*
|
||||
* 逐個 `[[table]]` 區塊掃:先在區塊內找 `binding = "X"`,再改同一區塊裡的值欄位
|
||||
* (KV→`id`、D1→`database_id`、Vectorize→`index_name`)。
|
||||
* 🔴 刻意**不用**「全檔第一個 database_id」這種寫法:cypher(`CREDENTIALS_DB`)與
|
||||
* kbdb(`DB`)各有自己的 D1 綁定,盲換會把兩邊當成同一個東西——而使用者的實例
|
||||
* 完全可以兩邊指向不同庫。誰綁誰是使用者那側的事實,我們只是原樣搬過去。
|
||||
*/
|
||||
export function applyResolvedBindings(
|
||||
toml: string,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
): string {
|
||||
if (resolved.size === 0) return toml;
|
||||
|
||||
const VALUE_KEY: Record<ResourceKind, string> = {
|
||||
kv_namespace: 'id',
|
||||
d1: 'database_id',
|
||||
vectorize: 'index_name',
|
||||
};
|
||||
|
||||
const out: string[] = [];
|
||||
let block: string[] = [];
|
||||
let kind: ResourceKind | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (kind) {
|
||||
const binding = block
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => !l.startsWith('#'))
|
||||
.map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
|
||||
.find((b): b is string => !!b);
|
||||
const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
|
||||
if (hit) {
|
||||
const key = VALUE_KEY[kind];
|
||||
const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
|
||||
const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
|
||||
if (at >= 0) {
|
||||
block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
|
||||
} else {
|
||||
// 區塊裡本來沒有這個欄位(例如新版 toml 只寫 binding)→ 補一行,不要靜默略過。
|
||||
block.push(`${key} = "${hit.value}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(...block);
|
||||
block = [];
|
||||
};
|
||||
|
||||
for (const line of toml.split('\n')) {
|
||||
const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
|
||||
if (table) {
|
||||
flush();
|
||||
kind = TABLE_KIND[table[1]] ?? null;
|
||||
}
|
||||
block.push(line);
|
||||
}
|
||||
flush();
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-20
@@ -6,9 +6,11 @@
|
||||
* 不是假設齊備直接動手 → 缺一個就卡(test_arcrun/4 的 D1 大跑去讀原始碼自己想辦法)。
|
||||
* - **裝完驗收**:部署後逐項確認(KV / D1 / migration / cypher 可達),缺哪項明確報哪項
|
||||
* + 給一鍵補裝指令。不是靜默印灰字(原本 harness/MCP 失敗只 console.log 灰字,用戶不知道)。
|
||||
* - **冪等**:重跑檢查後「什麼也沒動」(ensureKvNamespace / ensureD1Database 本就冪等)。
|
||||
* - **冪等**:重跑檢查後「什麼也沒動」。
|
||||
*
|
||||
* 本檔只做「偵測 + 報告」,不自己建資源(建資源仍走 cf-api 的 ensure*,由 init 編排)。
|
||||
* 本檔只做「偵測 + 報告」,不自己建資源(要不要建由 resource-resolver 判斷,deploy.ts 編排)。
|
||||
* 🔴 Arcrun#97:報告裡的 fix 指令也算「產品的一部分」——一句「acr update(冪等重建)」
|
||||
* 接在誤報的「缺 KV」後面,就是把使用者直接推去執行那個把實例洗空的動作。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -77,42 +79,58 @@ export function printPreflight(title: string, items: PreflightItem[]): void {
|
||||
*/
|
||||
export async function verifyInstall(opts: {
|
||||
cf: CfAccountClient;
|
||||
requiredKv: readonly string[];
|
||||
expectD1Name?: string;
|
||||
/** binding → KV namespace id(部署實際用上的那幾顆)。*/
|
||||
kvNamespaceIds: Record<string, string>;
|
||||
/** 部署實際用上的 D1 id(沒有 D1 就不傳)。*/
|
||||
d1DatabaseId?: string;
|
||||
cypherUrl?: string;
|
||||
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
|
||||
const items: PreflightItem[] = [];
|
||||
|
||||
// KV:實查 CF 上現有 namespace,比對必需清單
|
||||
// KV:核對「部署實際綁上去的那幾顆 id」在帳號上還在不在。
|
||||
// 🔴 Arcrun#97:這裡**不能**用「帳號上有沒有叫 WEBHOOKS 的 namespace」來驗。
|
||||
// 安裝器裝出來的實例,資源名字是 arcrun-rag-<instance>-kv-webhooks——照名字驗會誤報「缺」,
|
||||
// 而那句誤報底下就寫著「fix: acr update(冪等重建)」⇒ 使用者照做,就被重建成空的。
|
||||
// 驗的對象永遠是 id(我們真的綁上去的那顆),不是名字。
|
||||
const kvBindings = Object.entries(opts.kvNamespaceIds);
|
||||
try {
|
||||
const existing = await opts.cf.listKvNamespaces();
|
||||
const have = new Set(existing.keys());
|
||||
const missing = opts.requiredKv.filter((t) => !have.has(t));
|
||||
const ids = new Set((await opts.cf.listKvNamespaces()).values());
|
||||
const missing = kvBindings.filter(([, id]) => !ids.has(id)).map(([b]) => b);
|
||||
items.push(
|
||||
missing.length === 0
|
||||
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
|
||||
: { name: 'KV namespaces', ok: false, detail: `缺 ${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
|
||||
? { name: `KV namespaces (${kvBindings.length})`, ok: true }
|
||||
: {
|
||||
name: 'KV namespaces',
|
||||
ok: false,
|
||||
detail: `這幾個 binding 綁著的 namespace 在帳號上找不到:${missing.join(', ')}`,
|
||||
fix: '先確認那幾顆是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
|
||||
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: '檢查 CF token 的 KV 讀取權限' });
|
||||
}
|
||||
|
||||
// D1:實查 CF 上是否有該庫
|
||||
if (opts.expectD1Name) {
|
||||
// D1:同理,核對實際綁上去的那顆 id 還在不在(不是核對有沒有叫 arcrun-kbdb 的庫)。
|
||||
if (opts.d1DatabaseId) {
|
||||
try {
|
||||
const dbs = await opts.cf.listD1Databases();
|
||||
const ids = new Set((await opts.cf.listD1Databases()).values());
|
||||
items.push(
|
||||
dbs.has(opts.expectD1Name)
|
||||
? { name: `D1 ${opts.expectD1Name}`, ok: true }
|
||||
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' },
|
||||
ids.has(opts.d1DatabaseId)
|
||||
? { name: `D1 ${opts.d1DatabaseId}`, ok: true }
|
||||
: {
|
||||
name: `D1 ${opts.d1DatabaseId}`,
|
||||
ok: false,
|
||||
detail: '這顆 D1 在帳號上找不到',
|
||||
fix: '先確認它是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
|
||||
// D1 讀不到最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
|
||||
const m = msg(e);
|
||||
const fix = /auth/i.test(m)
|
||||
? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update'
|
||||
: 'acr update(冪等重試)';
|
||||
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix });
|
||||
: '檢查 CF token 的 D1 讀取權限';
|
||||
items.push({ name: `D1 ${opts.d1DatabaseId}`, ok: false, detail: m, fix });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* resource-resolver.ts — **這裡沒有邏輯**,只是把共用規則接到 CLI 的既有 import 路徑上。
|
||||
*
|
||||
* 「這個實例該用哪些資源」的規則住在 `shared/resource-rule/`(repo 根目錄),
|
||||
* 那是**唯一一份人手維護的實作**;`./resource-rule/` 是該目錄的逐位元組鏡射
|
||||
* (`scripts/sync-resource-rule.mjs` 產生,`npm run build` / `npm test` 會跑 `--check` 擋漂移)。
|
||||
* 之所以要有這份鏡射:`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案。
|
||||
*
|
||||
* 為什麼規則不在 CLI(leo 2026-08-12):
|
||||
* 「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
|
||||
* ——`acr` 有這條規則、安裝器沒有,結果就是 Arcrun#97:
|
||||
* 安裝器照名字找、找不到就建一顆空的綁上去,使用者的工作流與登入狀態整片消失。
|
||||
* 規則搬到共用層之後,安裝器直接 import 同一份原稿,**不再有第二種答案**。
|
||||
*
|
||||
* 🔴 不要把任何判斷寫回這個檔案。要改規則 → 改 `shared/resource-rule/rule.mjs`。
|
||||
*/
|
||||
|
||||
export {
|
||||
planResources,
|
||||
applyResourcePlan,
|
||||
parseWranglerRequirements,
|
||||
normalizeLiveBindings,
|
||||
normalizeLiveVars,
|
||||
bindingKey,
|
||||
ResourcePlanBlocked,
|
||||
KIND_LABEL,
|
||||
TABLE_KIND,
|
||||
} from './resource-rule/rule.mjs';
|
||||
|
||||
export type {
|
||||
ResourceKind,
|
||||
LiveBinding,
|
||||
ScriptBindings,
|
||||
ResourceApi,
|
||||
BindingRequirement,
|
||||
PlannedAdopt,
|
||||
PlannedCreate,
|
||||
ResourcePlan,
|
||||
ResolvedResource,
|
||||
WranglerRequirements,
|
||||
RawWorkerBinding,
|
||||
} from './resource-rule/rule.mjs';
|
||||
@@ -0,0 +1,202 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* cf-resource-api.mjs — 規則的**眼睛與手**:對 Cloudflare 帳號的那七個動作,也只有一份。
|
||||
*
|
||||
* `rule.mjs` 是純判斷,IO 由呼叫端注入(`ResourceApi`)。本檔就是那個注入物的正貨:
|
||||
* 用 CF REST API 實作 `ResourceApi`,零依賴、只用 global `fetch`
|
||||
* ⇒ Node 18+ 與 Cloudflare Workers runtime 都能直接跑。
|
||||
*
|
||||
* 【為什麼連這層也要共用】
|
||||
* 判斷一致還不夠——**看到的東西**也要一致。
|
||||
* 「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的;
|
||||
* 如果兩條路各自寫一份 client,隨便一個差異(打錯端點、把 404 當錯誤、漏了 per_page、
|
||||
* 少認一種欄位名)都會讓其中一條路「看不到既有綁定」——而看不到既有綁定的下一步,
|
||||
* 依規則就是**新建**。Arcrun#97 的災情不需要規則寫錯,只要眼睛不一樣就會重演。
|
||||
*
|
||||
* 這裡**故意只有 `ResourceApi` 那七個方法**。verifyAccess / 查 subdomain / KV 讀寫
|
||||
* 這些跟「該用哪些資源」無關的帳號操作留在各自的呼叫端,不往共用層堆。
|
||||
*
|
||||
* 🔴 除了同目錄的 `./rule.mjs`,這支不准 import 任何東西——共用層的價值在於
|
||||
* 「整個目錄複製到哪個 runtime 都能直接跑」,多一個外部依賴就少一條路吃得到。
|
||||
*/
|
||||
|
||||
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
/**
|
||||
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
|
||||
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
|
||||
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CfResourceApiOptions
|
||||
* @property {string} accountId
|
||||
* @property {string} apiToken
|
||||
* @property {typeof globalThis.fetch} [fetch]
|
||||
* 注入用(離線測試餵假帳號、或宿主要用自己的 fetch)。預設 global fetch。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 建一個打真實 Cloudflare 的 `ResourceApi`。
|
||||
*
|
||||
* @param {CfResourceApiOptions} options
|
||||
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
|
||||
*/
|
||||
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
|
||||
const doFetch = fetchImpl ?? globalThis.fetch;
|
||||
if (typeof doFetch !== 'function') {
|
||||
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
|
||||
}
|
||||
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
|
||||
*/
|
||||
async function cfRaw(path, init) {
|
||||
const res = await doFetch(`${accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data?.success) {
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error:
|
||||
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
|
||||
`HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, status: res.status, result: data.result };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function cf(path, init) {
|
||||
const { ok, status, result, error } = await cfRaw(path, init);
|
||||
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
cfRaw,
|
||||
|
||||
/**
|
||||
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
|
||||
*
|
||||
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
|
||||
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
|
||||
* 把查不到當成不存在,就是 #97 的根因。
|
||||
*
|
||||
* @param {string} script
|
||||
* @returns {Promise<ScriptBindings>}
|
||||
*/
|
||||
async getScriptBindings(script) {
|
||||
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
|
||||
const res = await cfRaw(path);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
|
||||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||||
}
|
||||
/** @type {RawWorkerBinding[]} */
|
||||
const raw = res.result?.bindings ?? [];
|
||||
return {
|
||||
deployed: true,
|
||||
bindings: normalizeLiveBindings(raw),
|
||||
vars: normalizeLiveVars(raw),
|
||||
};
|
||||
},
|
||||
|
||||
/** @returns {Promise<Map<string, string>>} title → id */
|
||||
async listKvNamespaces() {
|
||||
/** @type {Array<{id: string, title: string}>} */
|
||||
const result = await cf('/storage/kv/namespaces?per_page=100');
|
||||
const map = new Map();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
},
|
||||
|
||||
/** @returns {Promise<Map<string, string>>} name → uuid */
|
||||
async listD1Databases() {
|
||||
/** @type {Array<{uuid: string, name: string}>} */
|
||||
const result = await cf('/d1/database?per_page=100');
|
||||
const map = new Map();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
},
|
||||
|
||||
/** @returns {Promise<string[]>} */
|
||||
async listVectorizeIndexes() {
|
||||
/** @type {Array<{name: string}>} */
|
||||
const result = await cf('/vectorize/v2/indexes');
|
||||
return (result ?? []).map((i) => i.name);
|
||||
},
|
||||
|
||||
/**
|
||||
* 無條件新建一顆 KV namespace。
|
||||
*
|
||||
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
|
||||
* (安裝器取的名字跟 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
|
||||
* 要不要建一律先過 `planResources`。
|
||||
*
|
||||
* @param {string} title
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createKvNamespace(title) {
|
||||
const result = await cf('/storage/kv/namespaces', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
return result.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。
|
||||
* @param {string} name
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createD1Database(name) {
|
||||
const result = await cf('/d1/database', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return result.uuid;
|
||||
},
|
||||
|
||||
/**
|
||||
* 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**)。
|
||||
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。
|
||||
* 沒有 ensure 版本:「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createVectorizeIndex(name) {
|
||||
const res = await cfRaw('/vectorize/v2/indexes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
});
|
||||
if (res.ok) return name;
|
||||
const detail = (res.error ?? '').toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
|
||||
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* installer-entry.mjs — 安裝器那條路的**唯一入口**。
|
||||
*
|
||||
* 安裝器(arcrun-rag `installer/oauth-prototype/worker.js`)不必、也不准自己判斷
|
||||
* 「該建哪些資源」——它只要呼叫這一支,拿回「每個 binding 該用哪顆資源」。
|
||||
*
|
||||
* ```js
|
||||
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
|
||||
*
|
||||
* const r = await resolveInstanceResources({
|
||||
* accountId, apiToken,
|
||||
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
|
||||
* mode: isUpdate ? 'update' : 'init',
|
||||
* });
|
||||
* if (r.blocked) {
|
||||
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
|
||||
* return showAndStop(r.blockers);
|
||||
* }
|
||||
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
|
||||
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
|
||||
* ```
|
||||
*
|
||||
* 為什麼安裝器不需要副本:安裝器本來就會下載本 repo 的 archive 當部署來源
|
||||
* (見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
|
||||
* `shared/resource-rule/` 就在那份 archive 裡,直接 import 即可——
|
||||
* **不必再編一次、不必貼一份、也就不會有第二種答案。**
|
||||
*/
|
||||
|
||||
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
|
||||
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolveOptions
|
||||
* @property {string} accountId
|
||||
* @property {string} apiToken
|
||||
* @property {string[]} wranglerTomls 各 worker 的 wrangler.toml **內容**(不是路徑)。
|
||||
* @property {'update' | 'init'} mode 這台照定義裝過了沒。
|
||||
* @property {typeof globalThis.fetch} [fetch] 注入用(測試/宿主自帶 fetch)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolveResult
|
||||
* @property {boolean} blocked true = 什麼都沒建、什麼都不該部署。
|
||||
* @property {string[]} blockers blocked 時的原因原文(要原樣轉給使用者)。
|
||||
* @property {Record<string, string>} bindings `${kind}:${binding}` → 資源 id/index 名。
|
||||
* @property {Record<string, 'adopted'|'created'>} origin 同上 key → 這顆是沿用還是新建。
|
||||
* @property {Record<string, Record<string, string>>} liveVars script → 現有 plain_text var(#106)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 決定這台實例每個 binding 該用哪顆資源;照規則沿用既有、只在確定沒人綁過時才新建。
|
||||
*
|
||||
* @param {ResolveOptions} options
|
||||
* @returns {Promise<ResolveResult>}
|
||||
*/
|
||||
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
|
||||
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
|
||||
|
||||
/** @type {import('./rule.mjs').BindingRequirement[]} */
|
||||
const requirements = [];
|
||||
for (const toml of wranglerTomls) {
|
||||
const parsed = parseWranglerRequirements(toml);
|
||||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||||
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
|
||||
/** @param {string[]} blockers @returns {ResolveResult} */
|
||||
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
|
||||
|
||||
if (requirements.length === 0) {
|
||||
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
|
||||
}
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = await planResources(api, requirements, mode);
|
||||
} catch (e) {
|
||||
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
|
||||
}
|
||||
if (plan.blockers.length > 0) return stop(plan.blockers);
|
||||
|
||||
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await applyResourcePlan(api, plan);
|
||||
} catch (e) {
|
||||
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const bindings = {};
|
||||
/** @type {Record<string, 'adopted'|'created'>} */
|
||||
const origin = {};
|
||||
for (const [key, r] of resolved) {
|
||||
bindings[key] = r.value;
|
||||
origin[key] = r.origin;
|
||||
}
|
||||
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* rule.mjs — 「這個實例該用哪些資源」的**唯一一份**規則。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 這份檔案為什麼在這裡(`shared/`),不在 `cli/`
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
|
||||
*
|
||||
* `.claude/rules/07-thin-shell.md` 的判準口訣:
|
||||
* 「這段邏輯換一個介面要不要重寫?」要重寫 → 它是能力,該在共用層。
|
||||
*
|
||||
* 「該沿用哪幾顆資源」換到安裝器就得重寫一次 ⇒ 它是**能力**,不是薄殼的事。
|
||||
* 而它原本住在 `cli/src/lib/resource-resolver.ts` ⇒ 那本身就是違規,
|
||||
* 後果也真的發生了:`acr` 那條有這條規則、安裝器那條沒有,於是安裝器照名字找、
|
||||
* 找不到就建新的空的 ⇒ Arcrun#97「我按了更新,工作流和登入全不見了」。
|
||||
*
|
||||
* ── 為什麼不是 cypher-executor 的 API 端點(薄殼原則的標準答案)────────────
|
||||
* **自舉**:這條規則要在「決定怎麼裝/怎麼更新」的當下就用得到,而那個當下
|
||||
* cypher 可能還不存在(安裝器的工作正是把它生出來),或正要被覆蓋。
|
||||
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**——
|
||||
* 把它送去一顆平台託管的 worker 換一個答案,等於①讓「能不能安裝」綁在平台是否活著,
|
||||
* ②把使用者的帳號拓撲交給第三方。兩件都不該為了形式上的漂亮而做。
|
||||
*
|
||||
* 薄殼原則要求的是「能力只實作一次」,不是「能力一定要是 HTTP」。
|
||||
* 這條規則是**純函式**(唯一的 IO 由呼叫端注入 `ResourceApi`),
|
||||
* 所以它用不著變成服務——一份零依賴的 ESM 就能讓每條路吃到同一份判斷。
|
||||
*
|
||||
* ── 怎麼讓兩條路吃到「同一份」而不是各留一份 ───────────────────────────────
|
||||
* 本檔是**唯一被人手維護的實作**,零依賴、不吃任何 node 內建、Workers runtime 可直接跑。
|
||||
* · `acr`:`cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**,
|
||||
* 由 `scripts/sync-resource-rule.mjs` 產生(CLI 要能單獨 npm publish,
|
||||
* 套件目錄外的檔案打不進 tarball,故必須有這一份)。
|
||||
* `npm run build` / `npm test` 都會跑 `--check`,內容一漂就紅。
|
||||
* ——同 `cli/harness/`(產生物+世代閘)的既有慣例。
|
||||
* · 安裝器 / 任何 Worker:安裝器本來就會下載本 repo 的 archive(部署來源,
|
||||
* 見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
|
||||
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可,**不需要再編一次、也不留副本**。
|
||||
* 用法見同目錄 README.md。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 規則本身(leo 的兩句話)
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 「如果你沒有裝,就是新的;如果你已經有,原來叫什麼名字就繼續用下去。」
|
||||
*
|
||||
* 判準是「**這顆 worker 現在綁著誰**」,不是「有沒有叫這個名字的資源」:
|
||||
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
|
||||
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
|
||||
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
|
||||
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
|
||||
*
|
||||
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
|
||||
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
|
||||
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
|
||||
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
|
||||
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
|
||||
*
|
||||
* 🔴 這份檔案沒有 import、也不准有。任何依賴都會讓某一條路吃不到它。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
|
||||
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。
|
||||
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
|
||||
*/
|
||||
|
||||
/**
|
||||
* 從已部署 worker 上讀回來的一條綁定。`value`:KV/D1 是資源 id,Vectorize 是 index 名。
|
||||
* @typedef {object} LiveBinding
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ScriptBindings
|
||||
* @property {boolean} deployed
|
||||
* false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。
|
||||
* @property {LiveBinding[]} bindings
|
||||
* @property {Record<string, string>} [vars]
|
||||
* 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。
|
||||
*
|
||||
* 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize),
|
||||
* plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
|
||||
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)——
|
||||
* 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。
|
||||
* **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 規則需要的 CF 能力(收窄成介面,方便離線測試餵假帳號,也讓安裝器用自己的 fetch 實作)。
|
||||
* @typedef {object} ResourceApi
|
||||
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
|
||||
* @property {() => Promise<Map<string, string>>} listKvNamespaces title → id
|
||||
* @property {() => Promise<Map<string, string>>} listD1Databases name → uuid
|
||||
* @property {() => Promise<string[]>} listVectorizeIndexes
|
||||
* @property {(title: string) => Promise<string>} createKvNamespace
|
||||
* @property {(name: string) => Promise<string>} createD1Database
|
||||
* @property {(name: string) => Promise<string>} createVectorizeIndex
|
||||
*/
|
||||
|
||||
/**
|
||||
* 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。
|
||||
* @typedef {object} BindingRequirement
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
|
||||
* @property {string} createName
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} PlannedAdopt
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {string} from 從哪顆已部署的 worker 上讀到的
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} PlannedCreate
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} createName
|
||||
* @property {string[]} wantedBy
|
||||
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ResourcePlan
|
||||
* @property {PlannedAdopt[]} adopt
|
||||
* @property {PlannedCreate[]} create
|
||||
* @property {string[]} blockers 非空 = 整趟停手。applyResourcePlan 會拒絕執行。
|
||||
* @property {Map<string, Record<string, string>>} liveVars
|
||||
* 每顆**已部署** worker 現在掛著的 plain_text var(script → 名/值)。未部署的不在裡面。
|
||||
*
|
||||
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
|
||||
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
|
||||
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolvedResource
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {'adopted' | 'created'} origin
|
||||
* @property {string} [from]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} WranglerRequirements
|
||||
* @property {string} script worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。
|
||||
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
|
||||
*/
|
||||
|
||||
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
|
||||
export class ResourcePlanBlocked extends Error {
|
||||
/** @param {string[]} blockers */
|
||||
constructor(blockers) {
|
||||
super(`資源解析被擋下(${blockers.length} 項)`);
|
||||
this.name = 'ResourcePlanBlocked';
|
||||
/** @type {string[]} */
|
||||
this.blockers = blockers;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ResourceKind} kind
|
||||
* @param {string} binding
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bindingKey(kind, binding) {
|
||||
return `${kind}:${binding}`;
|
||||
}
|
||||
|
||||
/** @type {Record<ResourceKind, string>} */
|
||||
export const KIND_LABEL = {
|
||||
kv_namespace: 'KV namespace',
|
||||
d1: 'D1 資料庫',
|
||||
vectorize: 'Vectorize index',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {unknown} e
|
||||
* @returns {string}
|
||||
*/
|
||||
function msg(e) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
|
||||
*
|
||||
* @param {ResourceApi} api
|
||||
* @param {readonly BindingRequirement[]} requirements
|
||||
* @param {'update' | 'init'} mode
|
||||
* 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
|
||||
* @returns {Promise<ResourcePlan>}
|
||||
*/
|
||||
export async function planResources(api, requirements, mode) {
|
||||
/** @type {string[]} */
|
||||
const blockers = [];
|
||||
/** @type {PlannedAdopt[]} */
|
||||
const adopt = [];
|
||||
/** @type {PlannedCreate[]} */
|
||||
const create = [];
|
||||
|
||||
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
|
||||
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
|
||||
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
|
||||
/** @type {Map<string, LiveBinding[]>} */
|
||||
const live = new Map();
|
||||
/** @type {Map<string, Record<string, string>>} */
|
||||
const liveVars = new Map();
|
||||
let readFailed = false;
|
||||
for (const script of scripts) {
|
||||
try {
|
||||
const res = await api.getScriptBindings(script);
|
||||
if (res.deployed) {
|
||||
live.set(script, res.bindings);
|
||||
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
|
||||
liveVars.set(script, res.vars ?? {});
|
||||
}
|
||||
} catch (e) {
|
||||
readFailed = true;
|
||||
blockers.push(
|
||||
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
|
||||
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
|
||||
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
|
||||
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
|
||||
blockers.push(
|
||||
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
|
||||
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
|
||||
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
|
||||
`已停手,沒有新建任何資源。`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
|
||||
/** @type {Map<string, BindingRequirement[]>} */
|
||||
const byKey = new Map();
|
||||
for (const req of requirements) {
|
||||
const key = bindingKey(req.kind, req.binding);
|
||||
const list = byKey.get(key);
|
||||
if (list) list.push(req);
|
||||
else byKey.set(key, [req]);
|
||||
}
|
||||
|
||||
/** @type {Map<ResourceKind, Set<string>>} */
|
||||
const existingCache = new Map();
|
||||
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
|
||||
const listExisting = async (kind) => {
|
||||
const hit = existingCache.get(kind);
|
||||
if (hit) return hit;
|
||||
/** @type {Set<string>} */
|
||||
let set;
|
||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
||||
else set = new Set(await api.listVectorizeIndexes());
|
||||
existingCache.set(kind, set);
|
||||
return set;
|
||||
};
|
||||
|
||||
for (const [, reqs] of byKey) {
|
||||
const { kind, binding } = reqs[0];
|
||||
|
||||
/** @type {Array<{value: string, script: string}>} */
|
||||
const found = [];
|
||||
for (const [script, bindings] of live) {
|
||||
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
|
||||
if (hit) found.push({ value: hit.value, script });
|
||||
}
|
||||
const distinct = [...new Set(found.map((f) => f.value))];
|
||||
|
||||
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
|
||||
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
|
||||
if (distinct.length > 1) {
|
||||
blockers.push(
|
||||
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
|
||||
`(${found.map((f) => `${f.script} → ${f.value}`).join('、')})。` +
|
||||
`分不出哪一顆才是你在用的,不猜——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
||||
if (distinct.length === 1) {
|
||||
const value = distinct[0];
|
||||
/** @type {Set<string>} */
|
||||
let existing;
|
||||
try {
|
||||
existing = await listExisting(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||
`(${msg(e)})。不確定就不動——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!existing.has(value)) {
|
||||
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
||||
blockers.push(
|
||||
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
||||
`但這顆在你的 Cloudflare 帳號上找不到了。` +
|
||||
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
|
||||
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
adopt.push({ kind, binding, value, from: found[0].script });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
|
||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
||||
create.push({
|
||||
kind,
|
||||
binding,
|
||||
createName: reqs[0].createName,
|
||||
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
||||
alsoBind: [],
|
||||
});
|
||||
}
|
||||
|
||||
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
|
||||
}
|
||||
|
||||
/**
|
||||
* 收斂「不同 binding 其實是同一顆資源」的情況。
|
||||
*
|
||||
* 判準是 **toml 自己宣告的名字**(`database_name` / `index_name`),不是使用者那側的資源名——
|
||||
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`,
|
||||
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
|
||||
*
|
||||
* 沒有這一步會出兩種錯:
|
||||
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
|
||||
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
|
||||
*
|
||||
* @param {PlannedAdopt[]} adopt
|
||||
* @param {PlannedCreate[]} create
|
||||
* @param {Map<string, BindingRequirement[]>} byKey
|
||||
* @returns {PlannedCreate[]}
|
||||
*/
|
||||
function shareSameResource(adopt, create, byKey) {
|
||||
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
|
||||
const declaredName = (kind, binding) =>
|
||||
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
|
||||
|
||||
/** @type {PlannedCreate[]} */
|
||||
const out = [];
|
||||
/** @type {Map<string, PlannedCreate>} */
|
||||
const groups = new Map();
|
||||
|
||||
for (const c of create) {
|
||||
const groupKey = `${c.kind} ${c.createName}`;
|
||||
|
||||
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
|
||||
const twin = adopt.find(
|
||||
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
|
||||
);
|
||||
if (twin) {
|
||||
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
|
||||
const head = groups.get(groupKey);
|
||||
if (head) {
|
||||
head.alsoBind.push(c.binding);
|
||||
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
|
||||
continue;
|
||||
}
|
||||
groups.set(groupKey, c);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 照 plan 動手:沿用的原樣帶出來,該建的才建。
|
||||
* 有任何 blocker 直接丟 ResourcePlanBlocked,**一顆都不建**。
|
||||
*
|
||||
* @param {ResourceApi} api
|
||||
* @param {ResourcePlan} plan
|
||||
* @returns {Promise<Map<string, ResolvedResource>>}
|
||||
*/
|
||||
export async function applyResourcePlan(api, plan) {
|
||||
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
|
||||
|
||||
/** @type {Map<string, ResolvedResource>} */
|
||||
const out = new Map();
|
||||
for (const a of plan.adopt) {
|
||||
out.set(bindingKey(a.kind, a.binding), {
|
||||
kind: a.kind,
|
||||
binding: a.binding,
|
||||
value: a.value,
|
||||
origin: 'adopted',
|
||||
from: a.from,
|
||||
});
|
||||
}
|
||||
/** @type {string[]} */
|
||||
const madeSoFar = [];
|
||||
for (const c of plan.create) {
|
||||
/** @type {string} */
|
||||
let value;
|
||||
try {
|
||||
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
|
||||
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
|
||||
else value = await api.createVectorizeIndex(c.createName);
|
||||
} catch (e) {
|
||||
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
|
||||
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
|
||||
const orphans = madeSoFar.length > 0
|
||||
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
|
||||
: '';
|
||||
throw new Error(`建 ${KIND_LABEL[c.kind]}「${c.createName}」失敗:${msg(e)}${orphans}`);
|
||||
}
|
||||
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
|
||||
for (const binding of [c.binding, ...c.alsoBind]) {
|
||||
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// wrangler.toml → 需求清單
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。
|
||||
* @type {Record<string, ResourceKind>}
|
||||
*/
|
||||
export const TABLE_KIND = {
|
||||
kv_namespaces: 'kv_namespace',
|
||||
d1_databases: 'd1',
|
||||
vectorize: 'vectorize',
|
||||
};
|
||||
|
||||
/**
|
||||
* 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
|
||||
*
|
||||
* 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作,
|
||||
* 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
|
||||
* (kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。
|
||||
*
|
||||
* 也是「零依賴」的一部分:不引 TOML parser ⇒ 安裝器 import 這支不必多裝任何東西。
|
||||
*
|
||||
* @param {string} toml
|
||||
* @returns {WranglerRequirements}
|
||||
*/
|
||||
export function parseWranglerRequirements(toml) {
|
||||
let script = '';
|
||||
let seenTable = false;
|
||||
/** @type {WranglerRequirements['bindings']} */
|
||||
const bindings = [];
|
||||
|
||||
/** @type {ResourceKind | null} */
|
||||
let kind = null;
|
||||
let binding = '';
|
||||
let createName = '';
|
||||
|
||||
const flush = () => {
|
||||
if (kind && binding) {
|
||||
bindings.push({ kind, binding, createName: createName || binding });
|
||||
}
|
||||
kind = null;
|
||||
binding = '';
|
||||
createName = '';
|
||||
};
|
||||
|
||||
for (const raw of toml.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line === '' || line.startsWith('#')) continue;
|
||||
|
||||
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
|
||||
if (table) {
|
||||
flush();
|
||||
seenTable = true;
|
||||
kind = TABLE_KIND[table[1]] ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (!kv) continue;
|
||||
const [, key, value] = kv;
|
||||
|
||||
if (!seenTable && key === 'name') {
|
||||
script = value;
|
||||
continue;
|
||||
}
|
||||
if (!kind) continue;
|
||||
if (key === 'binding') binding = value;
|
||||
// 只有 D1/Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
|
||||
else if (key === 'database_name' || key === 'index_name') createName = value;
|
||||
}
|
||||
flush();
|
||||
|
||||
return { script, bindings };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
|
||||
* (同一種資源在不同 API 版本欄位名不一,故全都收)。
|
||||
*
|
||||
* @typedef {object} RawWorkerBinding
|
||||
* @property {string} [type]
|
||||
* @property {string} [name]
|
||||
* @property {string} [namespace_id]
|
||||
* @property {string} [id]
|
||||
* @property {string} [database_id]
|
||||
* @property {string} [index_name]
|
||||
* @property {string} [text] `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 把 CF 的 binding 陣列收斂成規則認得的三種資源。不認得的型別直接略過。
|
||||
*
|
||||
* 🔴 這支**刻意放在規則裡**,不留在各自的 CF client:
|
||||
* 「什麼才算『這顆 worker 綁著某顆資源』」是規則的一部分。
|
||||
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來(例如一邊認 `namespace_id`、
|
||||
* 另一邊只認 `id`,於是一邊看得到綁定、另一邊看不到 → 後者又去新建了)。
|
||||
*
|
||||
* @param {RawWorkerBinding[]} raw
|
||||
* @returns {LiveBinding[]}
|
||||
*/
|
||||
export function normalizeLiveBindings(raw) {
|
||||
/** @type {LiveBinding[]} */
|
||||
const out = [];
|
||||
for (const b of raw) {
|
||||
if (!b?.name) continue;
|
||||
if (b.type === 'kv_namespace') {
|
||||
const value = b.namespace_id ?? b.id;
|
||||
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
|
||||
} else if (b.type === 'd1' || b.type === 'd1_database') {
|
||||
const value = b.id ?? b.database_id;
|
||||
if (value) out.push({ kind: 'd1', binding: b.name, value });
|
||||
} else if (b.type === 'vectorize') {
|
||||
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抽出已部署 worker 上的 `plain_text` var(#106)。
|
||||
*
|
||||
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被搬來搬去;
|
||||
* wrangler deploy 不會動 secret,它們自己會留著)。
|
||||
*
|
||||
* @param {RawWorkerBinding[]} raw
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function normalizeLiveVars(raw) {
|
||||
/** @type {Record<string, string>} */
|
||||
const out = {};
|
||||
for (const b of raw) {
|
||||
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Arcrun#108 迴歸守衛 —— 「雲端讀資料用的命名空間,要跟你寫資料用的那個一致」
|
||||
*
|
||||
* 2026-08-12 實害:leo 的藏書地圖回 0 個庫,實際有 1854 條三元組。
|
||||
* 根因:你 push 工作流、小幫手上傳知識、MCP 查詢都用 `~/.arcrun/config.yaml` 的 `api_key`
|
||||
* (leo = `bfezv28v`),但 cypher 讀取時的 owner_id 來自 worker 環境變數
|
||||
* ——而那個變數是 repo toml 帶的**官方 prod 值** `CONSOLE_TENANT = "leo"`。
|
||||
* 寫在 A、讀在 B,全被過濾掉。
|
||||
*
|
||||
* 這份測試守兩件相反的事(本次的核心判斷):
|
||||
* · 驗得到知識 → **寫** `ARCRUN_NAMESPACE`,讓讀寫兩端對齊
|
||||
* · 驗不到 / 問不到 → **一個字都不動**,既有值原封保留
|
||||
* (無條件覆蓋會把一台「知識本來就寫在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
|
||||
* ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟)
|
||||
*
|
||||
* 全部離線跑:真的 wrangler.toml + 真的 render 程式碼,fetch 用假的,不碰任何實例。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
renderWranglerToml,
|
||||
preservedVars,
|
||||
namespaceHasKnowledge,
|
||||
VERSION_STAMP_WORKER,
|
||||
type DeployContext,
|
||||
} from '../src/lib/deploy.ts';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||||
|
||||
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
|
||||
const LEO_NS = 'bfezv28v';
|
||||
|
||||
const CTX: DeployContext = {
|
||||
accountId: 'acc-user-123',
|
||||
apiToken: 'token',
|
||||
workerSubdomain: 'user-sub',
|
||||
selfHosted: true,
|
||||
kbdbEmbed: true,
|
||||
};
|
||||
|
||||
function readVars(toml: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
let inVars = false;
|
||||
for (const line of toml.split('\n')) {
|
||||
if (/^\s*\[vars\]/.test(line)) { inVars = true; continue; }
|
||||
if (/^\s*\[/.test(line)) { inVars = false; continue; }
|
||||
if (!inVars) continue;
|
||||
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (m) out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 模擬 downloadAndDeploy 那段:沿用既有 var,再疊上這趟 CLI 算出來的值。 */
|
||||
function deployedVars(ctx: DeployContext, live: Record<string, string>): Record<string, string> {
|
||||
const keep = preservedVars(live, CYPHER_TOML);
|
||||
const extra: Record<string, string> = { ...keep };
|
||||
if (ctx.knowledgeNamespace) extra.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||||
return readVars(renderWranglerToml(CYPHER_TOML, ctx, new Map(), extra));
|
||||
}
|
||||
|
||||
// ── ① 驗得到知識 → 寫進去 ────────────────────────────────────────────────────
|
||||
|
||||
test('#108 給了 knowledgeNamespace → cypher [vars] 出現 ARCRUN_NAMESPACE(讀寫兩端終於同一個值)', () => {
|
||||
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, {});
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
|
||||
// CONSOLE_TENANT 一個字都不能動——它同時是帳號子 namespace 的組成,改了舊實例登不進去
|
||||
assert.equal(vars.CONSOLE_TENANT, 'leo');
|
||||
});
|
||||
|
||||
test('#108 蓋得過 worker 上的舊值(改名/搬遷後 acr update 要能修正,不是永遠沿用第一次那個)', () => {
|
||||
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, { ARCRUN_NAMESPACE: 'stale-ns' });
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
|
||||
});
|
||||
|
||||
// ── ② 驗不到 → 什麼都不動(比 bug 更糟的是把人家原本正常的實例弄空)──────────────
|
||||
|
||||
test('#108 沒給 knowledgeNamespace → 既有的 ARCRUN_NAMESPACE 原封保留(不因為這趟驗不到就洗掉)', () => {
|
||||
const vars = deployedVars(CTX, { ARCRUN_NAMESPACE: 'user-existing-ns' });
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, 'user-existing-ns');
|
||||
});
|
||||
|
||||
test('#108 沒給、worker 上也沒有 → 不注入(回退 CONSOLE_TENANT,舊實例行為一字不變)', () => {
|
||||
const vars = deployedVars(CTX, {});
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, undefined);
|
||||
assert.equal(vars.CONSOLE_TENANT, 'leo');
|
||||
});
|
||||
|
||||
test('#108 ARCRUN_NAMESPACE 不在 CLI_MANAGED_VARS:它「不是每趟重算」而是「驗到才寫」,' +
|
||||
'列進去會讓驗不到的那趟把既有值一起洗掉', async () => {
|
||||
const { CLI_MANAGED_VARS } = await import('../src/lib/deploy.ts');
|
||||
assert.equal((CLI_MANAGED_VARS as readonly string[]).includes('ARCRUN_NAMESPACE'), false);
|
||||
});
|
||||
|
||||
test('#108 只烙在 cypher 這顆 worker(其他 worker 不需要知識命名空間)', () => {
|
||||
assert.equal(VERSION_STAMP_WORKER, 'arcrun-cypher-executor');
|
||||
});
|
||||
|
||||
// ── ③ 「先驗再寫」那支探針的三態 ───────────────────────────────────────────────
|
||||
|
||||
test('namespaceHasKnowledge:這個命名空間底下查得到庫 → true(可以安全寫進去)', async () => {
|
||||
const calls: string[] = [];
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
calls.push(String(url));
|
||||
assert.equal((init?.headers as Record<string, string>)['X-Arcrun-API-Key'], LEO_NS);
|
||||
return new Response(JSON.stringify({ success: true, libraries: [{ library: 'kb' }], count: 1 }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), true);
|
||||
assert.equal(calls[0], `https://cypher.example.dev/kbdb/map?owner_id=${LEO_NS}`);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:查得到但是空的 → false(知識可能在別的命名空間,不准蓋)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: true, libraries: [], count: 0 }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), false);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:問不到(實例沒起來/舊版沒這條路/網路斷)→ null,不宣稱任何事', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:回應形狀不對 → null(讀不出來 ≠ 沒有資料,禁假綠)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:缺 url 或缺 namespace → null(不打任何請求)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () => { throw new Error('不該被呼叫'); }) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('', LEO_NS), null);
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', ''), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** `node --import ./tests/register-ts-hooks.mjs --test ...` 的進入點:註冊 ts-hooks.mjs。 */
|
||||
import { register } from 'node:module';
|
||||
|
||||
register('./ts-hooks.mjs', import.meta.url);
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Arcrun#97 迴歸守衛 —— 「跑完更新,使用者的東西還在原地」
|
||||
*
|
||||
* 2026-08-12 實害:leo 跑了一次例行更新,跑完工作流一支都沒有、portal 把他登出、
|
||||
* 總圖是空的、子庫全不見。資料沒被刪,但 worker 被重新綁到 9 顆新建的空 KV + 1 顆空 D1 上。
|
||||
*
|
||||
* 根因:更新「照名字」確保資源存在——拿 binding 名(WEBHOOKS)當 CF 上的資源標題去找,
|
||||
* 安裝器建的資源叫 `arcrun-rag-<instance>-kv-webhooks`,永遠對不上 ⇒ 每次更新都新建一顆綁上去。
|
||||
*
|
||||
* 這份測試用一台**照安裝器命名慣例**的假實例(不是 leo 的實例,不碰 leo21c)跑真正的
|
||||
* 解析程式碼(planResources / applyResourcePlan / renderWranglerToml),對照更新前後:
|
||||
* ① 工作流數、登入狀態、子庫數 ② 帳號上的資源顆數 ③ 找不到既有資源時要停手
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
planResources,
|
||||
applyResourcePlan,
|
||||
parseWranglerRequirements,
|
||||
bindingKey,
|
||||
ResourcePlanBlocked,
|
||||
type BindingRequirement,
|
||||
type ResourceApi,
|
||||
type ScriptBindings,
|
||||
type LiveBinding,
|
||||
type ResolvedResource,
|
||||
} from '../src/lib/resource-resolver.ts';
|
||||
import {
|
||||
renderWranglerToml,
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
type DeployContext,
|
||||
} from '../src/lib/deploy.ts';
|
||||
import { CfAccountClient } from '../src/lib/cf-api.ts';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
|
||||
/** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */
|
||||
const WORKER_TOMLS = [
|
||||
'cypher-executor/wrangler.toml',
|
||||
'registry/wrangler.toml',
|
||||
'mcp/wrangler.toml',
|
||||
'kbdb/wrangler.toml',
|
||||
];
|
||||
|
||||
const CTX: DeployContext = {
|
||||
accountId: 'acct-test',
|
||||
apiToken: 'tok-test',
|
||||
workerSubdomain: 'yuga3bse',
|
||||
selfHosted: true,
|
||||
kbdbEmbed: true,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const INSTANCE = 'yuga3bse';
|
||||
|
||||
interface FakeOpts {
|
||||
/** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */
|
||||
failBindingsFor?: string;
|
||||
/** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */
|
||||
deleteKvTitle?: string;
|
||||
/** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */
|
||||
nothingDeployed?: boolean;
|
||||
}
|
||||
|
||||
class FakeCloudflare implements ResourceApi {
|
||||
/** title → id */
|
||||
kv = new Map<string, string>();
|
||||
/** name → uuid */
|
||||
d1 = new Map<string, string>();
|
||||
vectorize: string[] = [];
|
||||
/** script → bindings */
|
||||
scripts = new Map<string, LiveBinding[]>();
|
||||
|
||||
/** 使用者的東西:kvId → (key → value) */
|
||||
kvData = new Map<string, Map<string, string>>();
|
||||
/** d1Id → 子庫名單 */
|
||||
d1Libraries = new Map<string, string[]>();
|
||||
|
||||
/** 這趟總共建立了什麼(驗「顆數不增加」用)。 */
|
||||
createdKv: string[] = [];
|
||||
createdD1: string[] = [];
|
||||
createdVectorize: string[] = [];
|
||||
|
||||
constructor(private opts: FakeOpts = {}) {
|
||||
// 安裝器的命名慣例:arcrun-rag-<instance>-kv-<binding 小寫>
|
||||
for (const binding of REQUIRED_KV_NAMESPACES) {
|
||||
const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`;
|
||||
const id = `kvid-${binding.toLowerCase()}`;
|
||||
this.kv.set(title, id);
|
||||
this.kvData.set(id, new Map());
|
||||
}
|
||||
this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb');
|
||||
this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`);
|
||||
|
||||
// 使用者的東西
|
||||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}');
|
||||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}');
|
||||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}');
|
||||
this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}');
|
||||
this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']);
|
||||
|
||||
if (!opts.nothingDeployed) {
|
||||
const kvB = (b: string): LiveBinding =>
|
||||
({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` });
|
||||
this.scripts.set('arcrun-cypher-executor', [
|
||||
kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'),
|
||||
kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'),
|
||||
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' },
|
||||
]);
|
||||
this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]);
|
||||
this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]);
|
||||
this.scripts.set('arcrun-kbdb', [
|
||||
{ kind: 'd1', binding: 'DB', value: 'd1id-kbdb' },
|
||||
{ kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` },
|
||||
]);
|
||||
}
|
||||
|
||||
if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle);
|
||||
}
|
||||
|
||||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)');
|
||||
const b = this.scripts.get(script);
|
||||
return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] };
|
||||
}
|
||||
async listKvNamespaces(): Promise<Map<string, string>> { return new Map(this.kv); }
|
||||
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
|
||||
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
|
||||
async createKvNamespace(title: string): Promise<string> {
|
||||
const id = `NEW-kvid-${this.createdKv.length}`;
|
||||
this.kv.set(title, id);
|
||||
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
|
||||
this.createdKv.push(title);
|
||||
return id;
|
||||
}
|
||||
async createD1Database(name: string): Promise<string> {
|
||||
const id = `NEW-d1id-${this.createdD1.length}`;
|
||||
this.d1.set(name, id);
|
||||
this.d1Libraries.set(id, []);
|
||||
this.createdD1.push(name);
|
||||
return id;
|
||||
}
|
||||
async createVectorizeIndex(name: string): Promise<string> {
|
||||
this.vectorize.push(name);
|
||||
this.createdVectorize.push(name);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
|
||||
const requirements: BindingRequirement[] = [];
|
||||
const tomls = new Map<string, string>();
|
||||
for (const rel of WORKER_TOMLS) {
|
||||
const raw = readFileSync(join(REPO, rel), 'utf8');
|
||||
tomls.set(rel, raw);
|
||||
const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
|
||||
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
return { requirements, tomls };
|
||||
}
|
||||
|
||||
/** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */
|
||||
function deployAndReadBindings(
|
||||
tomls: Map<string, string>,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
): Map<string, Map<string, string>> {
|
||||
const out = new Map<string, Map<string, string>>();
|
||||
for (const [rel, raw] of tomls) {
|
||||
const rendered = renderWranglerToml(raw, CTX, resolved);
|
||||
const script = parseWranglerRequirements(rendered).script;
|
||||
const bound = new Map<string, string>();
|
||||
let kind: string | null = null;
|
||||
let binding = '';
|
||||
for (const line of rendered.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith('#')) continue;
|
||||
const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
|
||||
if (table) { kind = table[1]; binding = ''; continue; }
|
||||
const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (!m) continue;
|
||||
if (m[1] === 'binding') binding = m[2];
|
||||
else if (binding && (
|
||||
(kind === 'kv_namespaces' && m[1] === 'id')
|
||||
|| (kind === 'd1_databases' && m[1] === 'database_id')
|
||||
|| (kind === 'vectorize' && m[1] === 'index_name')
|
||||
)) bound.set(binding, m[2]);
|
||||
}
|
||||
out.set(script, bound);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
const { requirements, tomls } = collectRequirements();
|
||||
|
||||
const before = {
|
||||
workflows: cf.kvData.get('kvid-webhooks')!.size,
|
||||
sessions: cf.kvData.get('kvid-sessions_kv')!.size,
|
||||
libraries: cf.d1Libraries.get('d1id-kbdb')!.length,
|
||||
};
|
||||
assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好');
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker');
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
|
||||
const bound = deployAndReadBindings(tomls, resolved);
|
||||
|
||||
// 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要)
|
||||
const cypher = bound.get('arcrun-cypher-executor')!;
|
||||
assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks');
|
||||
assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv');
|
||||
assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb');
|
||||
assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb');
|
||||
assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv');
|
||||
assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv');
|
||||
assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`);
|
||||
|
||||
const after = {
|
||||
workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size,
|
||||
sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size,
|
||||
libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length,
|
||||
};
|
||||
assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣');
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97 ②:更新不會在帳號上多生任何資源', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
const kvBefore = cf.kv.size;
|
||||
const d1Before = cf.d1.size;
|
||||
const vecBefore = cf.vectorize.length;
|
||||
assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]);
|
||||
|
||||
const { requirements } = collectRequirements();
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
await applyResourcePlan(cf, plan);
|
||||
|
||||
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
|
||||
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
|
||||
assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index');
|
||||
assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]);
|
||||
});
|
||||
|
||||
test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => {
|
||||
// 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與
|
||||
// cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。
|
||||
// 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。
|
||||
const cf = new FakeCloudflare();
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建
|
||||
}
|
||||
const d1s = await cf.listD1Databases();
|
||||
if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb');
|
||||
|
||||
assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)');
|
||||
assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆');
|
||||
assert.equal(cf.kv.size, 18, '9 → 18');
|
||||
assert.equal(cf.d1.size, 2, '1 → 2');
|
||||
// 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的
|
||||
assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97 ③-a:worker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => {
|
||||
const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` });
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.ok(plan.blockers.length > 0, '要有 blocker');
|
||||
const said = plan.blockers.join('\n');
|
||||
assert.match(said, /WEBHOOKS/, '要指名是哪個綁定');
|
||||
assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源');
|
||||
assert.match(said, /找不到/, '要說清楚發生什麼事');
|
||||
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」');
|
||||
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來');
|
||||
assert.deepEqual(cf.createdD1, []);
|
||||
});
|
||||
|
||||
test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => {
|
||||
const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' });
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/);
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, []);
|
||||
});
|
||||
|
||||
test('#97 ③-c:update 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/);
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, []);
|
||||
});
|
||||
|
||||
test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
// registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊)
|
||||
cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other';
|
||||
cf.kv.set('some-other-kv', 'kvid-other');
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/);
|
||||
assert.deepEqual(cf.createdKv, []);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 合法的新建:只有「確定沒人綁過」時才准
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的)
|
||||
cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`);
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.deepEqual(plan.blockers, []);
|
||||
assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建');
|
||||
await applyResourcePlan(cf, plan);
|
||||
assert.deepEqual(cf.createdKv, ['OAUTH_KV']);
|
||||
assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆');
|
||||
});
|
||||
|
||||
test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'init');
|
||||
assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手');
|
||||
await applyResourcePlan(cf, plan);
|
||||
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length);
|
||||
assert.deepEqual(cf.createdD1, ['arcrun-kbdb']);
|
||||
assert.equal(cf.createdVectorize.length, 1);
|
||||
});
|
||||
|
||||
test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb)
|
||||
const { requirements, tomls } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.deepEqual(plan.blockers, []);
|
||||
assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆');
|
||||
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
const bound = deployAndReadBindings(tomls, resolved);
|
||||
assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb',
|
||||
'credential 目錄要跟 KBDB 在同一顆庫');
|
||||
assert.deepEqual(cf.createdD1, []);
|
||||
});
|
||||
|
||||
test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => {
|
||||
// repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入,
|
||||
// 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。
|
||||
const cf = new FakeCloudflare();
|
||||
const { requirements, tomls } = collectRequirements();
|
||||
const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update'));
|
||||
|
||||
const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f';
|
||||
for (const [rel, raw] of tomls) {
|
||||
const rendered = renderWranglerToml(raw, CTX, resolved);
|
||||
assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`);
|
||||
assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`);
|
||||
}
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 做法本身的看守:不准再出現「照名字 ensure」這種原語
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => {
|
||||
const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8');
|
||||
assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/,
|
||||
'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。'
|
||||
+ '要建資源一律先過 resource-resolver 的 planResources。');
|
||||
});
|
||||
|
||||
test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => {
|
||||
for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) {
|
||||
const src = readFileSync(join(REPO, rel), 'utf8');
|
||||
assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/,
|
||||
`${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`);
|
||||
}
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 底層零件
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => {
|
||||
const toml = [
|
||||
'name = "arcrun-kbdb" # 註解不影響',
|
||||
'',
|
||||
'[[d1_databases]]',
|
||||
'binding = "DB"',
|
||||
'database_name = "arcrun-kbdb"',
|
||||
'database_id = "placeholder"',
|
||||
'',
|
||||
'[vars]',
|
||||
'ENVIRONMENT = "production"',
|
||||
'',
|
||||
'# [[vectorize]]',
|
||||
'# binding = "VECTORIZE"',
|
||||
'# index_name = "arcrun-kbdb-embed-m3"',
|
||||
].join('\n');
|
||||
const r = parseWranglerRequirements(toml);
|
||||
assert.equal(r.script, 'arcrun-kbdb');
|
||||
assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]);
|
||||
});
|
||||
|
||||
test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => {
|
||||
const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"');
|
||||
assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]);
|
||||
});
|
||||
|
||||
test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => {
|
||||
const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8');
|
||||
const resolved = new Map<string, ResolvedResource>([
|
||||
[bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }],
|
||||
[bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }],
|
||||
]);
|
||||
const out = renderWranglerToml(cypher, CTX, resolved);
|
||||
const bound = parseWranglerRequirements(out);
|
||||
assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB'));
|
||||
assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/);
|
||||
assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/);
|
||||
// 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved)
|
||||
assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/);
|
||||
});
|
||||
|
||||
test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => {
|
||||
for (const rel of WORKER_TOMLS) {
|
||||
const raw = readFileSync(join(REPO, rel), 'utf8');
|
||||
const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
|
||||
const resolved = new Map<string, ResolvedResource>(
|
||||
preview.bindings.map((b) => [
|
||||
bindingKey(b.kind, b.binding),
|
||||
{ kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const },
|
||||
]),
|
||||
);
|
||||
const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved));
|
||||
assert.deepEqual(
|
||||
after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
|
||||
preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
|
||||
`${rel}: 預覽與實際注入看到的綁定必須一致`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => {
|
||||
const { requirements } = collectRequirements();
|
||||
const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))];
|
||||
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
|
||||
});
|
||||
|
||||
test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
|
||||
) as typeof fetch;
|
||||
const cf = new CfAccountClient('a', 't');
|
||||
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [], vars: {} });
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
|
||||
) as typeof fetch;
|
||||
await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
success: true,
|
||||
result: {
|
||||
bindings: [
|
||||
{ type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' },
|
||||
{ type: 'd1', name: 'DB', id: 'db1' },
|
||||
{ type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' },
|
||||
{ type: 'plain_text', name: 'ENVIRONMENT', text: 'production' },
|
||||
{ type: 'service', name: 'SVC_SET', service: 'arcrun-set' },
|
||||
],
|
||||
},
|
||||
}), { status: 200 })) as typeof fetch;
|
||||
const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor');
|
||||
assert.equal(res.deployed, true);
|
||||
assert.deepEqual(res.bindings, [
|
||||
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' },
|
||||
{ kind: 'd1', binding: 'DB', value: 'db1' },
|
||||
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
|
||||
]);
|
||||
// #106:plain_text 也要收下來(service 這種不認得的仍略過)。
|
||||
assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 「只有一份」的機械證明。
|
||||
*
|
||||
* leo 的驗收條件:「改完之後,`grep` 得出『決定用哪些資源』的邏輯**只有一個地方**。
|
||||
* 兩個以上呼叫端各自有一份 ⇒ 不算完成。」
|
||||
*
|
||||
* 這份測試就是把那個 grep 寫成會紅的東西:
|
||||
* ① 規則的每一支函式,全 repo 只有 `shared/resource-rule/` 有實作
|
||||
* (`cli/src/lib/resource-rule/` 是它的逐位元組鏡射,由 sync 腳本產生並看守,不算第二份)
|
||||
* ② 鏡射與原稿逐位元組相同(sync --check 的同一道閘,這裡再測一次讓 `npm test` 也擋得住)
|
||||
* ③ 共用層不准長出依賴——有依賴就會有某條路吃不到它
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
|
||||
const MIRROR_DIR = join(REPO, 'cli/src/lib/resource-rule');
|
||||
|
||||
/** 規則的實作特徵:這些**宣告**只准出現在原稿目錄(與它的鏡射)裡。 */
|
||||
const RULE_DECLARATIONS = [
|
||||
'function planResources',
|
||||
'function applyResourcePlan',
|
||||
'function shareSameResource',
|
||||
'function parseWranglerRequirements',
|
||||
'function normalizeLiveBindings',
|
||||
'function normalizeLiveVars',
|
||||
'function createCloudflareResourceApi',
|
||||
];
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', '.wrangler', '.worker-builds', '.component-builds',
|
||||
'.github-public', 'coverage',
|
||||
]);
|
||||
|
||||
/** 只掃「人會寫程式的地方」;產生物與二進位不掃。 */
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const abs = join(dir, name);
|
||||
const st = statSync(abs);
|
||||
if (st.isDirectory()) walk(abs, out);
|
||||
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) out.push(abs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sha256 = (b: Buffer): string => createHash('sha256').update(b).digest('hex');
|
||||
|
||||
test('① 規則的實作全 repo 只有一份(原稿目錄 + 它的鏡射,沒有第三處)', () => {
|
||||
const files = walk(REPO);
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const abs of files) {
|
||||
const rel = relative(REPO, abs);
|
||||
// 原稿與鏡射本來就該有;測試檔在講規則、不是實作規則
|
||||
if (rel.startsWith('shared/resource-rule/')) continue;
|
||||
if (rel.startsWith('cli/src/lib/resource-rule/')) continue;
|
||||
if (rel.startsWith('cli/tests/')) continue;
|
||||
if (rel === 'scripts/sync-resource-rule.mjs') continue;
|
||||
|
||||
const src = readFileSync(abs, 'utf8');
|
||||
for (const decl of RULE_DECLARATIONS) {
|
||||
if (src.includes(decl)) offenders.push(`${rel} → ${decl}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(offenders, [],
|
||||
'「決定用哪些資源」的實作出現在共用層之外——這正是本票要消滅的東西:\n' +
|
||||
offenders.map((o) => ` • ${o}`).join('\n') +
|
||||
'\n要改規則就改 shared/resource-rule/,呼叫端只准 import。');
|
||||
|
||||
console.log(`\n ① 掃過 ${files.length} 個原始碼檔,${RULE_DECLARATIONS.length} 支規則函式的實作` +
|
||||
' 全部只出現在 shared/resource-rule/(+機械鏡射)');
|
||||
});
|
||||
|
||||
test('② CLI 帶的那份與原稿逐位元組相同(漂移=第二份實作偷偷長出來)', () => {
|
||||
const files = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
|
||||
assert.ok(files.length > 0, 'shared/resource-rule/ 裡沒有任何 .mjs 原稿');
|
||||
|
||||
const mirrored = readdirSync(MIRROR_DIR).filter((f) => f.endsWith('.mjs')).sort();
|
||||
assert.deepEqual(mirrored, files, '鏡射目錄的檔案清單與原稿不一致');
|
||||
|
||||
for (const f of files) {
|
||||
const a = sha256(readFileSync(join(SOURCE_DIR, f)));
|
||||
const b = sha256(readFileSync(join(MIRROR_DIR, f)));
|
||||
assert.equal(b, a, `cli/src/lib/resource-rule/${f} 與原稿不一致——不要手改產生物,` +
|
||||
'改 shared/resource-rule/ 後跑 node scripts/sync-resource-rule.mjs');
|
||||
console.log(` ② ${f.padEnd(24)} sha256 ${a.slice(0, 16)} 原稿 = 鏡射`);
|
||||
}
|
||||
});
|
||||
|
||||
test('③ 共用層零外部依賴(只准 import 同目錄的兄弟檔)', () => {
|
||||
for (const f of readdirSync(SOURCE_DIR).filter((x) => x.endsWith('.mjs'))) {
|
||||
const src = readFileSync(join(SOURCE_DIR, f), 'utf8');
|
||||
const imports = [...src.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1]);
|
||||
for (const spec of imports) {
|
||||
assert.ok(spec.startsWith('./'),
|
||||
`shared/resource-rule/${f} import 了 "${spec}"——共用層一旦有外部依賴,` +
|
||||
'就會有某條路(Workers runtime/安裝器)吃不到它。');
|
||||
}
|
||||
assert.doesNotMatch(src, /require\(|from\s+['"]node:/,
|
||||
`shared/resource-rule/${f} 用到 node 專屬 API——Cloudflare Workers 上跑不起來。`);
|
||||
console.log(` ③ ${f.padEnd(24)} import: ${imports.length ? imports.join(', ') : '(無)'}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 測試用 resolve hook:把 `./x.js` 這種 import 指回同名的 `./x.ts`(Arcrun#106 附帶修復)。
|
||||
*
|
||||
* 為什麼需要:`src/` 內部的 import 一律寫成 `.js`(NodeNext 慣例,編譯後才會有那個檔),
|
||||
* 但測試是**直接載入 `src/**\/*.ts`**、不經過 tsc(`outDir: dist`,所以 `src/` 底下永遠不會有 .js)。
|
||||
* Node 的型別剝離不會自己把 `.js` 對回 `.ts` ⇒ 三份測試在 node 22 上**一支都跑不起來**
|
||||
* (`ERR_MODULE_NOT_FOUND: .../src/lib/cf-api.js`)——包含 #97 那份「使用者的東西還在不在」的迴歸守衛。
|
||||
* 跑不起來的守衛等於沒有守衛,所以這裡補上。
|
||||
*
|
||||
* 只在「預設解析失敗」時才動作,且只換副檔名 → 對本來就解析得到的環境(新版 node / 已編譯)零影響。
|
||||
*/
|
||||
export async function resolve(specifier, context, next) {
|
||||
try {
|
||||
return await next(specifier, context);
|
||||
} catch (err) {
|
||||
if (typeof specifier === 'string' && specifier.endsWith('.js')) {
|
||||
return next(specifier.slice(0, -3) + '.ts', context);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 兩條路必須得出同一個答案 —— 本票的核心驗收。
|
||||
*
|
||||
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
|
||||
*
|
||||
* 後果已經真的發生過:`acr` 那條有 Arcrun#97 的修法、安裝器那條沒有,
|
||||
* 於是安裝器照名字找、找不到就建一顆空的綁上去 ⇒ 使用者的工作流與登入狀態整片消失。
|
||||
*
|
||||
* 這份測試把**同一個帳號狀態**餵給兩條路:
|
||||
* A. `acr` 那條:`CfAccountClient` + `resource-resolver`(CLI 真正跑的 import 鏈)
|
||||
* B. 安裝器那條:只 import `shared/resource-rule/`(安裝器唯一該碰的入口)
|
||||
* 然後比對它們選出的 **resource id 必須相同**。
|
||||
*
|
||||
* 假的是 `fetch`,不是 `ResourceApi`——所以兩條路都真的走完 HTTP → 解析 → 判斷整條鏈。
|
||||
* 只測判斷會漏掉「怎麼把 CF 回應讀成事實」,而 #97 的重演只要眼睛不一樣就夠了。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// ── A:acr 那條(CLI 真正用的東西)
|
||||
import { CfAccountClient } from '../src/lib/cf-api.ts';
|
||||
import { planResources, applyResourcePlan, bindingKey } from '../src/lib/resource-resolver.ts';
|
||||
import type { BindingRequirement } from '../src/lib/resource-resolver.ts';
|
||||
|
||||
// ── B:安裝器那條(只碰 shared/)
|
||||
import { resolveInstanceResources } from '../../shared/resource-rule/installer-entry.mjs';
|
||||
|
||||
// ── 共用 fixture
|
||||
import {
|
||||
makeAccount,
|
||||
requirements,
|
||||
SCENARIOS,
|
||||
WORKER_NEEDS,
|
||||
type Scenario,
|
||||
} from '../../shared/resource-rule/tests/fixture-account.mjs';
|
||||
|
||||
const ACCOUNT = 'acct-fixture';
|
||||
const TOKEN = 'tok-fixture';
|
||||
|
||||
/** 把 fixture 的需求組成安裝器吃的 wrangler.toml 文字(它的入口是從 toml 讀需求的)。 */
|
||||
function tomlsFor(): string[] {
|
||||
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
|
||||
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
|
||||
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
|
||||
for (const d of need.d1) {
|
||||
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
/** A:跑 acr 那條。CfAccountClient 走 global fetch,所以這裡把它換成 fixture。 */
|
||||
async function runAcrPath(scenario: Scenario, mode: 'update' | 'init') {
|
||||
const account = makeAccount(scenario);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = account.fetch;
|
||||
try {
|
||||
const api = new CfAccountClient(ACCOUNT, TOKEN);
|
||||
const plan = await planResources(api, requirements() as BindingRequirement[], mode);
|
||||
if (plan.blockers.length > 0) {
|
||||
return { blocked: true, blockers: plan.blockers, bindings: {} as Record<string, string>, account };
|
||||
}
|
||||
const resolved = await applyResourcePlan(api, plan);
|
||||
const bindings: Record<string, string> = {};
|
||||
for (const [k, r] of resolved) bindings[k] = r.value;
|
||||
return { blocked: false, blockers: [] as string[], bindings, account };
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
}
|
||||
|
||||
/** B:跑安裝器那條。只用 shared/ 的入口,fetch 直接注入。 */
|
||||
async function runInstallerPath(scenario: Scenario, mode: 'update' | 'init') {
|
||||
const account = makeAccount(scenario);
|
||||
const r = await resolveInstanceResources({
|
||||
accountId: ACCOUNT,
|
||||
apiToken: TOKEN,
|
||||
wranglerTomls: tomlsFor(),
|
||||
mode,
|
||||
fetch: account.fetch,
|
||||
});
|
||||
return { blocked: r.blocked, blockers: r.blockers, bindings: r.bindings, account };
|
||||
}
|
||||
|
||||
/** 把兩邊的決定印出來——PR 要貼的就是這張對照表。 */
|
||||
function report(scenario: Scenario, a: Record<string, string>, b: Record<string, string>): void {
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||
console.log(`\n ── ${scenario}:${SCENARIOS[scenario].label}`);
|
||||
console.log(` ${'binding'.padEnd(34)} ${'acr 選的'.padEnd(24)} 安裝器選的 一致?`);
|
||||
for (const k of keys) {
|
||||
const same = a[k] === b[k] ? '✓' : '✗';
|
||||
console.log(` ${k.padEnd(34)} ${(a[k] ?? '—').padEnd(24)} ${(b[k] ?? '—').padEnd(18)} ${same}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
for (const scenario of ['fresh', 'installed', 'renamed'] as const) {
|
||||
const mode = scenario === 'fresh' ? 'init' : 'update';
|
||||
|
||||
test(`兩條路一致 — ${scenario}:${SCENARIOS[scenario].label}`, async () => {
|
||||
const a = await runAcrPath(scenario, mode);
|
||||
const b = await runInstallerPath(scenario, mode);
|
||||
|
||||
assert.equal(a.blocked, b.blocked, '一邊停手、一邊照做 = 最危險的分歧');
|
||||
assert.deepEqual(a.blockers, b.blockers, '停手的理由也要一樣');
|
||||
report(scenario, a.bindings, b.bindings);
|
||||
assert.deepEqual(
|
||||
a.bindings,
|
||||
b.bindings,
|
||||
`${scenario}:兩條路選出的 resource id 不同——這就是 Arcrun#97 的形狀`,
|
||||
);
|
||||
|
||||
// 建立行為也要一致(一邊沿用、一邊新建 = 使用者的東西在其中一條路上會消失)
|
||||
assert.deepEqual(a.account.created, b.account.created, '兩條路「建了什麼」必須一樣');
|
||||
});
|
||||
}
|
||||
|
||||
// ── 三種情境各自該有的行為(不只是「兩邊一樣」,還要「一樣地對」)─────────────
|
||||
|
||||
test('情境① 沒裝過 → 正常建新的(不能為了沿用而變成永遠不建)', async () => {
|
||||
const { blocked, bindings, account } = await runInstallerPath('fresh', 'init');
|
||||
assert.equal(blocked, false, '全新帳號要裝得起來');
|
||||
assert.equal(account.created.kv.length, 9, `應新建 9 顆 KV,實際 ${account.created.kv.length}`);
|
||||
assert.equal(account.created.d1.length, 1, `應新建 1 顆 D1,實際 ${account.created.d1.length}`);
|
||||
// cypher 的 CREDENTIALS_DB 與 kbdb 的 DB 宣告同一個 database_name → 只該建一顆,兩邊共用
|
||||
assert.equal(bindings['d1:CREDENTIALS_DB'], bindings['d1:DB'], '同一顆 D1 不該被建成兩顆');
|
||||
console.log(`\n ① 新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆` +
|
||||
`(D1 共用:CREDENTIALS_DB = DB = ${bindings['d1:DB']})`);
|
||||
});
|
||||
|
||||
test('情境② 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在', async () => {
|
||||
const { blocked, bindings, account } = await runInstallerPath('installed', 'update');
|
||||
assert.equal(blocked, false);
|
||||
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '更新不該建出任何新資源');
|
||||
|
||||
// 使用者的東西掛在資源 id 上:綁定還指向原本那顆 = 東西還在
|
||||
assert.equal(bindings['kv_namespace:WEBHOOKS'], account.kvIdFor('WEBHOOKS'));
|
||||
assert.equal(bindings['kv_namespace:SESSIONS_KV'], account.kvIdFor('SESSIONS_KV'));
|
||||
assert.equal(bindings['d1:DB'], account.d1Id);
|
||||
console.log(`\n ② 沿用:WEBHOOKS → ${bindings['kv_namespace:WEBHOOKS']}` +
|
||||
`(工作流 ${account.userData.workflows.length} 支還在)|` +
|
||||
`SESSIONS_KV → ${bindings['kv_namespace:SESSIONS_KV']}(登入 session 還在)|` +
|
||||
`DB → ${bindings['d1:DB']}(子庫 ${account.userData.libraries.length} 個還在)|新建 0 顆`);
|
||||
});
|
||||
|
||||
test('情境③ 資源在但名字與預期完全不同 → 仍然沿用(#97 的病根,專門驗)', async () => {
|
||||
const { blocked, bindings, account } = await runInstallerPath('renamed', 'update');
|
||||
assert.equal(blocked, false);
|
||||
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] },
|
||||
'名字對不上就新建 = 正是 #97:一次更新生出 9 顆空 KV,使用者的東西從畫面上消失');
|
||||
for (const b of ['WEBHOOKS', 'SESSIONS_KV', 'RECIPES', 'USERS_KV']) {
|
||||
assert.equal(bindings[bindingKey('kv_namespace', b)], account.kvIdFor(b),
|
||||
`${b} 沒有沿用到原本那顆`);
|
||||
}
|
||||
console.log(`\n ③ 名字全不同(例:WEBHOOKS 那顆實際叫 "${SCENARIOS.renamed.titleFor('WEBHOOKS')}")` +
|
||||
` → 仍沿用 ${bindings['kv_namespace:WEBHOOKS']},新建 0 顆`);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」
|
||||
*
|
||||
* 2026-08-12 實害:leo 更新完 leo21c,Portal 設定頁的版本欄變成
|
||||
* 「無法讀取目前版本(知識庫服務可能正在啟動)」。
|
||||
* 根因:`ARCRUN_BUNDLE_VERSION` 是部署時注入的 plain_text var,**只有安裝器會注入**;
|
||||
* CLI 這條路重部署時 wrangler 整份覆蓋 toml,沒寫的 var 直接消失 ⇒ 標籤被洗掉。
|
||||
* #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),**沒修「櫃子上的標籤」**。
|
||||
*
|
||||
* 這份測試守兩件相反的事(本次的核心判斷):
|
||||
* · 設定類 var(安裝器注入的 PORTAL_MAIL_RELAY_BASE 之類)=使用者實例的事實 → **沿用**
|
||||
* · 版本標籤 ARCRUN_BUNDLE_VERSION =這份成品的屬性 → **每趟重烙,絕不沿用舊值**
|
||||
* (沿用舊值 = 一個永遠停在安裝當天的假標籤,比沒有標籤更糟)
|
||||
*
|
||||
* 全部離線跑:真的 wrangler.toml + 真的 render/inject 程式碼,fetch 用假的,不碰任何實例。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
renderWranglerToml,
|
||||
preservedVars,
|
||||
applyVars,
|
||||
resolveBundleStamp,
|
||||
CLI_MANAGED_VARS,
|
||||
VERSION_STAMP_WORKER,
|
||||
type DeployContext,
|
||||
} from '../src/lib/deploy.ts';
|
||||
import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||||
|
||||
const CTX: DeployContext = {
|
||||
accountId: 'acc-user-123',
|
||||
apiToken: 'token',
|
||||
workerSubdomain: 'user-sub',
|
||||
selfHosted: true,
|
||||
kbdbEmbed: true,
|
||||
};
|
||||
|
||||
/** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */
|
||||
const LIVE_VARS: Record<string, string> = {
|
||||
ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤
|
||||
PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信
|
||||
CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的
|
||||
WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算
|
||||
CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算
|
||||
MULTI_TENANT: 'false', // CLI 自己算
|
||||
ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫
|
||||
};
|
||||
|
||||
/** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */
|
||||
function readVars(toml: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
let inVars = false;
|
||||
for (const raw of toml.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; }
|
||||
if (!inVars || line.startsWith('#')) continue;
|
||||
const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (m) out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ① 病灶本身:舊行為會把標籤洗掉
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => {
|
||||
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||||
assert.equal(
|
||||
readVars(rendered).ARCRUN_BUNDLE_VERSION,
|
||||
undefined,
|
||||
'若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫',
|
||||
);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ② 設定類 var:沿用實例上的事實
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => {
|
||||
const keep = preservedVars(LIVE_VARS, CYPHER_TOML);
|
||||
assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
// repo toml 寫死的是官方值,使用者實例上的值才是事實
|
||||
assert.equal(keep.CONSOLE_TENANT, 'someone-else');
|
||||
// 與 toml 同值 → 不需要重寫進去(雜訊)
|
||||
assert.equal(keep.ENVIRONMENT, undefined);
|
||||
});
|
||||
|
||||
test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => {
|
||||
const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML);
|
||||
for (const managed of CLI_MANAGED_VARS) {
|
||||
assert.equal(keep[managed], undefined, `${managed} 不該被沿用`);
|
||||
}
|
||||
// 而且注入完的 toml 裡,這些值仍是這趟算出來的那個
|
||||
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep);
|
||||
const vars = readVars(rendered);
|
||||
assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub');
|
||||
assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123');
|
||||
assert.equal(vars.MULTI_TENANT, 'false');
|
||||
assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev');
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ③ 版本標籤:重烙,不沿用
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
assert.equal(stamp.version, '1.4.41');
|
||||
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue
|
||||
assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver');
|
||||
});
|
||||
|
||||
test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => {
|
||||
const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/);
|
||||
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION);
|
||||
assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」');
|
||||
});
|
||||
|
||||
test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch);
|
||||
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => {
|
||||
const api: ResourceApi = {
|
||||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} };
|
||||
return {
|
||||
deployed: true,
|
||||
bindings: [
|
||||
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' },
|
||||
{ kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' },
|
||||
{ kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' },
|
||||
{ kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' },
|
||||
{ kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' },
|
||||
{ kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' },
|
||||
{ kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' },
|
||||
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' },
|
||||
],
|
||||
vars: LIVE_VARS,
|
||||
};
|
||||
},
|
||||
async listKvNamespaces() {
|
||||
return new Map([
|
||||
['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'],
|
||||
['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'],
|
||||
]);
|
||||
},
|
||||
async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); },
|
||||
async listVectorizeIndexes() { return []; },
|
||||
async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); },
|
||||
async createD1Database() { throw new Error('這趟不該新建 D1'); },
|
||||
async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); },
|
||||
};
|
||||
|
||||
const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||||
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||||
const parsed = parseWranglerRequirements(preview);
|
||||
const plan = await planResources(
|
||||
api,
|
||||
parsed.bindings.map((b) => ({ ...b, worker: parsed.script })),
|
||||
'update',
|
||||
);
|
||||
assert.deepEqual(plan.blockers, []);
|
||||
// 讀綁定時順手把 var 帶回來——不另外打一次 API
|
||||
assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
const extra = {
|
||||
...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML),
|
||||
ARCRUN_BUNDLE_VERSION: stamp.version,
|
||||
ARCRUN_BUNDLE_COMMIT: stamp.commit!,
|
||||
};
|
||||
|
||||
const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra));
|
||||
|
||||
// ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本
|
||||
assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41');
|
||||
assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
// ② 安裝器注入的設定沒有在更新中消失
|
||||
assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
assert.equal(deployed.CONSOLE_TENANT, 'someone-else');
|
||||
// ③ CLI 自己算的仍然是這趟算出來的
|
||||
assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub');
|
||||
assert.equal(deployed.MULTI_TENANT, 'false');
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => {
|
||||
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/);
|
||||
assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/);
|
||||
assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/);
|
||||
const noVars = applyVars('name = "w"\n', { A: 'a' });
|
||||
assert.match(noVars, /\[vars\]\nA = "a"/);
|
||||
assert.match(noVars, /^name = "w"/);
|
||||
});
|
||||
|
||||
test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => {
|
||||
const out = applyVars('[vars]\n', { A: 'say "hi"\\path' });
|
||||
assert.match(out, /A = "say \\"hi\\"\\\\path"/);
|
||||
});
|
||||
|
||||
test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => {
|
||||
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/);
|
||||
assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/);
|
||||
// 怪名字不寫進去(不拿它組正規式)
|
||||
assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n');
|
||||
});
|
||||
|
||||
test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => {
|
||||
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||||
const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map()));
|
||||
const withVars = parseWranglerRequirements(
|
||||
renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }),
|
||||
);
|
||||
assert.equal(withVars.script, withoutVars.script);
|
||||
assert.deepEqual(withVars.bindings, withoutVars.bindings);
|
||||
});
|
||||
@@ -6,6 +6,11 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
// resource-rule.mjs 是共用規則的副本(純 JS + JSDoc,零依賴,見該檔開頭)。
|
||||
// allowJs 讓 tsc 把它一起編進 dist(否則 npm 套件裡會缺這支 → 執行期 MODULE_NOT_FOUND);
|
||||
// checkJs 讓它的 JSDoc 型別真的被檢查,而不是靜靜地當 any。
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
|
||||
@@ -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" };
|
||||
@@ -277,9 +277,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
} else {
|
||||
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||
}
|
||||
rows.push(sys.graph && sys.graph.ok
|
||||
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||
// Arcrun#100:「服務活著嗎」與「庫裡有幾條」拆兩列。混一列時,圖服務打不通會把
|
||||
// 「其實有 1854 條」整個吞掉,畫面看起來就像知識庫是空的。數字讀不到寫「讀不到」,不寫 0。
|
||||
var gOk = !!(sys.graph && sys.graph.ok);
|
||||
var tri = sys.graph && sys.graph.triplets != null ? sys.graph.triplets : null;
|
||||
rows.push(sysRow('知識圖譜服務', gOk ? '● 正常' : '● 打不通', gOk ? 'ok' : 'bad'));
|
||||
rows.push(sysRow('三元組(關聯)', tri == null ? '讀不到' : tri.toLocaleString() + ' 條', tri == null ? 'off' : ''));
|
||||
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
||||
// 三元組/已嵌入 已各有一列)
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -925,14 +934,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
fetch(API_BASE + '/console/kb-scale-data')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
||||
// #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上)
|
||||
if (!d) { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; return; }
|
||||
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
|
||||
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
|
||||
var latest = d.wiki_card_latest_ago_minutes;
|
||||
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
|
||||
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
|
||||
})
|
||||
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
|
||||
.catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; });
|
||||
}
|
||||
$('se-sem').addEventListener('click', function () {
|
||||
S.semantic = !S.semantic;
|
||||
@@ -970,7 +980,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 +1467,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 () {
|
||||
@@ -1492,7 +1505,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
]).then(function (rs) {
|
||||
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
|
||||
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
|
||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
||||
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
|
||||
var rows = '';
|
||||
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
|
||||
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
|
||||
@@ -1523,6 +1536,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,7 +1081,36 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
});
|
||||
})();
|
||||
|
||||
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
|
||||
// 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 完成安裝清單(進站必見,做完才消失)───────────────────────────────
|
||||
// 件數演進:t53 三件 → t54 兩件(設定檔改由小幫手憑帳密自取)
|
||||
// → arcrun-rag#81 一件(AI 問答改走 Workers AI,不再要用戶自備金鑰)。
|
||||
function setupSteps() {
|
||||
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
|
||||
}
|
||||
@@ -963,7 +1125,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var p = S.profile || {};
|
||||
if (p.role !== 'admin') return;
|
||||
var s = setupSteps();
|
||||
if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
|
||||
// arcrun-rag#81(leo 08-12:「已經改用 workers AI,刪掉」):只剩「下載小幫手」一件。
|
||||
// 舊的 s.key(貼 Google AI 金鑰)已整條移除,見下方 innerHTML 處的說明。
|
||||
// 相容:舊瀏覽器 localStorage 裡殘留的 s.key 不再被讀 —— 沒設過 key 的人也不會被卡住。
|
||||
if (s.daemon) return; // t54 起設定改由小幫手輸入帳密自取,不再下載檔案
|
||||
var cfg = window.ARCRUN_CONFIG || {};
|
||||
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
|
||||
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
|
||||
@@ -974,7 +1139,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var el = document.createElement('div');
|
||||
el.id = 'setup-checklist';
|
||||
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
|
||||
el.innerHTML = '<b>還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
|
||||
// 🔴 arcrun-rag#81(leo 08-12):第二項「啟用 AI 問答(貼 Google AI 金鑰)」整條刪除。
|
||||
// 為什麼不是「一個沒用的欄位」而已:它長在**安裝完成清單裡而且是勾選項**
|
||||
// ⇒ 用戶會以為不做這步就沒裝完,而它要人離開流程、去第三方網站申請帳號、
|
||||
// 把金鑰貼進表單——整條安裝路徑上最重的一個動作,而且是白做的。
|
||||
// 真相:雲端問答走 t181(08-04)改好的 Workers AI(`env.AI` binding,免金鑰)——
|
||||
// /portal/data/chat → tenant 的 rag_chat workflow → `workers_ai_chat` recipe
|
||||
// (api-recipe-seeds.ts:150,endpoint `@cf/meta/llama-4-scout-17b-16e-instruct`)。
|
||||
// 這裡貼的金鑰是打 POST /portal/admin/ai 存一筆雲端 gemini_api_key credential,
|
||||
// **現行問答鏈路一個地方都沒有讀它**。
|
||||
// 後端 route 本身不動(同 08-09 拿掉檢修孔按鈕的處置:端點無害、已無任何 UI 呼叫,
|
||||
// 純粹清路標);已經存過金鑰的人那筆 credential 也原封不動,不做刪除。
|
||||
// ⚠️ 別把這個跟設定頁「AI 設定」面板講的地端萃取金鑰搞混——那把是填在
|
||||
// **同步小幫手**托盤裡的,從來不經過這個清單。
|
||||
el.innerHTML = '<b>還差 1 步,安裝就真的完成了</b>'
|
||||
+ row(s.daemon, 'daemon',
|
||||
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
|
||||
+ (daemonUrl
|
||||
@@ -996,34 +1174,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
|
||||
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
|
||||
+ '<div class="muted" style="font-size:12.5px;margin-top:4px">裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。</div>')
|
||||
+ row(s.key, 'key',
|
||||
'<b>啟用 AI 問答</b>(<a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
|
||||
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
|
||||
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
|
||||
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
|
||||
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
|
||||
document.body.appendChild(el);
|
||||
var dl = document.getElementById('sc-daemon');
|
||||
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
|
||||
// t54:config.json 下載鈕已移除(設定由小幫手憑帳密自取)
|
||||
var kb = document.getElementById('sc-key-save');
|
||||
if (kb) kb.addEventListener('click', function () {
|
||||
var k = (document.getElementById('sc-key').value || '').trim();
|
||||
var m = document.getElementById('sc-key-msg');
|
||||
if (!k) { m.textContent = '請先貼上金鑰'; return; }
|
||||
kb.disabled = true; m.textContent = '啟用中…';
|
||||
fetch(API_BASE + '/portal/admin/ai', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify({ gemini_api_key: k })
|
||||
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
kb.disabled = false;
|
||||
if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; }
|
||||
markStep('key');
|
||||
})
|
||||
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
|
||||
});
|
||||
// arcrun-rag#81:金鑰輸入框的送出邏輯(POST /portal/admin/ai)一併移除——
|
||||
// 只藏畫面留著那條路,等於這個要求還在,只是變得更難發現。
|
||||
var later = document.getElementById('sc-later');
|
||||
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
|
||||
}
|
||||
@@ -1088,6 +1245,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 +1283,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 +1311,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 +1355,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 +1455,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>' +
|
||||
@@ -1306,7 +1483,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
$('se-q').value = name;
|
||||
doGraphSearch(name);
|
||||
}
|
||||
// 讀不到就明說「讀不到」——標題列**絕不**留著 0 或舊數字(Arcrun#100:leo 看到
|
||||
// 「0 個實體・0 條關聯」以為要去上傳文件,其實庫裡有 1854 條,只是這支讀失敗了)。
|
||||
function mapUnavailable(html) {
|
||||
$('map-meta').textContent = '讀不到';
|
||||
$('map-box').innerHTML = '<div class="err" style="padding:30px 10px">' + html + '</div>';
|
||||
}
|
||||
function loadMap() {
|
||||
$('map-meta').textContent = '';
|
||||
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
|
||||
$('map-md-link').innerHTML = SOURCE_WEB_BASE
|
||||
? ':<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
|
||||
@@ -1315,14 +1499,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '</div>'; return; }
|
||||
var nodes = x.d.nodes || [];
|
||||
var edges = x.d.edges || [];
|
||||
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
|
||||
if (!x.ok) { mapUnavailable(esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')'))); return; }
|
||||
// Arcrun#100:「0」只准在後端確認過真的是 0 的時候出現。
|
||||
// 形狀不對 → 讀不到(不是空庫);nodes 為空但 empty_confirmed 不成立 → 讀不到。
|
||||
if (!Array.isArray(x.d.nodes) || !Array.isArray(x.d.edges)) {
|
||||
mapUnavailable('總圖回應格式不對——沒有拿到關聯資料。這不代表知識庫是空的。');
|
||||
return;
|
||||
}
|
||||
var nodes = x.d.nodes, edges = x.d.edges;
|
||||
var total = typeof x.d.triplets_total === 'number' ? x.d.triplets_total : null;
|
||||
if (!nodes.length && x.d.empty_confirmed !== true) {
|
||||
mapUnavailable(x.d.empty_reason === 'scope_mismatch'
|
||||
? '讀不到你這個帳號的關聯資料——知識庫裡有三元組'
|
||||
+ (total ? '(本帳號範圍算到 ' + total.toLocaleString() + ' 條)' : '')
|
||||
+ ',但這張圖一條都抽不出來。<br>'
|
||||
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>;比較像資料的歸屬範圍對不上,請通知管理員。'
|
||||
: '讀不到知識庫的關聯資料,無法確認庫裡有沒有關聯。<br>'
|
||||
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>——是這次讀取失敗,請稍後重整或通知管理員。');
|
||||
return;
|
||||
}
|
||||
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯'
|
||||
+ (total !== null && x.d.truncated ? '(全庫共 ' + total.toLocaleString() + ' 條,已達單次上限)' : x.d.truncated ? '・已達上限截斷' : '');
|
||||
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
|
||||
renderMap(nodes, edges);
|
||||
})
|
||||
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
|
||||
.catch(function (e) { mapUnavailable('請求失敗:' + esc(friendlyErr(e))); });
|
||||
}
|
||||
function renderMap(nodes, edges) {
|
||||
var N = nodes.length;
|
||||
@@ -1521,9 +1722,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;
|
||||
@@ -1790,6 +2039,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// 兩邊都是 semver(例 1.4.2),用數字逐段比,不用字串比('1.4.10' < '1.4.9' 會出錯)。
|
||||
var INSTALLER_ORIGIN = 'https://install.arcrun.dev';
|
||||
|
||||
// Arcrun#106:版號後面可以帶 build metadata(`1.4.41+d61`、`1.4.41+a1b2c3d`)——
|
||||
// 那是 semver 規格裡「比大小時要忽略」的那一段。舊寫法拿整串去比對正規式,
|
||||
// 一律落到「較舊版本」(youlin 實例就是這樣,明明有版號卻顯示不出來)。
|
||||
// 這裡只取前面的 `x.y.z` 當比較用的核心,顯示仍顯示完整原字串。
|
||||
function semverCore(v) {
|
||||
var m = String(v || '').match(/^(\d+\.\d+\.\d+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function cmpSemver(a, b) {
|
||||
var x = String(a || '').split('.').map(Number);
|
||||
var y = String(b || '').split('.').map(Number);
|
||||
@@ -1806,9 +2064,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var btn = $('st-ver-update');
|
||||
if (!line) return;
|
||||
|
||||
// #106:順便把 bundle_commit 帶回來(有注入才有)——版號是頻道編號,commit 才是「真的部了哪份碼」。
|
||||
var mineCommit = '';
|
||||
var mineP = fetch(window.ARCRUN_API_BASE + '/health', { cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { return (j && j.bundle_version) || ''; })
|
||||
.then(function (j) {
|
||||
mineCommit = (j && j.bundle_commit) || '';
|
||||
return (j && j.bundle_version) || '';
|
||||
})
|
||||
.catch(function () { return ''; });
|
||||
var latestP = fetch(INSTALLER_ORIGIN + '/api/latest')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
@@ -1821,15 +2084,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
if (!mine) { line.textContent = '無法讀取目前版本(知識庫服務可能正在啟動)'; return; }
|
||||
// 舊實例的 bundle_version 是舊格式(2026-07-31+8e83589),比不了 semver。
|
||||
// 這種情況一律當成「落後」——因為新版才會寫 semver 進來。
|
||||
var mineIsSemver = /^\d+\.\d+\.\d+$/.test(mine);
|
||||
// #106:`1.4.41+<commit>` 這種帶 build metadata 的**是** semver,取核心比即可。
|
||||
var mineCore = semverCore(mine);
|
||||
var mineIsSemver = !!mineCore;
|
||||
// commit 是輔助資訊(有才顯示):版號說「哪一版」,commit 說「真的是哪份碼」。
|
||||
var commitNote = mineCommit ? ' <span class="muted">commit ' + esc(String(mineCommit).slice(0, 7)) + '</span>' : '';
|
||||
|
||||
if (!latest) {
|
||||
line.textContent = '目前版本 ' + mine + '(暫時查不到最新版,稍後再試)';
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong>(暫時查不到最新版,稍後再試)' + commitNote;
|
||||
return;
|
||||
}
|
||||
var behind = !mineIsSemver || cmpSemver(mine, latest) < 0;
|
||||
var behind = !mineIsSemver || cmpSemver(mineCore, latest) < 0;
|
||||
if (!behind) {
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版';
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版' + commitNote;
|
||||
dot.style.display = 'none';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
@@ -1863,33 +2130,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 當代。');
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run"
|
||||
"check:tenant": "node scripts/check-tenant-source.mjs",
|
||||
"test": "node scripts/check-tenant-source.mjs && vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "^1.2.4",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 「靜態租戶字串不得用於資料面過濾」機械閘的**執行殼**(Arcrun#108)。
|
||||
*
|
||||
* 規則本體(純函式、零 node 相依)在 `tenant-source-rules.mjs`——拆開的理由是
|
||||
* **這道閘自己要能被測試**:Workers runtime 的 vitest 沒有 node:fs,規則若和走檔案系統的
|
||||
* 程式碼綁在一起就 import 不動,測試也就寫不出來(當晚有一道閘連讀自己的原始碼都擋,
|
||||
* 結果沒人驗得了它會不會誤攔)。現在 tests/tenant-gate.test.ts 直接餵字串驗規則。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/check-tenant-source.mjs [projectRoot] # 掃 src/,有違規 → exit 1
|
||||
* node scripts/check-tenant-source.mjs --stdin <相對路徑> # 從 stdin 讀「即將寫入的內容」
|
||||
* npm run check:tenant
|
||||
*
|
||||
* `--stdin` 是給 `.claude/hooks/pre-write-guard.sh`(規則 8.1)用的:在檔案**還沒寫下去之前**
|
||||
* 就擋,這樣違規根本進不了工作區。Edit 只給片段也沒關係——規則是逐行的,正好只看新寫的那幾行。
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scanSource } from './tenant-source-rules.mjs';
|
||||
|
||||
/** 遞迴列出目錄下的 .ts 檔(相對 root 的路徑)。 */
|
||||
function listTsFiles(root, dir = root, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) listTsFiles(root, full, out);
|
||||
else if (name.endsWith('.ts')) out.push(relative(root, full));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 掃整個 cypher-executor/src。回傳違規清單。 */
|
||||
export function scanProject(projectRoot) {
|
||||
const srcRoot = join(projectRoot, 'src');
|
||||
const all = [];
|
||||
for (const rel of listTsFiles(projectRoot, srcRoot)) {
|
||||
const relPosix = rel.split(sep).join('/');
|
||||
all.push(
|
||||
...scanSource(relPosix, readFileSync(join(projectRoot, rel), 'utf8')).map((v) => ({
|
||||
...v,
|
||||
file: relPosix,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** stdin 模式:讀「即將寫入的內容」,印違規、有違規 → exit 1。 */
|
||||
async function runStdin(relPath) {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const violations = scanSource(relPath, Buffer.concat(chunks).toString('utf8'));
|
||||
if (violations.length === 0) return 0;
|
||||
for (const v of violations) {
|
||||
console.error(`[${v.rule}] ${relPath}(新寫入的第 ${v.line} 行):${v.text}`);
|
||||
console.error(` → ${v.message}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
if (process.argv[2] === '--stdin') {
|
||||
process.exit(await runStdin(process.argv[3] ?? 'src/unknown.ts'));
|
||||
}
|
||||
const projectRoot = process.argv[2] ?? process.cwd();
|
||||
const violations = scanProject(projectRoot);
|
||||
if (violations.length === 0) {
|
||||
console.log('✓ 租戶來源檢查通過:資料面 owner_id 全部來自 src/lib/tenant.ts');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('❌ 租戶來源檢查失敗(Arcrun#108 的閘)\n');
|
||||
for (const v of violations) {
|
||||
console.error(` [${v.rule}] ${v.file}:${v.line}`);
|
||||
console.error(` ${v.text}`);
|
||||
console.error(` → ${v.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 「靜態租戶字串不得用於資料面過濾」— 機械閘(Arcrun#108)。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 為什麼要有這道閘
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 同一句話已經寫錯兩次:
|
||||
* #105 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"`
|
||||
* #108 `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
|
||||
* 兩次都是「拿一個部署環境變數的字面預設值,當成使用者資料的歸屬」。規則早就在(rule 07
|
||||
* 薄殼、design §3.3 租戶不下發),但**沒有任何機制會擋**,所以它每隔幾週就長回來一次。
|
||||
* leo 2026-08-12:「做一個平台要減少 hotfix。」⇒ 修掉 bug 不算完成,要留下會擋的東西。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 判準:看「有沒有在做那件事」,不是看「有沒有出現那個詞」
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 誤攔比漏攔更容易殺死一道閘(被擋煩了就有人把它關掉),所以三條規則全部盯**行為**:
|
||||
*
|
||||
* T1 租戶環境變數只有一個產地
|
||||
* `env.CONSOLE_TENANT` / `env.ARCRUN_NAMESPACE` 只能在 src/lib/tenant.ts 被讀取。
|
||||
* 盯的是「你在把部署設定讀成身分」這個動作本身。註解裡寫這兩個字不算(只看 `env.X` 取值)。
|
||||
*
|
||||
* T2 資料面租戶識別不得憑空捏造
|
||||
* `as TenantId` 只能出現在 src/lib/tenant.ts,且不得套在字面字串上。
|
||||
* 盯的是「繞過唯一產地自己造一個租戶」。
|
||||
*
|
||||
* T3 帳號層字串不得流進知識資料面
|
||||
* 同一行同時「在組 owner_id」且「值來自 portalTenant()/accountTenant()」→ 擋。
|
||||
* 這正是 #108 那一行的形狀:`owner_id=${encodeURIComponent(portalTenant(c.env))}`。
|
||||
* `owner_id: ns`(帳號子 namespace,合法)不命中;`x.owner_id` 這種讀取也不命中。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 這道閘自己要能被測試
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 核心是純函式 `scanSource(relPath, text)`(不碰檔案系統),測試餵好例子/壞例子驗它會不會叫
|
||||
* (tests/tenant-gate.test.ts)——當晚有一道閘連讀自己的原始碼都擋,導致沒人驗得了它。
|
||||
* 本檔只掃 `src/`,測試與 fixture 都不在掃描範圍內,所以**不會擋到自己**。
|
||||
*
|
||||
* 本檔是**純規則**(零 node 相依),所以 Workers runtime 的 vitest 也 import 得動;
|
||||
* 走檔案系統的那半在 check-tenant-source.mjs。
|
||||
*/
|
||||
|
||||
/** 唯一允許產出租戶識別的檔案(相對 cypher-executor/)。 */
|
||||
export const TENANT_SOURCE_FILE = 'src/lib/tenant.ts';
|
||||
/** 只宣告型別、不取值的檔案(`CONSOLE_TENANT?: string` 這種)。 */
|
||||
const TYPE_DECL_FILES = new Set(['src/types.ts']);
|
||||
|
||||
/** 被視為「租戶來源」的環境變數——讀它們=在決定使用者資料的歸屬。 */
|
||||
const TENANT_ENV_VARS = ['CONSOLE_TENANT', 'ARCRUN_NAMESPACE'];
|
||||
|
||||
/** 帳號層租戶字串的取得方式(回的是 string 不是 TenantId,不得用於知識資料面)。 */
|
||||
const ACCOUNT_TENANT_CALLS = ['portalTenant(', 'accountTenant('];
|
||||
|
||||
const ENV_READ = new RegExp(String.raw`\benv\s*\.\s*(${TENANT_ENV_VARS.join('|')})\b`);
|
||||
const AS_TENANT_ID = /\bas\s+TenantId\b/;
|
||||
const LITERAL_AS_TENANT_ID = /(['"`][^'"`]*['"`])\s*as\s+TenantId\b/;
|
||||
|
||||
/**
|
||||
* 「這一行在組 owner_id 嗎?」——**構造**才算,**讀取**不算。
|
||||
* 算:`owner_id=` 出現在字串/樣板裡、`owner_id:` 當成物件屬性在賦值
|
||||
* 不算:`x.owner_id`(讀)、`owner_id?:`(型別宣告)、`owner_id` 單獨出現在註解句子裡
|
||||
*/
|
||||
function buildsOwnerFilter(line) {
|
||||
const code = stripComment(line);
|
||||
if (!code.includes('owner_id')) return false;
|
||||
if (/owner_id\s*=/.test(code) && !/[.\w]owner_id\s*=/.test(code)) return true; // `?owner_id=` / `owner_id=${...}`
|
||||
if (/(^|[^.\w])owner_id\s*:/.test(code) && !/owner_id\s*\?\s*:/.test(code)) return true; // `owner_id: X`
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 去掉行末 `//` 註解(不處理跨行 /* *\/——那種行本來就不含可執行的取值)。 */
|
||||
function stripComment(line) {
|
||||
const i = line.indexOf('//');
|
||||
return i === -1 ? line : line.slice(0, i);
|
||||
}
|
||||
|
||||
/** 整行是註解?(`//` 開頭或位於 JSDoc 區塊的 ` *` 行) */
|
||||
function isCommentLine(line) {
|
||||
const t = line.trim();
|
||||
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
|
||||
}
|
||||
|
||||
/**
|
||||
* 掃一份原始碼,回傳違規清單(純函式,測試直接餵字串)。
|
||||
* @param {string} relPath 相對 cypher-executor/ 的路徑,例如 'src/routes/portal-data.ts'
|
||||
* @param {string} text 檔案內容
|
||||
* @returns {{rule: string, line: number, text: string, message: string}[]}
|
||||
*/
|
||||
export function scanSource(relPath, text) {
|
||||
const rel = relPath.split('\\').join('/');
|
||||
const violations = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const n = idx + 1;
|
||||
const push = (rule, message) =>
|
||||
violations.push({ rule, line: n, text: line.trim(), message });
|
||||
|
||||
if (isCommentLine(line)) return;
|
||||
const code = stripComment(line);
|
||||
|
||||
// T1:租戶環境變數只有一個產地
|
||||
if (rel !== TENANT_SOURCE_FILE && !TYPE_DECL_FILES.has(rel) && ENV_READ.test(code)) {
|
||||
push(
|
||||
'T1',
|
||||
`租戶環境變數只能在 ${TENANT_SOURCE_FILE} 讀取。` +
|
||||
'在別處讀它=又一次「身分來自環境變數」(#105/#108 同形),' +
|
||||
'請改呼叫 knowledgeOwner(env)(知識資料面)或 accountTenant(env)(帳號層)。',
|
||||
);
|
||||
}
|
||||
|
||||
// T2:資料面租戶識別不得憑空捏造
|
||||
if (AS_TENANT_ID.test(code)) {
|
||||
if (rel !== TENANT_SOURCE_FILE) {
|
||||
push(
|
||||
'T2',
|
||||
`TenantId 只能由 ${TENANT_SOURCE_FILE} 產生。自己 cast 一個等於繞過唯一產地——` +
|
||||
'請用 knowledgeOwner(env) 或 tenantFromApiKey(header)。',
|
||||
);
|
||||
} else if (LITERAL_AS_TENANT_ID.test(code)) {
|
||||
push(
|
||||
'T2',
|
||||
'不得把**字面字串**當成租戶識別(那就是 `|| "leo"` 那個預設值的原形)。' +
|
||||
'解析不到請丟 TenantUnresolvedError,誠實說讀不到。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// T3:帳號層字串不得流進知識資料面
|
||||
if (buildsOwnerFilter(line) && ACCOUNT_TENANT_CALLS.some((fn) => code.includes(fn))) {
|
||||
push(
|
||||
'T3',
|
||||
'這一行拿**帳號層**租戶字串去組知識資料面的 owner_id 過濾——' +
|
||||
'正是 #108 那一行(1854 條三元組被過濾成 0)。' +
|
||||
'知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()。',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||||
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
|
||||
import { resolveRecipe } from '../routes/recipes';
|
||||
import type { RecipeDefinition } from '../routes/recipes';
|
||||
import { branchHintFor } from '../lib/branch-hints';
|
||||
@@ -44,8 +44,12 @@ export type NodeInfo = {
|
||||
status: NodeStatus;
|
||||
componentId?: string;
|
||||
type: NodeRole;
|
||||
/** found 時標來源庫:零件 registry(component)或 recipe 庫(recipe)。 */
|
||||
source?: 'component' | 'recipe';
|
||||
/**
|
||||
* found 時標來源庫:零件 registry(component)、recipe 庫(recipe),
|
||||
* 或 cypher-executor 自帶、無須查 registry 即保證解析得動的執行期原生零件(builtin,
|
||||
* Arcrun#88——component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS)。
|
||||
*/
|
||||
source?: 'component' | 'recipe' | 'builtin';
|
||||
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
|
||||
input_schema?: unknown;
|
||||
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
|
||||
@@ -212,6 +216,25 @@ export async function searchNodes(
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ──────────────────
|
||||
// component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS=trigger_workflow/
|
||||
// BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS 的聯集——
|
||||
// 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。
|
||||
// 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control/
|
||||
// http_request/switch…)會被下面「兩庫都查過沒有」誤判成 not_found——
|
||||
// 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。
|
||||
// target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。
|
||||
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
|
||||
nodeResults[nodeName] = {
|
||||
status: 'found',
|
||||
componentId,
|
||||
type: role,
|
||||
source: 'builtin',
|
||||
branch_hint: branchHintFor(componentId),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
|
||||
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
||||
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -77,7 +77,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
||||
filter: 'SVC_FILTER',
|
||||
merge: 'SVC_MERGE',
|
||||
try_catch: 'SVC_TRY_CATCH',
|
||||
wait: 'SVC_WAIT',
|
||||
// wait 已於 Arcrun#101(2026-08-12)移進 BUILTIN_COMPONENTS(step 1)——
|
||||
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
|
||||
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
|
||||
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
|
||||
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.1:13 個既有 binding 保留不新增),
|
||||
// 拆綁定要重新部署、與本票無關。
|
||||
set: 'SVC_SET',
|
||||
array_ops: 'SVC_ARRAY_OPS',
|
||||
string_ops: 'SVC_STRING_OPS',
|
||||
@@ -88,6 +93,33 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
||||
};
|
||||
|
||||
/**
|
||||
* 「查得到 vs 真的有」的單一真相源(Arcrun#88,2026-08-11)。
|
||||
*
|
||||
* 病因:`/cypher/search`(`search-nodes.ts`)只查 component registry(`SUBMISSIONS_KV`,
|
||||
* 經 `submitComponent`/`index-only` 才會有記錄);而本檔 0/1/5/7 四步驟能直接解析、
|
||||
* **完全不查 registry** 的一整類零件(trigger_workflow、BUILTIN_COMPONENTS、
|
||||
* LOGIC_BINDING_MAP、WASM_HTTP_RUNNER_IDS)從未被 submit 過(也不需要——它們是
|
||||
* cypher-executor 自帶的,不是投稿存量)。實測 leo21c 實例:`/components/catalog`
|
||||
* 404(registry 是舊版沒這端點/索引空),search 因此對 `if_control`/`http_request`
|
||||
* 誠實地回「兩庫都查過沒有」——但這兩顆其實跑得動(leo 08-11 探測工作流已證)。
|
||||
*
|
||||
* 修法:把「執行期真的解析得動」的這份清單匯出給 search-nodes.ts,在查 registry
|
||||
* **之前**先比對——讓「查得到」不受 registry 是否可達/是否已 backfill 影響。
|
||||
*
|
||||
* 刻意不做的事:不去掃 `registry/components/*` 目錄當清單來源——那是零件原始碼
|
||||
* 存放處,含已標記待刪的死碼(`km_writer`/`kbdb_upsert_block`,見
|
||||
* `system-dev/docs/3-specs/arcrun-usable/cleanup-dead-code.md`);07-30 曾把這類死碼
|
||||
* 誤灌進 registry(leo 點名的錯)。這裡改用**執行期真正拿去 resolve 的白名單本身**
|
||||
* (本檔 1/5/7 步驟既有的三份清單)——精確等於「解析得動」,不會多一顆、不會少一顆。
|
||||
*/
|
||||
export const RUNTIME_NATIVE_COMPONENT_IDS: ReadonlySet<string> = new Set([
|
||||
'trigger_workflow',
|
||||
...BUILTIN_COMPONENTS.keys(),
|
||||
...Object.keys(LOGIC_BINDING_MAP),
|
||||
...WASM_HTTP_RUNNER_IDS,
|
||||
]);
|
||||
|
||||
export function createComponentLoader(env: Bindings) {
|
||||
return async (componentId: string): Promise<ComponentRunner> => {
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* wait 零件的等待上限(毫秒)。與 registry/components/wait/component.contract.yaml
|
||||
* 逐字相同 —— 超過此值截斷、不報錯。**不可為了閃避資源上限調小**(Arcrun#101 紅線):
|
||||
* 「等外部系統跟上」是這顆零件存在的理由,把上限砍掉等於把能力換掉。
|
||||
*/
|
||||
export const WAIT_MAX_MS = 30000;
|
||||
|
||||
/**
|
||||
* 內建零件表(靜態函數)
|
||||
* WASM 零件 = 各自獨立 Worker,cypher-executor 走 HTTP URL 呼叫(不從 R2 讀)
|
||||
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
|
||||
// ── wait:等待 N 毫秒後繼續(Arcrun#101,2026-08-12)────────────────────────
|
||||
//
|
||||
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
|
||||
//
|
||||
// 舊實作是 registry/components/wait/main.go(TinyGo → WASM),用 time.Sleep。
|
||||
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
|
||||
// poll_oneoff 實作成 ENOSYS(`.component-builds/*/src/index.ts`:`poll_oneoff: () => 76`)
|
||||
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
|
||||
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
|
||||
//
|
||||
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
|
||||
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
|
||||
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
|
||||
// ⇒ 時鐘並沒有全程凍結。
|
||||
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
|
||||
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
|
||||
//
|
||||
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
|
||||
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
|
||||
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
|
||||
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
|
||||
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
|
||||
//
|
||||
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
|
||||
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
|
||||
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
|
||||
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
|
||||
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
|
||||
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
|
||||
//
|
||||
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
|
||||
// 吃 ms(必填 > 0)+可選 context;ms > WAIT_MAX_MS 截斷;
|
||||
// 回 { success: true, data: { ...context, waited_ms } };ms <= 0 回 success:false。
|
||||
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
|
||||
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
|
||||
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
|
||||
['wait', async (ctx) => {
|
||||
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
|
||||
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
|
||||
if (!Number.isFinite(requested) || requested <= 0) {
|
||||
return { success: false, error: 'ms 必須大於 0' };
|
||||
}
|
||||
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
|
||||
|
||||
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
|
||||
? c.context as Record<string, unknown>
|
||||
: {};
|
||||
return { success: true, data: { ...passthrough, waited_ms: ms } };
|
||||
}],
|
||||
]);
|
||||
|
||||
export const SCORE_THRESHOLD = 0.5;
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 租戶字串的**唯一產地**(Arcrun#108)。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 這個檔案存在的理由(不是為了整潔,是為了不再犯同一個錯)
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* #105:`ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` ——身分來自環境變數。
|
||||
* #108:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` ——同一句話換一個檔案。
|
||||
*
|
||||
* 兩次的形狀一模一樣:**「這筆資料是誰的」與「這個請求是誰」來自兩個可以各自漂移的地方**。
|
||||
* leo 的知識在 `owner_id=bfezv28v`(08-11 回灌時定的名,也就是他 `~/.arcrun/config.yaml`
|
||||
* 的 `api_key`、小幫手上傳時帶的 `X-Arcrun-API-Key`),而 cypher 拿 repo 預設值 `"leo"`
|
||||
* 去過濾 ⇒ 1854 條三元組被過濾成 0,畫面卻只寫「沒有庫」。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 定案:租戶字串從哪裡來
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* **從「寫入這批知識的那一方」來,而不是從一份手抄的環境變數預設值來。**
|
||||
*
|
||||
* 寫入端只有一個真相源:使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace)。
|
||||
* CLI 用它 push workflow(`{ns}:wf:*`)、小幫手用它上傳知識(`owner_id=ns`)、
|
||||
* MCP 用它當 Bearer。**讀取端必須用同一個值**,否則讀寫兩端各說各話。
|
||||
* 所以 `acr init/update` 把它注入成 `ARCRUN_NAMESPACE`(cli/src/lib/deploy.ts,
|
||||
* 與 CF_ACCOUNT_ID / WORKER_SUBDOMAIN / MULTI_TENANT 同一批 CLI 管理值)——
|
||||
* 它不是「使用者要自己維護的設定」,是**從既有真相源導出的值**,因此不會漂。
|
||||
*
|
||||
* 那為什麼不像 #105 一樣「掛在登入者身上」?因為在這個架構裡租戶**不是**每人一個:
|
||||
* portal 帳號共用同一台實例的知識庫(design D-2,帳號自己住 `{tenant}::portal` 子
|
||||
* namespace),帳號之間的差別是 `libraries` 權限,不是 owner_id。把 owner_id 複製一份
|
||||
* 到每個帳號上,只會多一個可以各自過期的副本——那正是本票的病,不是解藥。
|
||||
* #105 真正的教訓不是「一律搬到帳號上」,而是:
|
||||
* **過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、而且要能被機械驗證。**
|
||||
* 這三件事就是本檔在做的事。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 型別即閘(`TenantId`)
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* `TenantId` 是 branded string,**只能**由本檔產生(`knowledgeOwner` / `tenantFromApiKey`)。
|
||||
* 所有資料面 owner_id 過濾一律經 `ownerQuery()` / `ownerField()`,而那兩支只吃 `TenantId`
|
||||
* ⇒ 想把「隨手一個 env 字串」拿去過濾,`tsc` 當場就不給過。
|
||||
*
|
||||
* 配套的機械檢查在 `scripts/check-tenant-source.mjs`(測試 `tests/tenant-gate.test.ts`
|
||||
* 會同時驗「repo 現況乾淨」與「這道閘真的擋得住壞例子」)。
|
||||
*/
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
/**
|
||||
* 可以拿去做資料面過濾的租戶識別。
|
||||
*
|
||||
* branded type:外面拿不到建構子,只能從本檔的兩支 minter 取得——
|
||||
* 一支從實例 namespace 來(`knowledgeOwner`),一支從請求本身來(`tenantFromApiKey`)。
|
||||
* 兩支都不含字面預設值。
|
||||
*/
|
||||
export type TenantId = string & { readonly __tenantId: unique symbol };
|
||||
|
||||
/** 實例 namespace 解析不出來 → 誠實炸掉,不拿預設值當答案(#100「讀不到就說讀不到」同源)。 */
|
||||
export class TenantUnresolvedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TenantUnresolvedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 這台實例的**知識 owner_id**——三元組 / entries / records / 藏書地圖 / 工作流 KV
|
||||
* 全部掛在這個字串底下,由 CLI、小幫手、MCP 寫入時決定。
|
||||
*
|
||||
* 解析順序(**沒有字面預設值**):
|
||||
* 1. `ARCRUN_NAMESPACE`——`acr init/update` 從 `~/.arcrun/config.yaml` 的 `api_key` 注入。
|
||||
* 這是寫入端用的那個值本身,因此永遠對得上。
|
||||
* 2. `CONSOLE_TENANT`——官方 prod(`cypher.arcrun.dev`)與 #108 之前部署的實例走這條。
|
||||
* 官方 prod 的知識確實寫在 `leo` 底下,所以對它而言這是正解;對跑過 `acr update`
|
||||
* 的 self-hosted 實例,第 1 條會先命中。
|
||||
* 3. 兩個都沒有 → **丟 TenantUnresolvedError**。不回 `"leo"`:那個預設值正是把
|
||||
* 「這台機器沒設定」偽裝成「你沒有資料」的元凶。
|
||||
*/
|
||||
export function knowledgeOwner(env: Bindings): TenantId {
|
||||
const injected = (env.ARCRUN_NAMESPACE ?? '').trim();
|
||||
if (injected) return injected as TenantId;
|
||||
const legacy = (env.CONSOLE_TENANT ?? '').trim();
|
||||
if (legacy) return legacy as TenantId;
|
||||
throw new TenantUnresolvedError(
|
||||
'這個部署沒有知識命名空間(ARCRUN_NAMESPACE / CONSOLE_TENANT 都沒設)——' +
|
||||
'不知道要去哪一格找資料。請跑 `acr update` 讓它從你的 ~/.arcrun/config.yaml 注入。',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 請求自帶的租戶(`X-Arcrun-API-Key`=namespace 明碼,self-hosted 身分模型)。
|
||||
*
|
||||
* 這條路的租戶來自**請求本身**而不是環境變數,本來就沒有 #105/#108 的漂移問題;
|
||||
* 收進本檔只是為了讓「所有 owner_id 過濾值都是 TenantId」這條型別閘沒有破口。
|
||||
* 空字串不給過——沒有身分就不該有查詢範圍。
|
||||
*/
|
||||
export function tenantFromApiKey(apiKey: string): TenantId {
|
||||
const key = (apiKey ?? '').trim();
|
||||
if (!key) throw new TenantUnresolvedError('缺少 X-Arcrun-API-Key,無法決定查詢範圍');
|
||||
return key as TenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 帳號子 namespace 用的租戶字串(design D-2:帳號資料住 `{tenant}::portal`)。
|
||||
*
|
||||
* 🔴 **回傳的是 `string`,不是 `TenantId`——這是刻意的**:帳號那批資料是 cypher 自己
|
||||
* 寫進去的(用的就是這個值),所以它自洽;但它**不可以**拿去過濾知識資料面,
|
||||
* 否則就是把 #108 再犯一次。型別上不給過,不必靠人記得。
|
||||
*
|
||||
* 保留 `'leo'` 預設值是為了不動既有帳號的落點(改了會讓舊實例登不進去)。
|
||||
*/
|
||||
export function accountTenant(env: Bindings): string {
|
||||
return env.CONSOLE_TENANT || 'leo';
|
||||
}
|
||||
|
||||
/**
|
||||
* KBDB query string 的 owner_id 過濾片段——**資料面過濾的唯一入口之一**。
|
||||
* 用法:`kbdbFetch(env, `/map?${ownerQuery(tenant)}`)`
|
||||
*/
|
||||
export function ownerQuery(tenant: TenantId): string {
|
||||
return `owner_id=${encodeURIComponent(tenant)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 要放進 JSON body / URLSearchParams 的 owner_id 值——**資料面過濾的唯一入口之一**。
|
||||
* 用法:`JSON.stringify({ owner_id: ownerField(tenant) })`
|
||||
*/
|
||||
export function ownerField(tenant: TenantId): string {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **刻意不帶租戶範圍**的查詢片段(KBDB 慣例:`owner_id` 空值=不過濾)。
|
||||
*
|
||||
* 唯一合法用途:#100 的普查——「本租戶查到 0 筆」時再問一次「整台實例到底有沒有」,
|
||||
* 用來分辨「查不到」與「沒有」。**回傳的是統計數字,不是任何人的內容**;
|
||||
* 拿它去撈實際資料就是跨租戶外洩。名字取得這麼長就是要讓 review 一眼看見。
|
||||
*/
|
||||
export function censusQueryAllTenants(): string {
|
||||
return 'owner_id=';
|
||||
}
|
||||
|
||||
/** 逐筆核對歸屬(讀回來的 record/entry 是不是這個租戶的)。缺欄位一律視為不是。 */
|
||||
export function isOwnedBy(value: unknown, tenant: TenantId): boolean {
|
||||
return typeof value === 'string' && value === (tenant as string);
|
||||
}
|
||||
@@ -22,6 +22,21 @@
|
||||
*/
|
||||
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';
|
||||
// Arcrun#108:租戶字串唯一產地。
|
||||
import { knowledgeOwner } from '../lib/tenant';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -66,20 +81,85 @@ async function hashPassword(password: string, salt: string): Promise<string> {
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* console 登入後下發給前端當 api_key 用的租戶字串(舊 console 的設計,與 portal 不同:
|
||||
* portal 絕不下發,console 會)。
|
||||
*
|
||||
* Arcrun#108:這是**知識資料面**的 owner_id(前端拿它直打 `/kbdb/*`),所以必須與寫入端
|
||||
* (CLI/小幫手/MCP 用的實例 namespace)同源。以前直接讀 `env.CONSOLE_TENANT || 'leo'`
|
||||
* ⇒ 與 portal 同一個病:資料在 `bfezv28v`、過濾拿 `leo`,console 首頁的藏書地圖同樣是空的。
|
||||
* 現在走唯一產地 `lib/tenant.ts`。
|
||||
*/
|
||||
function tenantOf(c: { env: Bindings }): string {
|
||||
return c.env.CONSOLE_TENANT || 'leo';
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
|
||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 讀出 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 +170,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 +187,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 +203,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbBase, graphBase } from './kbdb-proxy';
|
||||
import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import {
|
||||
type KbdbEntry,
|
||||
@@ -72,6 +72,9 @@ import {
|
||||
taipeiDayKey,
|
||||
} from '../lib/console-dashboard-model';
|
||||
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
|
||||
// Arcrun#108:租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT
|
||||
// 過濾而看不到自己的資料——與 portal 同一個病,同一個修法。
|
||||
import { knowledgeOwner } from '../lib/tenant';
|
||||
|
||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -104,6 +107,31 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本租戶三元組的**真實總數**(null = 讀不到,畫面要顯示「讀不到」而非 0)。
|
||||
*
|
||||
* 🔴 Arcrun#100:不可以拿 graph-plugin `/triplets/stats` 的 `total` 當數量。
|
||||
* 那支的 `total` 是**分頁長度**不是 COUNT——它走 `/records/by-template/triplet`(KBDB 端
|
||||
* `searchByTemplate` 預設 limit=100、硬上限 500)且不帶 owner 過濾,所以 1854 條的庫
|
||||
* 只會回 100。修好 401 之後若還讀它,畫面會從「0」變成「100」——一樣是假的。
|
||||
* 真相源=KBDB `/records/triplet-stats`(真 SQL COUNT(*)、依 owner_id 過濾、無上限),
|
||||
* 回 `{ success, stats: [{ library, triplet_count }] }`,加總即全庫條數。
|
||||
*/
|
||||
async function fetchTripletTotal(env: Bindings, tenant: string): Promise<number | null> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>(
|
||||
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`,
|
||||
headers,
|
||||
);
|
||||
if (!data || !Array.isArray(data.stats)) return null;
|
||||
let total = 0;
|
||||
for (const row of data.stats) {
|
||||
if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到,不半信半疑加總
|
||||
total += row.triplet_count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||||
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
@@ -240,7 +268,7 @@ export async function cachedGiteaSprint(
|
||||
|
||||
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
||||
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const now = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
@@ -254,6 +282,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
tripletTotal,
|
||||
entriesTotal,
|
||||
wikiCardTotal,
|
||||
workflowTotal,
|
||||
@@ -265,7 +294,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
|
||||
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
|
||||
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
|
||||
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(
|
||||
`${graphUrl}/triplets/stats`,
|
||||
graphHeaders(c.env),
|
||||
),
|
||||
fetchTripletTotal(c.env, tenant),
|
||||
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||
fetchEntryTotal(c.env, { owner_id: tenant }),
|
||||
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
|
||||
@@ -400,13 +435,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
embed: embedStatus
|
||||
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
||||
: null,
|
||||
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
|
||||
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
|
||||
graph: { ok: graphStats !== null, triplets: tripletTotal },
|
||||
workflow_total: workflowTotal,
|
||||
},
|
||||
kb: {
|
||||
entries_total: entriesTotal,
|
||||
wiki_card_total: wikiCardTotal,
|
||||
triplets_total: graphStats?.total ?? null,
|
||||
triplets_total: tripletTotal,
|
||||
},
|
||||
generated_at: new Date(now).toISOString(),
|
||||
});
|
||||
@@ -418,17 +454,17 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
|
||||
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
|
||||
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
const now = Date.now();
|
||||
const [wikiCards, graphStats, embedStatus] = await Promise.all([
|
||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
||||
// limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
|
||||
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
|
||||
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
|
||||
headers,
|
||||
),
|
||||
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
|
||||
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
|
||||
fetchTripletTotal(c.env, tenant),
|
||||
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
|
||||
]);
|
||||
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||
@@ -436,7 +472,7 @@ consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
return c.json({
|
||||
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
|
||||
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
|
||||
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
|
||||
triplets_total: tripletTotal,
|
||||
embedded: embedStatus?.embedded ?? null,
|
||||
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
||||
generated_at: new Date(now).toISOString(),
|
||||
@@ -466,7 +502,7 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const [todoEntries, inboxEntries] = await Promise.all([
|
||||
fetchEntries(c.env, tenant, 'todo', 500),
|
||||
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||
@@ -497,7 +533,7 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => {
|
||||
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
|
||||
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
|
||||
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
|
||||
|
||||
@@ -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,31 @@ 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 是正確的)。
|
||||
// Arcrun#106(leo 08-12 實撞:更新完設定頁變成「無法讀取目前版本」):
|
||||
// `bundle_version` 只在部署時被注入,而**只有安裝器會注入**——CLI 更新那條路重部署
|
||||
// 等於把這個標籤洗掉(wrangler deploy 整份覆蓋,toml 沒寫的 var 直接消失)。
|
||||
// 修在 CLI 那側(cli/src/lib/deploy.ts:既有 var 沿用 + 版本標籤每趟重烙)。
|
||||
// 這裡只多吐一個 `bundle_commit`:版號是「發行頻道的編號」,commit 才是「真的部了哪份碼」——
|
||||
// 兩個一起看才有辦法查「標籤有沒有跟成品漂掉」。沒注入就省略該欄(同 bundle_version 的既有行為)。
|
||||
healthRouter.get('/health', (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
return c.json(
|
||||
bundleVersion ? { ok: true, bundle_version: bundleVersion } : { ok: true },
|
||||
);
|
||||
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||
return c.json({
|
||||
ok: true,
|
||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||
...(bundleCommit ? { bundle_commit: bundleCommit } : {}),
|
||||
auth_store: authStoreStatus(c.env),
|
||||
// arcrun-rag#38/#69/#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) =>
|
||||
|
||||
@@ -111,6 +111,45 @@ kbdbProxyRouter.get('/kbdb/records/by-template/:template', async (c) => {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// POST /kbdb/records/backfill-library — 三元組版標庫補存量對外通道(Arcrun#87 二次收尾,2026-08-13)。
|
||||
// 基本盤(kbdb/src/routes/records.ts)的 POST /records/backfill-library 缺對外通道——同
|
||||
// PATCH /kbdb/records/:recordId 那條的破口(能力在 base,插件/工作流打不到)。純轉發,
|
||||
// owner_id 強制用租戶身份(同本檔 POST /kbdb/records 的既有慣例,不信任 caller 自帶 owner_id)。
|
||||
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
|
||||
kbdbProxyRouter.post('/kbdb/records/backfill-library', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.library) return c.json({ error: 'library 必填' }, 400);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records/backfill-library`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
library: body.library,
|
||||
owner_id: owner,
|
||||
triplet_template: body.triplet_template,
|
||||
source_prefix: body.source_prefix,
|
||||
limit: body.limit,
|
||||
}),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/records/backfill-library/status — 待補標統計(三元組版),owner_id 同樣強制用租戶身份。
|
||||
kbdbProxyRouter.get('/kbdb/records/backfill-library/status', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const params = new URLSearchParams({ owner_id: owner });
|
||||
for (const k of ['triplet_template', 'source_prefix']) {
|
||||
const v = c.req.query(k);
|
||||
if (v) params.set(k, v);
|
||||
}
|
||||
const res = await fetch(`${base}/records/backfill-library/status?${params.toString()}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/records/:recordId — 取單筆 record。
|
||||
kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
@@ -119,6 +158,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。
|
||||
@@ -202,13 +262,27 @@ export function graphBase(env: Bindings): string {
|
||||
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb-graph-plugin 的 internal headers。**打 plugin 一律用這支,不要各自手拼**(Arcrun#100)。
|
||||
*
|
||||
* plugin 端(kbdb-graph-plugin/src/index.ts)對 `/triplets` `/graph` `/search` `/entities`
|
||||
* 四個前綴掛了 Bearer 閘:設了 KBDB_INTERNAL_TOKEN 就必須帶,否則一律 401。
|
||||
* 原本三處手拼(本檔 neighbors、portal-data neighbors、console-dashboard 兩支 stats),
|
||||
* 前兩處帶了、後兩處漏了 → `/triplets/stats` 永遠 401 → 前端「三元組 0」。
|
||||
* 收斂成一支函式=新的呼叫點不可能再漏(漂移的根,不是那兩行本身)。
|
||||
*/
|
||||
export function graphHeaders(env: Bindings): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
|
||||
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
|
||||
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const base = graphBase(c.env);
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const headers = graphHeaders(c.env);
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
@@ -23,8 +23,11 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
|
||||
import { graphBase } from './kbdb-proxy';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||
// Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import
|
||||
// portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。
|
||||
import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant';
|
||||
import { graphBase, graphHeaders } from './kbdb-proxy';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
|
||||
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
||||
@@ -40,7 +43,9 @@ export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/** 讀 tenant 的 named workflow graph(`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
|
||||
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
|
||||
const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text');
|
||||
// #108:workflow 是 CLI `acr push` 用實例 namespace 寫進來的(`{ns}:wf:*`),
|
||||
// 所以讀的時候也要用同一個 namespace,不是帳號層那個字串。
|
||||
const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text');
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
|
||||
@@ -186,10 +191,51 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
|
||||
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||||
}
|
||||
|
||||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||||
/**
|
||||
* 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端
|
||||
* `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。
|
||||
*/
|
||||
async function tripletCount(env: Bindings, owner: TenantId | null): Promise<number | null> {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||
// owner=null = 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個
|
||||
// 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
|
||||
if (!body || !Array.isArray(body.stats)) return null;
|
||||
let total = 0;
|
||||
for (const row of body.stats) {
|
||||
if (typeof row?.triplet_count !== 'number') return null;
|
||||
total += row.triplet_count;
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 三元組普查(Arcrun#100)——回答總圖那句「知識庫還沒有任何關聯」到底能不能講。
|
||||
*
|
||||
* leo 的原則(已寫在 portal.ts §②.5 daemon diagnostics):**「不要讓『查不到』和『沒有』
|
||||
* 長得一樣」**。t161 前科:手補的 record owner_id 存成 None ⇒ 全量查得到、按 owner_id 過濾
|
||||
* 的畫面永遠空——比真的沒資料更難查。所以本租戶數為 0 時**再花一次查詢換一條路徑**
|
||||
* (同一支端點但不帶 owner),問「這個庫到底有沒有三元組」:
|
||||
* owned>0 → 有資料
|
||||
* owned=0 且 any=0 → 真的空(此時、也只有此時,畫面才准印 0)
|
||||
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
|
||||
* owned=null → 讀不到 → 畫面說讀不到
|
||||
*/
|
||||
async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> {
|
||||
const owned = await tripletCount(env, tenant);
|
||||
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
|
||||
return { owned, any: await tripletCount(env, null) };
|
||||
}
|
||||
|
||||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
@@ -224,7 +270,7 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
||||
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
|
||||
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
|
||||
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
||||
@@ -295,7 +341,7 @@ portalDataRouter.get('/portal/data/entries/:id', (c) =>
|
||||
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
|
||||
const entry = body.entry;
|
||||
if (!entry) return notFound(c);
|
||||
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
|
||||
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
|
||||
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||
return c.json({ success: true, entry });
|
||||
}),
|
||||
@@ -320,7 +366,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
const nodeName = normalizeCjkQuery(c.req.param('name'));
|
||||
|
||||
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
||||
if (wfGraph) {
|
||||
const depthRaw = c.req.query('depth') ?? '';
|
||||
@@ -343,8 +389,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
|
||||
// ② plugin fallback(Mira/leo21c 相容)
|
||||
const base = graphBase(c.env);
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const headers = graphHeaders(c.env);
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||||
if (!res.ok) {
|
||||
@@ -382,15 +427,24 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
||||
if (!(await hasGraphAccess(c.env, libraries))) {
|
||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||
}
|
||||
const tenant = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`),
|
||||
tripletCensus(c.env, tenant),
|
||||
]);
|
||||
const tripletsTotal = census.owned;
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { records?: { values?: Record<string, unknown> }[] }
|
||||
| null;
|
||||
const records = body && Array.isArray(body.records) ? body.records : [];
|
||||
// #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖,
|
||||
// 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
|
||||
}
|
||||
const records = body.records;
|
||||
const EDGE_CAP = 500;
|
||||
const seen = new Set<string>();
|
||||
const edges: { subject: string; predicate: string; object: string }[] = [];
|
||||
@@ -413,7 +467,28 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
||||
degree.set(o, (degree.get(o) ?? 0) + 1);
|
||||
}
|
||||
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
|
||||
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
|
||||
// #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)——
|
||||
// confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」
|
||||
// scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科)
|
||||
// unreadable :連條數都讀不到 → 只能說讀不到
|
||||
let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
|
||||
if (nodes.length === 0) {
|
||||
if (census.owned === null) emptyReason = 'unreadable';
|
||||
else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
|
||||
else if (census.any === null) emptyReason = 'unreadable';
|
||||
else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
|
||||
}
|
||||
return c.json({
|
||||
nodes,
|
||||
edges,
|
||||
node_count: nodes.length,
|
||||
edge_count: edges.length,
|
||||
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
|
||||
truncated: truncated || records.length >= 500,
|
||||
triplets_total: tripletsTotal,
|
||||
empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
|
||||
empty_reason: emptyReason,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -437,7 +512,7 @@ portalDataRouter.get('/portal/data/chat', (c) =>
|
||||
wfGraph,
|
||||
{ question },
|
||||
'rag_chat',
|
||||
portalTenant(c.env),
|
||||
knowledgeOwner(c.env),
|
||||
c.executionCtx,
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -543,7 +618,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
// 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
|
||||
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
|
||||
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const prefix = `${tenant}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const workflows = await Promise.all(
|
||||
@@ -569,7 +644,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`,
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null) as {
|
||||
success?: boolean;
|
||||
@@ -584,3 +659,297 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 授權的 AI(arcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
|
||||
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
|
||||
//
|
||||
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
|
||||
// 於是查詢時只好去找一把**服務內部金鑰**(KBDB_INTERNAL_TOKEN)直打 KBDB——
|
||||
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
|
||||
//
|
||||
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
|
||||
// ① 一律 requirePortalUser(session → 回讀 user record → 停用即時生效),
|
||||
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
|
||||
// 呼叫端自己帶租戶字串=繞過庫過濾),
|
||||
// ③ 越權與不存在同回 404(不洩存在性)。
|
||||
//
|
||||
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
|
||||
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
|
||||
|
||||
/**
|
||||
* record 的庫歸屬。與 entry 不同:**沒有 `library` slot 的 record 不套庫過濾**。
|
||||
*
|
||||
* 為什麼不比照 entry 用 'general' fallback:entry 是知識內容(庫是它的第一屬性,沒標就歸
|
||||
* general 是對的);record 是結構化資料列(contact / workflow_metadata / triplet…),
|
||||
* 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback,
|
||||
* 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺,不是隔離。
|
||||
* 租戶邊界仍然守著(owner_id 由 server 注入/逐筆比對),這裡只多守「有標庫的別越庫」。
|
||||
*/
|
||||
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
|
||||
const lib = values?.library;
|
||||
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
|
||||
}
|
||||
|
||||
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
|
||||
function canReadRecord(
|
||||
rec: { values?: Record<string, unknown>; owner_id?: string | null },
|
||||
tenant: TenantId,
|
||||
libraries: string[],
|
||||
): boolean {
|
||||
if (!isOwnedBy(rec.owner_id, tenant)) return false;
|
||||
const lib = recordLibrary(rec.values);
|
||||
return lib === null || canReadLibrary(libraries, lib);
|
||||
}
|
||||
|
||||
// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。
|
||||
// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
|
||||
//
|
||||
// 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜**
|
||||
// (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制):
|
||||
// no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題
|
||||
// filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離
|
||||
// confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」
|
||||
// scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上**
|
||||
// (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾)
|
||||
// scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。
|
||||
//
|
||||
// ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。
|
||||
// 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。
|
||||
portalDataRouter.get('/portal/data/map', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'no_library_grant',
|
||||
note: '此帳號尚未被授權任何知識庫,請聯絡管理員。',
|
||||
});
|
||||
}
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
|
||||
if (!body || !Array.isArray(body.libraries)) {
|
||||
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
|
||||
}
|
||||
const allowed = body.libraries.filter(
|
||||
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
|
||||
);
|
||||
if (allowed.length > 0) {
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
|
||||
}
|
||||
// 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。
|
||||
if (body.libraries.length > 0) {
|
||||
// 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'filtered_out',
|
||||
note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。',
|
||||
});
|
||||
}
|
||||
const census = await tripletCensus(c.env, tenant);
|
||||
if (census.owned === null || (census.owned === 0 && census.any === null)) {
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: false, empty_reason: 'unreadable',
|
||||
note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。',
|
||||
});
|
||||
}
|
||||
if (census.owned === 0 && (census.any ?? 0) > 0) {
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: false, empty_reason: 'scope_mismatch',
|
||||
instance_triplet_count: census.any,
|
||||
note:
|
||||
`讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` +
|
||||
'這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' +
|
||||
'請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。',
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'confirmed_empty',
|
||||
note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
|
||||
portalDataRouter.get('/portal/data/map/:library', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
const library = c.req.param('library');
|
||||
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`,
|
||||
);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/templates — template 清單。
|
||||
// template=虛擬表定義(schema),**全域共享不分租戶**(kbdb-proxy 同一裁定,leo 2026-06-14):
|
||||
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
|
||||
portalDataRouter.get('/portal/data/templates', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const res = await kbdbFetch(c.env, '/templates');
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/data/templates — 建 template(name + slots)。
|
||||
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
|
||||
// created_by 記租戶(溯源),template 本身全域可見可用。
|
||||
portalDataRouter.post('/portal/data/templates', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { name?: unknown; slots?: unknown; description?: unknown }
|
||||
| null;
|
||||
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
|
||||
return c.json({ error: 'name 與 slots[] 必填' }, 400);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, '/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: body.name,
|
||||
slots: body.slots,
|
||||
description: typeof body.description === 'string' ? body.description : undefined,
|
||||
created_by: knowledgeOwner(c.env),
|
||||
}),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
|
||||
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
|
||||
portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`,
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
|
||||
| null;
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
|
||||
}
|
||||
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
|
||||
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
|
||||
return c.json({ success: true, records, count: records.length });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/records/:recordId — 單筆 record。
|
||||
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
|
||||
portalDataRouter.get('/portal/data/records/:recordId', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return notFound(c);
|
||||
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
|
||||
| null;
|
||||
const record = body?.record;
|
||||
if (!record) return notFound(c);
|
||||
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
|
||||
return c.json({ success: true, record });
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/data/records — 依 template 填一筆 record。
|
||||
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
|
||||
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
|
||||
portalDataRouter.post('/portal/data/records', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { template?: unknown; values?: unknown }
|
||||
| null;
|
||||
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
|
||||
return c.json({ error: 'template 與 values 必填' }, 400);
|
||||
}
|
||||
const values = body.values as Record<string, unknown>;
|
||||
const targetLib = recordLibrary(values);
|
||||
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
|
||||
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
|
||||
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
|
||||
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, '/records', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||||
//
|
||||
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||||
//
|
||||
// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 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 = knowledgeOwner(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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,13 @@ export type Bindings = {
|
||||
* 未注入(本地 dev/舊實例)= undefined,/health 省略該欄。
|
||||
*/
|
||||
ARCRUN_BUNDLE_VERSION?: string;
|
||||
/**
|
||||
* Arcrun#106:這份成品實際來自哪個 commit(40 碼 sha)。
|
||||
* `ARCRUN_BUNDLE_VERSION` 是**發行頻道的編號**(semver,Portal/daemon 拿它比新舊),
|
||||
* 這個是**真的部了哪份碼**——兩個一起吐,標籤跟成品漂掉時查得出來。
|
||||
* 由 `acr init/update`(cli/src/lib/deploy.ts)注入;安裝器那條路沒有此 var → /health 省略該欄。
|
||||
*/
|
||||
ARCRUN_BUNDLE_COMMIT?: string;
|
||||
// Platform telemetry api_key(可選,wrangler secret)
|
||||
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
||||
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
||||
@@ -84,6 +91,20 @@ export type Bindings = {
|
||||
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
||||
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
||||
CONSOLE_TENANT?: string;
|
||||
/**
|
||||
* 這台實例的**知識命名空間**(Arcrun#108)=使用者 `~/.arcrun/config.yaml` 的 `api_key`。
|
||||
*
|
||||
* 由 `acr init/update`(cli/src/lib/deploy.ts CLI_MANAGED_VARS)自動注入,**使用者不必手動維護**:
|
||||
* 它就是 CLI push workflow(`{ns}:wf:*`)、小幫手上傳知識(`owner_id=ns`)、MCP Bearer
|
||||
* 用的同一個值 ⇒ 讀取端用它過濾,永遠對得上寫入端。
|
||||
*
|
||||
* 為什麼不沿用 `CONSOLE_TENANT`:那是 repo toml 帶的**官方 prod 值**(`leo`),
|
||||
* self-hosted 實例的資料根本不在它底下(#108 實撞:1854 條被過濾成 0),
|
||||
* 而且 `CONSOLE_TENANT` 同時還是帳號子 namespace(`{tenant}::portal`)的組成,
|
||||
* 改它會讓舊實例登不進去。兩件事拆成兩個 var,各自對應各自的真相源。
|
||||
* 解析邏輯只在 `src/lib/tenant.ts`(唯一產地,機械閘看守)。
|
||||
*/
|
||||
ARCRUN_NAMESPACE?: string;
|
||||
// Console 顯示品牌/實例名(Arcrun#21 rebrand,非機密)。只影響 UI 字樣(title/header/logo),
|
||||
// 不影響任何行為。未設 → "Arcrun"(console 是引擎共用件,不寫死產品名)。
|
||||
// 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。
|
||||
@@ -103,9 +124,8 @@ export type Bindings = {
|
||||
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
|
||||
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
|
||||
ARCRUN_BUNDLE_VERSION?: string;
|
||||
// (ARCRUN_BUNDLE_VERSION 原本在這裡重複宣告了一次——TS2300 重複識別字,
|
||||
// #106 順手併回上面那一處,說明同源,行為零變化。)
|
||||
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
||||
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
||||
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||
@@ -115,6 +135,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,147 @@
|
||||
/**
|
||||
* Arcrun#100 — 「畫面上的 0,只准在真的是 0 的時候出現」
|
||||
*
|
||||
* 病灶(leo 實遇):總圖頁寫「0 個實體・0 條關聯/知識庫還沒有任何關聯——上傳文件後 AI 會
|
||||
* 自動織網」,而他庫裡有 1854 條三元組。那句話會叫他去做一件不需要做的事。
|
||||
*
|
||||
* 本檔釘住三件事:
|
||||
* ① kbdb-graph-plugin 的 `/triplets` 前綴掛 Bearer 閘,cypher 打它**一定要帶 token**
|
||||
* (console-dashboard 兩支 stats 原本漏帶 → 永遠 401)。
|
||||
* ② 三元組數量的真相源=KBDB `/records/triplet-stats`(真 SQL COUNT、依 owner 過濾),
|
||||
* **不是** plugin `/triplets/stats` 的 `total`——那是分頁長度(KBDB 端上限 100/500),
|
||||
* 1854 條的庫只會回 100。只修 401 不換來源=把「0」換成「100」,一樣是假的。
|
||||
* ③ 讀不到一律 null / 502 / empty_confirmed=false,**絕不退化成 0**。
|
||||
*
|
||||
* KBDB/graph-plugin 都打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test、
|
||||
* KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { graphHeaders, graphBase } from '../src/routes/kbdb-proxy';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const GRAPH = 'https://graph.test';
|
||||
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
/** KBDB `/records/triplet-stats` — 真 COUNT 的形狀:{ success, stats: [{library, triplet_count}] } */
|
||||
function mockTripletStats(rows: { library: string; triplet_count: number }[] | null, status = 200) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(status, rows === null ? { success: false, error: 'boom' } : { success: true, stats: rows });
|
||||
}
|
||||
|
||||
// ═══════════════ 1. graphHeaders:打 plugin 的 header 只有一份 ═══════════════
|
||||
|
||||
describe('graphHeaders(#100 漂移的根:三處手拼 → 一支函式)', () => {
|
||||
it('有 KBDB_INTERNAL_TOKEN → 帶 Bearer(plugin 的 /triplets /graph /search /entities 全靠它)', () => {
|
||||
expect(graphHeaders({ KBDB_INTERNAL_TOKEN: 'tok-abc' } as unknown as Bindings)).toEqual({
|
||||
Authorization: 'Bearer tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('沒設 token → 空 headers(plugin 未設 secret 時本來就開放,不硬塞空 Bearer)', () => {
|
||||
expect(graphHeaders({} as unknown as Bindings)).toEqual({});
|
||||
});
|
||||
|
||||
it('graphBase 仍照舊(KBDB_GRAPH_URL 優先、去尾斜線)', () => {
|
||||
expect(graphBase({ KBDB_GRAPH_URL: 'https://graph.test/' } as unknown as Bindings)).toBe('https://graph.test');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. /console/kb-scale-data:數字對得上庫裡真正的數量 ═══════════════
|
||||
|
||||
describe('GET /console/kb-scale-data — 三元組數=KBDB 真 COUNT', () => {
|
||||
it('庫裡 1854 條(跨三個庫)→ triplets_total 回 1854,不是 plugin 的分頁長度 100', async () => {
|
||||
mockTripletStats([
|
||||
{ library: 'general', triplet_count: 1200 },
|
||||
{ library: 'finance', triplet_count: 600 },
|
||||
{ library: 'ops', triplet_count: 54 },
|
||||
]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:triplet-stats 讀不到(500)→ triplets_total = null,**不是 0**', async () => {
|
||||
mockTripletStats(null, 500);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBeNull();
|
||||
expect(d.triplets_total).not.toBe(0); // 這一行就是 #100 的整個重點
|
||||
});
|
||||
|
||||
it('反向:回應形狀不對(stats 不是陣列)→ null,不半信半疑當 0', async () => {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: 'oops' });
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBeNull();
|
||||
});
|
||||
|
||||
it('真的是 0(庫存在但沒有任何三元組)→ 誠實回 0(0 只在這種時候出現)', async () => {
|
||||
mockTripletStats([]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(0);
|
||||
});
|
||||
|
||||
it('kb-scale-data 不再打 graph-plugin(沒有 plugin interceptor 也能拿到數字)', async () => {
|
||||
mockTripletStats([{ library: 'general', triplet_count: 7 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(7); // 打 GRAPH 的話 disableNetConnect 會讓它變 null
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3. /console/dashboard-data:燈號問 plugin、數字問 KBDB ═══════════════
|
||||
|
||||
describe('GET /console/dashboard-data — 圖服務健康 vs 三元組數量是兩件事', () => {
|
||||
it('打 plugin /triplets/stats **有帶 Bearer** → graph.ok=true;數量仍取 KBDB 真 COUNT', async () => {
|
||||
// headers matcher:漏帶 Authorization 就配不到這個 interceptor → 請求失敗 → graph.ok=false
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({
|
||||
path: (p: string) => p.startsWith('/triplets/stats'),
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` },
|
||||
})
|
||||
.reply(200, { total: 100 }); // plugin 的分頁長度,故意與真值不同
|
||||
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as {
|
||||
system: { graph: { ok: boolean; triplets: number | null } };
|
||||
kb: { triplets_total: number | null };
|
||||
};
|
||||
expect(d.system.graph.ok).toBe(true); // 帶了 token 才會是 true(#100 迴歸閘)
|
||||
expect(d.system.graph.triplets).toBe(1854); // 不是 plugin 的 100
|
||||
expect(d.kb.triplets_total).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:plugin 打不通 → graph.ok=false,但三元組數照樣是真的(不被服務狀態吞掉)', async () => {
|
||||
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
|
||||
expect(d.system.graph.ok).toBe(false);
|
||||
expect(d.system.graph.triplets).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:兩邊都讀不到 → ok=false + triplets=null(不是 0)', async () => {
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
|
||||
expect(d.system.graph.ok).toBe(false);
|
||||
expect(d.system.graph.triplets).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -26,4 +26,33 @@ describe('GET /health — bundle_version 欄位', () => {
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.bundle_version).toBe('2026-07-28/6d06162');
|
||||
});
|
||||
|
||||
// Arcrun#106:CLI 更新那條路會多烙一個 commit(版號=發行頻道編號,commit=真的部了哪份碼)。
|
||||
it('有 ARCRUN_BUNDLE_COMMIT 時一起回(acr update 注入情境)', async () => {
|
||||
const fakeEnv = {
|
||||
ARCRUN_BUNDLE_VERSION: '1.4.41',
|
||||
ARCRUN_BUNDLE_COMMIT: 'f87d0e92f49690253e7c89c5badc82a08eb5d21b',
|
||||
} as unknown as Bindings;
|
||||
const res = await healthRouter.fetch(
|
||||
new Request('http://localhost/health'),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
const data = await res.json() as { bundle_version: string; bundle_commit: string };
|
||||
expect(data.bundle_version).toBe('1.4.41');
|
||||
expect(data.bundle_commit).toBe('f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
});
|
||||
|
||||
// 安裝器那條路沒有這個 var(回歸:不能因為多了新欄位就讓舊路徑多吐一個空字串出來)。
|
||||
it('沒 ARCRUN_BUNDLE_COMMIT 就省略該欄(安裝器路徑不受影響)', async () => {
|
||||
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '1.4.41' } as unknown as Bindings;
|
||||
const res = await healthRouter.fetch(
|
||||
new Request('http://localhost/health'),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
const data = await res.json() as { bundle_version: string; bundle_commit?: string };
|
||||
expect(data.bundle_version).toBe('1.4.41');
|
||||
expect(data.bundle_commit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* POST /kbdb/records/backfill-library + GET .../status proxy 測試(Arcrun#87 二次收尾,2026-08-13)
|
||||
*
|
||||
* 背景:基本盤 kbdb/src/routes/records.ts 新增了三元組版的批次標庫補存量端點
|
||||
* (backfillTripletLibraryTags——藏書地圖讀的是三元組 record 自己的 'library' slot,跟
|
||||
* entries 版 backfill-library 補的 metadata_json.library 是不同存放處)。這條 cypher proxy
|
||||
* 之前完全沒轉發這兩支——同 kbdb-records-patch-proxy.test.ts 那次的破口(能力在 base,
|
||||
* 插件/工作流打不到)。純轉發,owner_id 強制用租戶身份(不信任 caller 自帶 owner_id,
|
||||
* 同本檔既有 POST /kbdb/records 的慣例)。
|
||||
*
|
||||
* 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts 慣例):
|
||||
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
|
||||
* 2. body 沒有 library → 400,不轉發
|
||||
* 3. 轉發:owner_id 一律用租戶身份覆蓋(即使 caller 自帶了別的 owner_id 也被忽略)
|
||||
* 4. GET status:owner_id 同樣強制用租戶身份,query 參數透傳
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host+disableNetConnect——測試絕不外連。
|
||||
*/
|
||||
import { SELF, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
describe('POST /kbdb/records/backfill-library — 租戶閘', () => {
|
||||
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ library: 'arcrun' }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /kbdb/records/backfill-library — 參數驗證', () => {
|
||||
it('body 沒有 library → 400,不轉發', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ source_prefix: 'gitea:Leo/Arcrun@' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /kbdb/records/backfill-library — 轉發', () => {
|
||||
it('owner_id 一律用租戶身份覆蓋,即使 caller 自帶了別的 owner_id', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records/backfill-library',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
library: 'arcrun',
|
||||
owner_id: 'leo', // 來自 X-Arcrun-API-Key,不是 body 裡的 'someone-else'
|
||||
triplet_template: undefined,
|
||||
source_prefix: 'gitea:Leo/Arcrun@',
|
||||
limit: 200,
|
||||
}),
|
||||
})
|
||||
.reply(200, { success: true, library: 'arcrun', scanned: 208, tagged: 200, remaining: 8, quota_limit: 2000, quota_used_today: 200, quota_exceeded: true });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ library: 'arcrun', owner_id: 'someone-else', source_prefix: 'gitea:Leo/Arcrun@', limit: 200 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; tagged: number; remaining: number };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.tagged).toBe(200);
|
||||
expect(data.remaining).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /kbdb/records/backfill-library/status — 租戶閘 + 轉發', () => {
|
||||
it('無 X-Arcrun-API-Key → 401', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('owner_id 強制用租戶身份,其餘 query 參數透傳', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/records/backfill-library/status?owner_id=leo&source_prefix=gitea%3ALeo%2FArcrun%40', method: 'GET' })
|
||||
.reply(200, { success: true, pending: 8 });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@', {
|
||||
headers: KEY,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; pending: number };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pending).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Arcrun#108 — 藏書地圖看得到自己的知識(租戶字串來源收斂)。
|
||||
*
|
||||
* 釘住的事實:
|
||||
* 1. 資料面 owner_id 來自 `knowledgeOwner(env)`:`ARCRUN_NAMESPACE` 優先、`CONSOLE_TENANT` 回退、
|
||||
* 兩者皆無 → 丟 TenantUnresolvedError(**沒有 `|| 'leo'` 這種靜默預設值**)。
|
||||
* 2. `/portal/data/map` 真的拿那個值去打 KBDB(leo 的情境:ARCRUN_NAMESPACE=bfezv28v
|
||||
* → 打 `owner_id=bfezv28v` 拿回 9 個庫,而不是打 `owner_id=leo` 拿回 0 個)。
|
||||
* 3. **權限沒有被拿掉**:同一份 KBDB 回應,庫權限 ["kb"] 的帳號只看得到 kb。
|
||||
* 4. 空地圖分得出四種成因(#100 那條「讀不到就說讀不到」延伸到藏書地圖):
|
||||
* no_library_grant / filtered_out / scope_mismatch / confirmed_empty。
|
||||
* 5. 回應**不含租戶字串**(design §3.3 紅線:前端拿到就能繞過庫過濾直打 /kbdb/*)。
|
||||
*/
|
||||
import { env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant';
|
||||
import { portalDataRouter } from '../src/routes/portal-data';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
|
||||
const LEO_NS = 'bfezv28v';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
/**
|
||||
* 直接餵 router 一份 env(不是 SELF.fetch)——`cloudflare:test` 的 `env` 物件改了不會傳進
|
||||
* SELF 那個 worker(實測:改 ARCRUN_BUNDLE_VERSION 後 /health 仍回舊值),
|
||||
* 而本票要驗的正是「換一個命名空間,查詢就跟著換」。Hono router 吃 env 參數,
|
||||
* 走的是同一支 handler、同一條 KBDB fetch,只有 env 這一項是測試給的。
|
||||
*/
|
||||
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext;
|
||||
|
||||
async function seedSession(token: string, recordId: string) {
|
||||
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||||
}
|
||||
|
||||
function mockGetRecord(recordId: string, libraries: string) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: `/records/${recordId}`, method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
record: {
|
||||
record_id: recordId,
|
||||
template_id: 'tpl_pu',
|
||||
values: {
|
||||
email: 'leo@example.com',
|
||||
display_name: 'leo',
|
||||
status: 'active',
|
||||
role: 'admin',
|
||||
password_hash: 'pbkdf2-sha256$600000$AA$BB',
|
||||
libraries,
|
||||
created_at: '2026-08-12T00:00:00.000Z',
|
||||
updated_at: '2026-08-12T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 攔 `/map`,同時把「實際被查詢的 owner_id」記下來給斷言用。 */
|
||||
function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) => {
|
||||
if (!p.startsWith('/map')) return false;
|
||||
seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? '');
|
||||
return true;
|
||||
},
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, libraries, count: libraries.length });
|
||||
}
|
||||
|
||||
function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) =>
|
||||
p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''),
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] });
|
||||
}
|
||||
|
||||
async function getMap(token: string, overrides: Partial<Bindings> = {}) {
|
||||
const res = await portalDataRouter.fetch(
|
||||
new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }),
|
||||
{ ...env, ...overrides } as Bindings,
|
||||
ctx,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** undici 的 path matcher 可能被呼叫多次 → 比對前先去重(我們在意的是「查了哪些 owner_id」)。 */
|
||||
const distinct = (xs: string[]): string[] => [...new Set(xs)];
|
||||
|
||||
// ── ① 唯一產地的解析順序 ───────────────────────────────────────────────────────
|
||||
|
||||
describe('knowledgeOwner:租戶字串只有一個產地,且沒有靜默預設值', () => {
|
||||
it('ARCRUN_NAMESPACE 優先(=acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值)', () => {
|
||||
expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS);
|
||||
});
|
||||
|
||||
it('沒注入 → 回退 CONSOLE_TENANT(官方 prod 與尚未 acr update 的實例,行為一字不變)', () => {
|
||||
expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
|
||||
});
|
||||
|
||||
it('空字串不算數(部署把 var 設成空字串 ≠ 有設定)', () => {
|
||||
expect(knowledgeOwner({ ARCRUN_NAMESPACE: ' ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
|
||||
});
|
||||
|
||||
it('兩個都沒有 → 丟 TenantUnresolvedError,**不回 "leo"**(靜默預設值正是本票的病)', () => {
|
||||
expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError);
|
||||
});
|
||||
|
||||
it('帳號層 accountTenant 不受影響(改它會讓舊實例登不進去,所以刻意不動)', () => {
|
||||
expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
|
||||
expect(accountTenant({} as Bindings)).toBe('leo');
|
||||
});
|
||||
|
||||
it('過濾片段只有兩種形狀:帶租戶的 ownerQuery,與明著喊全庫的普查', () => {
|
||||
expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b');
|
||||
expect(censusQueryAllTenants()).toBe('owner_id=');
|
||||
});
|
||||
});
|
||||
|
||||
// ── ② 地圖真的用那個 owner_id 去查 ─────────────────────────────────────────────
|
||||
|
||||
describe('GET /portal/data/map — leo 的情境(1854 條 → 看得到,不是 0 個庫)', () => {
|
||||
it('注入 ARCRUN_NAMESPACE 後,KBDB 收到的 owner_id 是它,而且庫都回得來', async () => {
|
||||
await seedSession('t-map-1', 'rec_leo');
|
||||
mockGetRecord('rec_leo', '["*"]');
|
||||
const seen: string[] = [];
|
||||
mockMap(
|
||||
[
|
||||
{ library: 'kb', triplet_count: 1851 },
|
||||
{ library: 'general', triplet_count: 3 },
|
||||
],
|
||||
seen,
|
||||
);
|
||||
|
||||
const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS });
|
||||
expect(status).toBe(200);
|
||||
expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票:以前送出去的是 'leo'
|
||||
expect(body.count).toBe(2);
|
||||
expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count))
|
||||
.toEqual([1851, 3]);
|
||||
expect(body.empty_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('回應不含租戶字串(前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線)', async () => {
|
||||
await seedSession('t-map-2', 'rec_leo2');
|
||||
mockGetRecord('rec_leo2', '["*"]');
|
||||
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
|
||||
|
||||
const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS });
|
||||
expect(JSON.stringify(body)).not.toContain(LEO_NS);
|
||||
expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE');
|
||||
});
|
||||
|
||||
it('沒注入時沿用 CONSOLE_TENANT(未跑 acr update 的實例行為不變,這次改動對它是惰性的)', async () => {
|
||||
await seedSession('t-map-3', 'rec_leo3');
|
||||
mockGetRecord('rec_leo3', '["*"]');
|
||||
const seen: string[] = [];
|
||||
mockMap([{ library: 'kb', triplet_count: 1 }], seen);
|
||||
|
||||
await getMap('t-map-3');
|
||||
expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT
|
||||
});
|
||||
});
|
||||
|
||||
// ── ③ 權限沒有被拿掉(紅線:修這題不准把 owner_id 過濾或庫過濾拆掉)──────────────
|
||||
|
||||
describe('權限:只被授權部分庫的帳號,只看得到那幾個庫', () => {
|
||||
it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => {
|
||||
await seedSession('t-perm-1', 'rec_partial');
|
||||
mockGetRecord('rec_partial', '["kb"]');
|
||||
mockMap(
|
||||
[
|
||||
{ library: 'kb', triplet_count: 1851 },
|
||||
{ library: 'finance', triplet_count: 42 },
|
||||
{ library: 'general', triplet_count: 3 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS });
|
||||
expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']);
|
||||
expect(body.count).toBe(1);
|
||||
});
|
||||
|
||||
it('一個庫都沒被授權 → 不打 KBDB,誠實說是權限問題', async () => {
|
||||
await seedSession('t-perm-2', 'rec_nolib');
|
||||
mockGetRecord('rec_nolib', '[]');
|
||||
const { body } = await getMap('t-perm-2'); // 沒有 mockMap:打了就會 assertNoPendingInterceptors 失敗
|
||||
expect(body.count).toBe(0);
|
||||
expect(body.empty_reason).toBe('no_library_grant');
|
||||
expect(body.empty_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('實例有庫但都不在權限內 → filtered_out(是隔離正常,不是資料不見)', async () => {
|
||||
await seedSession('t-perm-3', 'rec_other');
|
||||
mockGetRecord('rec_other', '["finance"]');
|
||||
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
|
||||
|
||||
const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS });
|
||||
expect(body.empty_reason).toBe('filtered_out');
|
||||
expect(body.empty_confirmed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ④ 空地圖的四種成因分得出來(不再把設定錯誤畫成「你沒有資料」)────────────────
|
||||
|
||||
describe('空地圖:分得出「讀不到」與「沒有」', () => {
|
||||
it('命名空間對不上(本租戶 0、整台實例有)→ scope_mismatch,並指出該跑 acr update', async () => {
|
||||
await seedSession('t-empty-1', 'rec_e1');
|
||||
mockGetRecord('rec_e1', '["*"]');
|
||||
mockMap([], []);
|
||||
mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0
|
||||
mockTripletStats((o) => o === '', 1854); // 全庫普查:有 1854 條
|
||||
|
||||
const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' });
|
||||
expect(body.empty_reason).toBe('scope_mismatch');
|
||||
expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」
|
||||
expect(body.instance_triplet_count).toBe(1854);
|
||||
expect(String(body.note)).toContain('acr update');
|
||||
expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串
|
||||
});
|
||||
|
||||
it('整台實例真的空 → confirmed_empty(此時、也只有此時,才准說「還沒有內容」)', async () => {
|
||||
await seedSession('t-empty-2', 'rec_e2');
|
||||
mockGetRecord('rec_e2', '["*"]');
|
||||
mockMap([], []);
|
||||
mockTripletStats((o) => o === 'leo', 0);
|
||||
mockTripletStats((o) => o === '', 0);
|
||||
|
||||
const { body } = await getMap('t-empty-2');
|
||||
expect(body.empty_reason).toBe('confirmed_empty');
|
||||
expect(body.empty_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('連統計都讀不到 → unreadable(不假裝是空庫)', async () => {
|
||||
await seedSession('t-empty-3', 'rec_e3');
|
||||
mockGetRecord('rec_e3', '["*"]');
|
||||
mockMap([], []);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(500, { error: 'boom' });
|
||||
|
||||
const { body } = await getMap('t-empty-3');
|
||||
expect(body.empty_reason).toBe('unreadable');
|
||||
expect(body.empty_confirmed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
|
||||
//
|
||||
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
|
||||
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
|
||||
|
||||
describe('藏書地圖 /portal/data/map(MCP 走的那條)', () => {
|
||||
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
|
||||
await seedSession('tok-m1', 'rec_1');
|
||||
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
|
||||
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
|
||||
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
|
||||
expect(data.count).toBe(1);
|
||||
});
|
||||
|
||||
it('["*"] 全庫 → 全部庫都回', async () => {
|
||||
await seedSession('tok-m2', 'rec_2');
|
||||
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
|
||||
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
|
||||
const data = (await res.json()) as { libraries: { library: string }[] };
|
||||
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
|
||||
});
|
||||
|
||||
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
|
||||
await seedSession('tok-m3', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { count: number; note?: string };
|
||||
expect(data.count).toBe(0);
|
||||
expect(data.note).toContain('尚未被授權');
|
||||
});
|
||||
|
||||
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
|
||||
await seedSession('tok-m4', 'rec_4');
|
||||
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||
});
|
||||
|
||||
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
|
||||
await seedSession('tok-m5', 'rec_5');
|
||||
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
|
||||
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
|
||||
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('未登入 → 401', async () => {
|
||||
expect((await get('/portal/data/map')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('結構化資料 /portal/data/records、/portal/data/templates(MCP 走的那條)', () => {
|
||||
it('by-template:server 注入 owner_id;caller 自帶的被靜默覆蓋(繞不過)', async () => {
|
||||
await seedSession('tok-r1', 'rec_1');
|
||||
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
|
||||
let captured = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) => {
|
||||
if (!p.startsWith('/records/by-template/contact')) return false;
|
||||
captured = p;
|
||||
return true;
|
||||
},
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, records: [], count: 0 });
|
||||
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
|
||||
Authorization: 'Bearer tok-r1',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
|
||||
});
|
||||
|
||||
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
|
||||
await seedSession('tok-r2', 'rec_2');
|
||||
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
records: [
|
||||
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
|
||||
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
|
||||
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
|
||||
],
|
||||
count: 3,
|
||||
});
|
||||
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
|
||||
const data = (await res.json()) as { records: { record_id: string }[] };
|
||||
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
|
||||
});
|
||||
|
||||
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
|
||||
await seedSession('tok-r3', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_other', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
|
||||
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||
});
|
||||
|
||||
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
|
||||
await seedSession('tok-r4', 'rec_4');
|
||||
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_hr', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
|
||||
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
|
||||
|
||||
await seedSession('tok-r5', 'rec_5');
|
||||
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_fin', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
|
||||
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
|
||||
await seedSession('tok-r6', 'rec_6');
|
||||
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
|
||||
let body: Record<string, unknown> = {};
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
body: (b: string) => {
|
||||
body = JSON.parse(b) as Record<string, unknown>;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.reply(200, { success: true, record: { record_id: 'r_new' } });
|
||||
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.owner_id).toBe(TENANT);
|
||||
});
|
||||
|
||||
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
|
||||
await seedSession('tok-r7', 'rec_7');
|
||||
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
|
||||
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('templates 全域共享(schema 非內容):登入即可列', async () => {
|
||||
await seedSession('tok-t1', 'rec_t1');
|
||||
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/templates', method: 'GET' })
|
||||
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
|
||||
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { count: number }).count).toBe(1);
|
||||
});
|
||||
|
||||
it('未登入 → 401(records / templates 都是)', async () => {
|
||||
expect((await get('/portal/data/templates')).status).toBe(401);
|
||||
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
|
||||
expect((await get('/portal/data/records/r1')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
|
||||
|
||||
describe('GET /portal/data/graph/neighbors/:name(D-4 粗閘)', () => {
|
||||
@@ -711,3 +919,322 @@ 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('截圖');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══ Arcrun#100: 總圖的「0」只准在真的是 0 的時候出現 ═══
|
||||
|
||||
describe('GET /portal/data/graph/overview(#100 空圖三態)', () => {
|
||||
/** KBDB `/records/triplet-stats`:帶 owner 與不帶 owner 是兩條不同路徑,分別攔。 */
|
||||
function mockCount(scoped: number | null, global?: number | null) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith(`/records/triplet-stats?owner_id=${TENANT}`), method: 'GET' })
|
||||
.reply(scoped === null ? 500 : 200, scoped === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: scoped }] });
|
||||
if (global !== undefined) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p === '/records/triplet-stats?owner_id=', method: 'GET' })
|
||||
.reply(global === null ? 500 : 200, global === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: global }] });
|
||||
}
|
||||
}
|
||||
function mockTriplets(body: object, status = 200) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(status, body);
|
||||
}
|
||||
async function overview(token: string) {
|
||||
await seedSession(token, `rec_${token}`);
|
||||
mockGetRecord(`rec_${token}`, userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
return get('/portal/data/graph/overview', { Authorization: `Bearer ${token}` });
|
||||
}
|
||||
|
||||
it('有資料 → 照常回圖,並附上全庫真實條數', async () => {
|
||||
mockTriplets({ success: true, records: [{ values: { subject: 'A', predicate: '連到', object: 'B' } }] });
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov1');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { node_count: number; triplets_total: number; empty_confirmed: boolean };
|
||||
expect(d.node_count).toBe(2);
|
||||
expect(d.triplets_total).toBe(1854);
|
||||
expect(d.empty_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('真的空(本租戶 0、全庫也 0)→ empty_confirmed=true,畫面才准印 0', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(0, 0);
|
||||
const res = await overview('tok-ov2');
|
||||
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.node_count).toBe(0);
|
||||
expect(d.empty_confirmed).toBe(true);
|
||||
expect(d.empty_reason).toBe('confirmed_empty');
|
||||
});
|
||||
|
||||
it('🔴 反向:本租戶查到 0、全庫卻有 1854(t161 owner_id 對不上)→ 不准說空,回 scope_mismatch', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(0, 1854);
|
||||
const res = await overview('tok-ov3');
|
||||
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
expect(d.empty_reason).toBe('scope_mismatch');
|
||||
});
|
||||
|
||||
it('🔴 反向:條數讀不到 → unreadable(不是 confirmed_empty,畫面顯示「讀不到」)', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(null);
|
||||
const res = await overview('tok-ov4');
|
||||
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string; triplets_total: number | null };
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
expect(d.empty_reason).toBe('unreadable');
|
||||
expect(d.triplets_total).toBeNull();
|
||||
});
|
||||
|
||||
it('🔴 反向:有條數卻一條邊都抽不出來 → scope_mismatch,不是空庫', async () => {
|
||||
mockTriplets({ success: true, records: [{ values: { subject: '', object: '' } }] });
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov5');
|
||||
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.node_count).toBe(0);
|
||||
expect(d.empty_reason).toBe('scope_mismatch');
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it('🔴 反向:KBDB 回應形狀不對(沒有 records 陣列)→ 502,不再回一張空圖', async () => {
|
||||
mockTriplets({ success: true, items: [] }); // 欄位名不對=讀不出來
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov6');
|
||||
expect(res.status).toBe(502);
|
||||
const d = (await res.json()) as { error: string };
|
||||
expect(d.error).toContain('三元組讀取失敗');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Arcrun#88:「零件目錄會說『這顆零件不存在』,但同一台實例上那顆零件跑得動」
|
||||
*
|
||||
* 病史(leo21c 實例實測,2026-08-11):
|
||||
* `/cypher/search` 對 `if_control`/`http_request` 回 `not_found`,
|
||||
* 但兩者其實由 component-loader.ts 直接解析(LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS),
|
||||
* 從不查 registry;registry catalog 端點在該實例回 404(`GET /components/catalog` →
|
||||
* `{"success":false,"error":"零件 catalog 不存在"}`),search 因此誤判成「兩庫都查過沒有」。
|
||||
*
|
||||
* 本測試複現病史的環境條件(wrangler.test.toml 未設 WORKER_SUBDOMAIN → registryBase
|
||||
* undefined → catalog.status='unreachable',等價於「registry 完全連不到」),
|
||||
* 驗證修法:RUNTIME_NATIVE_COMPONENT_IDS 的成員必須在 registry 查詢**之前**就短路成 found,
|
||||
* 不受 registry 健康狀態影響——因為它們的存在性從不依賴 registry。
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { parseTriplets } from '../src/actions/triplet-parser';
|
||||
import { searchNodes } from '../src/actions/search-nodes';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const IF_CONTROL_TRIPLETS = [
|
||||
'input >> ON_SUCCESS >> if_control',
|
||||
];
|
||||
|
||||
const HTTP_REQUEST_TRIPLETS = [
|
||||
'input >> ON_SUCCESS >> http_request',
|
||||
];
|
||||
|
||||
const MULTI_BUILTIN_TRIPLETS = [
|
||||
'input >> ON_SUCCESS >> switch',
|
||||
'input >> ON_SUCCESS >> filter',
|
||||
'input >> ON_SUCCESS >> code',
|
||||
];
|
||||
|
||||
const FAKE_COMPONENT_TRIPLETS = [
|
||||
'input >> ON_SUCCESS >> totally_made_up_component_xyz',
|
||||
];
|
||||
|
||||
describe('Arcrun#88:執行期原生零件不受 registry 健康狀態影響', () => {
|
||||
it('if_control(LOGIC_BINDING_MAP 成員)在 registry 不可達時仍回 found', async () => {
|
||||
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||
expect(parsed).not.toBeNull();
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
|
||||
// 無 WORKER_SUBDOMAIN/REGISTRY_BASE_URL → registryBase undefined → catalog unreachable
|
||||
});
|
||||
expect(nodeResults.if_control.status).toBe('found');
|
||||
expect(nodeResults.if_control.source).toBe('builtin');
|
||||
// if_control 會分岔,branch_hint 應隨 found 一併附上(不必逐顆再查一次)
|
||||
expect(nodeResults.if_control.branch_hint?.edge_types).toEqual(['ON_TRUE', 'ON_FALSE']);
|
||||
expect(missingNodes).not.toContain('if_control');
|
||||
});
|
||||
|
||||
it('http_request(WASM_HTTP_RUNNER_IDS 成員)在 registry 不可達時仍回 found', async () => {
|
||||
const parsed = parseTriplets(HTTP_REQUEST_TRIPLETS);
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {});
|
||||
expect(nodeResults.http_request.status).toBe('found');
|
||||
expect(nodeResults.http_request.source).toBe('builtin');
|
||||
expect(missingNodes).not.toContain('http_request');
|
||||
});
|
||||
|
||||
it('switch/filter/code(同一批白名單的其他成員)也回 found,不逐一漏網', async () => {
|
||||
const parsed = parseTriplets(MULTI_BUILTIN_TRIPLETS);
|
||||
const { nodeResults } = await searchNodes(parsed!, undefined, {});
|
||||
expect(nodeResults.switch.status).toBe('found');
|
||||
expect(nodeResults.filter.status).toBe('found');
|
||||
expect(nodeResults.code.status).toBe('found');
|
||||
});
|
||||
|
||||
it('registry 完全連不到時,真正不存在的名字誠實回 unknown(不敢空口說沒有——既有行為,修法沒有動它)', async () => {
|
||||
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
|
||||
const { nodeResults } = await searchNodes(parsed!, undefined, {});
|
||||
expect(nodeResults.totally_made_up_component_xyz.status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('registry 查得到但目錄是空的(複現 leo21c 實例 catalog 404 的真實症狀):真正不存在的名字回 not_found', async () => {
|
||||
// 複現生產實測:GET /components/catalog → HTTP 200 空陣列(本測試模擬「registry 活著但沒東西」,
|
||||
// 與 leo21c 實例的「404 零件 catalog 不存在」殊途同歸——都會落到「查得到、目錄無此零件」)。
|
||||
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
));
|
||||
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
|
||||
WORKER_SUBDOMAIN: 'test-sub',
|
||||
});
|
||||
expect(nodeResults.totally_made_up_component_xyz.status).toBe('not_found');
|
||||
expect(missingNodes).toContain('totally_made_up_component_xyz');
|
||||
});
|
||||
|
||||
it('registry 目錄是空的(catalog 通但無資料)時,執行期原生零件依然 found——這才是 Arcrun#88 的核心場景', async () => {
|
||||
// 這就是 leo21c 實例的真實狀態:registry 活著、目錄卻沒有任何一顆執行期原生零件的記錄
|
||||
// (SUBMISSIONS_KV 從未收到 if_control/http_request 的 submit)。若沒有本次修法,
|
||||
// 這裡會落到「兩庫都查過沒有」→ not_found,正是 Arcrun#88 回報的病徵。
|
||||
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
));
|
||||
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||
const { nodeResults } = await searchNodes(parsed!, undefined, { WORKER_SUBDOMAIN: 'test-sub' });
|
||||
expect(nodeResults.if_control.status).toBe('found');
|
||||
expect(nodeResults.if_control.source).toBe('builtin');
|
||||
});
|
||||
|
||||
it('target=recipe 明確只查 recipe 庫時,執行期原生零件不搶答 found(尊重使用者明確限庫)', async () => {
|
||||
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||
const { nodeResults } = await searchNodes(parsed!, undefined, {}, 'discover', 'recipe');
|
||||
// if_control 從來不是 recipe,target=recipe 下不該被 builtin 短路成 found
|
||||
expect(nodeResults.if_control.status).not.toBe('found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 「靜態租戶字串不得用於資料面過濾」這道閘**自己**的測試(Arcrun#108)。
|
||||
*
|
||||
* 收工標準明列三條,這裡逐條釘:
|
||||
* ① 會擋,不是只提醒 → 壞例子必須產出違規(CLI 端據此 exit 1、hook 據此 exit 2)
|
||||
* ② 判準看「有沒有在做那件事」 → 一整組「長得像但沒在做」的合法寫法必須零誤攔
|
||||
* ③ 閘自己要能被測試 → 規則是純函式,這裡直接餵字串;不需要跑檔案系統、也擋不到自己
|
||||
*
|
||||
* 外加一條回歸:現行 src/ 必須是乾淨的(`?raw` 讀真原始碼,不是讀我編的假字串)。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// @ts-expect-error -- 純規則模組(.mjs,零 node 相依),型別非本檔關注重點
|
||||
import { scanSource, TENANT_SOURCE_FILE } from '../scripts/tenant-source-rules.mjs';
|
||||
// @ts-expect-error -- vite ?raw:build-time 讀檔,runtime 是純字串(Workers 沒有 node:fs)
|
||||
import tenantLibSource from '../src/lib/tenant.ts?raw';
|
||||
// @ts-expect-error -- 同上
|
||||
import portalDataSource from '../src/routes/portal-data.ts?raw';
|
||||
// @ts-expect-error -- 同上
|
||||
import portalSource from '../src/routes/portal.ts?raw';
|
||||
// @ts-expect-error -- 同上
|
||||
import consoleAuthSource from '../src/routes/console-auth.ts?raw';
|
||||
// @ts-expect-error -- 同上
|
||||
import consoleDashboardSource from '../src/routes/console-dashboard.ts?raw';
|
||||
|
||||
type Violation = { rule: string; line: number; text: string; message: string };
|
||||
const scan = (path: string, text: string): Violation[] => scanSource(path, text) as Violation[];
|
||||
const rulesOf = (v: Violation[]): string[] => [...new Set(v.map((x) => x.rule))].sort();
|
||||
|
||||
const FILE = 'src/routes/portal-data.ts';
|
||||
|
||||
describe('閘會擋:三種「靜態租戶字串進資料面」的真實形狀', () => {
|
||||
it('T1 — 在 tenant.ts 以外讀租戶環境變數(#105/#108 的原句)', () => {
|
||||
const bad = `export function portalTenant(env: Bindings): string {\n return env.CONSOLE_TENANT || 'leo';\n}`;
|
||||
const v = scan('src/routes/portal.ts', bad);
|
||||
expect(rulesOf(v)).toContain('T1');
|
||||
expect(v[0].message).toContain('knowledgeOwner');
|
||||
});
|
||||
|
||||
it('T1 — `c.env.ARCRUN_NAMESPACE` 也一樣(換一個變數名不是換一個做法)', () => {
|
||||
const v = scan('src/routes/console-dashboard.ts', `const t = c.env.ARCRUN_NAMESPACE || 'leo';`);
|
||||
expect(rulesOf(v)).toEqual(['T1']);
|
||||
});
|
||||
|
||||
it('T2 — 繞過唯一產地自己 cast 一個 TenantId', () => {
|
||||
const v = scan(FILE, `const tenant = (c.env.SOMETHING ?? '') as TenantId;`);
|
||||
expect(rulesOf(v)).toContain('T2');
|
||||
});
|
||||
|
||||
it('T2 — 連在 tenant.ts 裡都不准把「字面字串」當成租戶識別(那就是 `|| "leo"` 的原形)', () => {
|
||||
const v = scan(TENANT_SOURCE_FILE, ` return 'leo' as TenantId;`);
|
||||
expect(rulesOf(v)).toEqual(['T2']);
|
||||
expect(v[0].message).toContain('TenantUnresolvedError');
|
||||
});
|
||||
|
||||
it('T3 — 拿帳號層字串去組知識資料面的 owner_id(#108 那一行,逐字)', () => {
|
||||
const bad = " const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);";
|
||||
const v = scan(FILE, bad);
|
||||
expect(rulesOf(v)).toContain('T3');
|
||||
expect(v[0].message).toContain('1854');
|
||||
});
|
||||
|
||||
it('T3 — 換成物件屬性寫法一樣擋(`owner_id: portalTenant(c.env)`)', () => {
|
||||
const v = scan(FILE, ` body: JSON.stringify({ values, owner_id: portalTenant(c.env) }),`);
|
||||
expect(rulesOf(v)).toContain('T3');
|
||||
});
|
||||
|
||||
it('T3 — accountTenant() 這個新名字也擋(規則盯的是「這是帳號層的值」,不是某個函式名字的拼法)', () => {
|
||||
const v = scan(FILE, " kbdbFetch(env, `/entries?owner_id=${accountTenant(env)}`);");
|
||||
expect(rulesOf(v)).toContain('T3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('閘不誤攔:長得像、但沒有在做那件事的合法寫法', () => {
|
||||
const legit: [string, string, string][] = [
|
||||
['讀取別人回傳的 owner_id(不是在組過濾)', FILE, ` if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);`],
|
||||
['型別宣告裡的 owner_id 欄位', FILE, ` | { record?: { values?: Record<string, unknown>; owner_id?: string | null } }`],
|
||||
['走唯一入口組過濾', FILE, " const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);"],
|
||||
['走唯一入口填 body', FILE, ` body: JSON.stringify({ template, values, owner_id: ownerField(tenant) }),`],
|
||||
['帳號子 namespace 的過濾(`{tenant}::portal`,那是 cypher 自己寫的資料)', 'src/routes/portal.ts', ` const res = await kbdbFetch(env, \`/records/by-template/x?owner_id=\${encodeURIComponent(ns)}\`);`],
|
||||
['請求自帶的租戶(webhooks-named 慣例:呼叫端就是租戶)', 'src/routes/webhooks-named.ts', ` owner_id: apiKey,`],
|
||||
['註解裡整句在講 CONSOLE_TENANT 與 owner_id(文件不是行為)', FILE, `// 之前的病:owner_id 拿 env.CONSOLE_TENANT,portalTenant(c.env) 那條路整個空掉`],
|
||||
['JSDoc 區塊裡出現同樣的字', FILE, ` * 舊寫法 owner_id=\${portalTenant(env)} 已廢除,改走 knowledgeOwner。`],
|
||||
['行末註解裡出現(程式碼本身乾淨)', FILE, ` const tenant = knowledgeOwner(c.env); // 不是 portalTenant(c.env),也不是 owner_id=leo`],
|
||||
['tenant.ts 自己讀環境變數(它就是唯一產地)', TENANT_SOURCE_FILE, ` const injected = (env.ARCRUN_NAMESPACE ?? '').trim();`],
|
||||
['types.ts 只宣告型別不取值', 'src/types.ts', ` CONSOLE_TENANT?: string;`],
|
||||
];
|
||||
|
||||
for (const [name, path, line] of legit) {
|
||||
it(`零誤攔:${name}`, () => {
|
||||
expect(scan(path, line)).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('回歸:現行原始碼是乾淨的(讀真檔,不是讀我編的字串)', () => {
|
||||
const files: [string, string][] = [
|
||||
[TENANT_SOURCE_FILE, tenantLibSource as string],
|
||||
['src/routes/portal-data.ts', portalDataSource as string],
|
||||
['src/routes/portal.ts', portalSource as string],
|
||||
['src/routes/console-auth.ts', consoleAuthSource as string],
|
||||
['src/routes/console-dashboard.ts', consoleDashboardSource as string],
|
||||
];
|
||||
for (const [path, text] of files) {
|
||||
it(`${path} 零違規`, () => {
|
||||
expect(scan(path, text)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it('唯一產地本身沒有字面預設值(knowledgeOwner 解析不到要用丟的,不是回 "leo")', () => {
|
||||
const body = (tenantLibSource as string).slice(
|
||||
(tenantLibSource as string).indexOf('export function knowledgeOwner'),
|
||||
(tenantLibSource as string).indexOf('export function tenantFromApiKey'),
|
||||
);
|
||||
expect(body).toContain('TenantUnresolvedError');
|
||||
// 解析路徑只准回 env 讀到的值;任何 `|| '...'` / `?? '...'` 形式的字面 fallback 都是本票的病本身
|
||||
expect(body).not.toMatch(/(\|\||\?\?)\s*['"][^'"]+['"]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* wait:等待不該吃運算額度(Arcrun#101)
|
||||
*
|
||||
* 病灶(leo 2026-08-12 於 youlin stage 實測,只有 input >> wait 兩個節點):
|
||||
* ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
* 四個值同一種死法、與 ms 完全無關。若「等 N 秒=燒 N 秒 CPU」,ms=3000 只會花 3 秒
|
||||
* 就結束、根本不該死 —— 所以真正的病不是「等待很貴」,是「等待永遠不會結束」。
|
||||
*
|
||||
* 機制:wait 是 TinyGo WASM,time.Sleep 走 WASI poll_oneoff;component worker 的
|
||||
* WASI shim 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get
|
||||
* 自旋;而 Workers 的時鐘在無 I/O 的同步執行期間凍結 ⇒ 迴圈的結束條件永遠不成立。
|
||||
*
|
||||
* 本檔驗四件事:
|
||||
* A. 反向驗證(機制):在真的 workerd 裡,輪詢時鐘的同步自旋迴圈確實永不前進。
|
||||
* B. 修法本體:wait 走引擎的 timer ⇒ 真的讓出執行緒(不佔請求執行緒)。
|
||||
* C. 契約沒變:既有 workflow 的 wait 節點定義不用改就能照樣跑。
|
||||
* D. 路由:wait 由 step 1 內建命中,不再打 arcrun-wait worker(不發任何 fetch)。
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { env } from 'cloudflare:test';
|
||||
import { BUILTIN_COMPONENTS, WAIT_MAX_MS } from '../src/lib/constants';
|
||||
import { createComponentLoader } from '../src/lib/component-loader';
|
||||
import type { Bindings, ComponentRunner } from '../src/types';
|
||||
|
||||
const wait = BUILTIN_COMPONENTS.get('wait') as ComponentRunner;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── A. 反向驗證:舊路徑為什麼不可能便宜地等 ──────────────────────────────────
|
||||
//
|
||||
// 直接跑那顆 component.wasm 沒辦法寫成安全的測試 —— 它會把 isolate 卡到 CPU 上限,
|
||||
// 測試無從中止(那正是 bug 本身)。所以這裡驗的是「**沙箱裡根本沒有睡覺這個手段**」。
|
||||
//
|
||||
// 🔴 這裡本來有一條斷言「Workers 的時鐘在同步執行期間凍結,所以自旋迴圈的結束條件
|
||||
// 永遠不成立」。**實跑打臉了**:在 vitest-pool-workers 的 workerd 裡,2553 圈之後
|
||||
// Date.now() 就前進了。⇒ 那條斷言被刪掉,不是改鬆——它從一開始就不是證據。
|
||||
//
|
||||
// 保留下來的是**查證得動的那一半**:WASI shim 把 poll_oneoff 實作成 ENOSYS(76),
|
||||
// TinyGo 的 time.Sleep 只有這一條路可走 ⇒ 拿不到「睡到某個時刻」的手段,
|
||||
// 只能退化成自旋。至於「自旋為什麼會拖到 35 秒才死」的完整機制**仍是推測**,
|
||||
// 證據是 leo 在 youlin stage 的四次實測(見檔頭),不是本檔任何一條斷言。
|
||||
//
|
||||
// ⇒ 而修法不依賴那個推測:純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)
|
||||
// 本來就沒有「不花 CPU 地等」這種東西,會等的只有宿主。無論卡死的細節是什麼,
|
||||
// 等待都該搬回引擎。
|
||||
// 「poll_oneoff 是 ENOSYS」這件事查原始碼即可(`wasi-shim.ts:319` 的
|
||||
// `poll_oneoff: () => WASI_ENOSYS`,以及 13 個 `.component-builds/*/src/index.ts`
|
||||
// 的 `poll_oneoff: () => 76`)。**沒有為它硬寫一條測試**——寫得出來的只會是
|
||||
// 「把字串抓出來比對」,那驗的是抓字串,不是行為。事實放註解,斷言留給真的驗行為的 B/C/D。
|
||||
describe('A. 反向驗證:WASI 沙箱裡沒有「睡覺」這個手段', () => {
|
||||
it('對照組:await 一個 timer 之後時鐘才會前進(=為什麼修法必須在引擎側 await)', async () => {
|
||||
const t0 = Date.now();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
expect(Date.now()).toBeGreaterThan(t0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── B. 修法本體:等待是 timer,不是佔用執行緒 ────────────────────────────────
|
||||
describe('B. 引擎側的 wait 真的讓出執行緒(等 30 秒與等 3 秒同價)', () => {
|
||||
it('5 個 300ms 的 wait 併發跑完 ≈ 300ms 而非 1500ms(會 blocking 的實作做不到這件事)', async () => {
|
||||
const started = Date.now();
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () => wait({ ms: 300 })),
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
for (const r of results) {
|
||||
expect(r).toEqual({ success: true, data: { waited_ms: 300 } });
|
||||
}
|
||||
// 序列化(blocking)會是 ~1500ms;讓出執行緒則 5 個計時器同時走完 ≈ 300ms。
|
||||
// 抓 900ms 當門檻:離 300 夠鬆、離 1500 夠遠。
|
||||
expect(elapsed).toBeLessThan(900);
|
||||
expect(elapsed).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
|
||||
it('等待期間 event loop 沒被佔住:同時排的 timer 照樣先到', async () => {
|
||||
const order: string[] = [];
|
||||
const waited = Promise.resolve(wait({ ms: 400 })).then(() => { order.push('wait-400'); });
|
||||
const ticked = new Promise<void>((r) => setTimeout(r, 50)).then(() => { order.push('tick-50'); });
|
||||
|
||||
await Promise.all([waited, ticked]);
|
||||
expect(order).toEqual(['tick-50', 'wait-400']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── C. 契約沒變:既有 wait 節點定義不用改 ────────────────────────────────────
|
||||
//
|
||||
// 逐條對 registry/components/wait/component.contract.yaml 的 gherkin_tests。
|
||||
describe('C. I/O 契約與 WASM 版一致(既有 workflow 不必改定義)', () => {
|
||||
it('contract gherkin:等待 100ms → waited_ms:100', async () => {
|
||||
expect(await wait({ ms: 100 })).toEqual({ success: true, data: { waited_ms: 100 } });
|
||||
});
|
||||
|
||||
it('contract gherkin:ms 為 0 時失敗(不是靜靜跳過)', async () => {
|
||||
expect(await wait({ ms: 0 })).toEqual({ success: false, error: 'ms 必須大於 0' });
|
||||
});
|
||||
|
||||
it('ms 缺漏 / 負數 / 非數字,一律誠實回 success:false,不假裝等過', async () => {
|
||||
for (const bad of [undefined, null, -1, 'abc', {}, []]) {
|
||||
expect(await wait({ ms: bad })).toEqual({ success: false, error: 'ms 必須大於 0' });
|
||||
}
|
||||
});
|
||||
|
||||
it('contract gherkin:ms=99999 截斷為上限 30000(不是報錯、也不是真的等 99 秒)', async () => {
|
||||
// 不真的等 30 秒:換掉 setTimeout,攔下引擎「要求等多久」再立刻放行。
|
||||
const asked: number[] = [];
|
||||
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
|
||||
asked.push(Number(delay));
|
||||
fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout);
|
||||
|
||||
expect(await wait({ ms: 99999 })).toEqual({ success: true, data: { waited_ms: WAIT_MAX_MS } });
|
||||
expect(asked).toEqual([WAIT_MAX_MS]);
|
||||
expect(WAIT_MAX_MS).toBe(30000); // 紅線:上限不准為了閃避資源限制被調小
|
||||
});
|
||||
|
||||
it('ms=30000 一路走到底也只是「排一個 30 秒的 timer」,沒有任何同步佔用', async () => {
|
||||
const asked: number[] = [];
|
||||
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
|
||||
asked.push(Number(delay));
|
||||
fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout);
|
||||
|
||||
expect(await wait({ ms: 30000 })).toEqual({ success: true, data: { waited_ms: 30000 } });
|
||||
expect(asked).toEqual([30000]);
|
||||
});
|
||||
|
||||
it('context 照契約透傳,並補上 waited_ms', async () => {
|
||||
const r = await wait({ ms: 5, context: { order_id: 'A-1', payload: { n: 2 } } });
|
||||
expect(r).toEqual({
|
||||
success: true,
|
||||
data: { order_id: 'A-1', payload: { n: 2 }, waited_ms: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('node.data 經 interpolateData 後 ms 會是字串 —— 收得下(WASM 版在這裡直接 unmarshal 失敗)', async () => {
|
||||
expect(await wait({ ms: '250' })).toEqual({ success: true, data: { waited_ms: 250 } });
|
||||
});
|
||||
});
|
||||
|
||||
// ── D. 路由:不再打 arcrun-wait worker ───────────────────────────────────────
|
||||
describe('D. component-loader 把 wait 解到內建 runner(step 1),不發任何 fetch', () => {
|
||||
it('loader("wait") 跑起來不會對外送出任何請求', async () => {
|
||||
const fakeEnv = { ...env, WORKER_SUBDOMAIN: 'test-sub' } as unknown as Bindings;
|
||||
const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const runner = await createComponentLoader(fakeEnv)('wait');
|
||||
const r = await runner({ ms: 10 });
|
||||
|
||||
expect(r).toEqual({ success: true, data: { waited_ms: 10 } });
|
||||
// 修法前這裡會打 arcrun-wait.test-sub.workers.dev(SVC_WAIT 未綁時的 fallback),
|
||||
// 那顆 worker 就是會燒到 1102 的那顆。
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wait 仍在「執行期真的解析得動」的清單裡(/cypher/search 查得到)', async () => {
|
||||
const { RUNTIME_NATIVE_COMPONENT_IDS } = await import('../src/lib/component-loader');
|
||||
expect(RUNTIME_NATIVE_COMPONENT_IDS.has('wait')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -49,3 +49,15 @@ KBDB_BASE_URL = "https://kbdb.test"
|
||||
CONSOLE_TENANT = "leo"
|
||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||
KBDB_GRAPH_URL = "https://graph.test"
|
||||
# Arcrun#100:kbdb-graph-plugin 對 /triplets /graph /search /entities 掛 Bearer 閘。測試環境要有
|
||||
# 這把(明顯的假字串、非真實金鑰)才驗得出「cypher 打 plugin 有沒有帶 token」——原本兩支
|
||||
# /triplets/stats 漏帶 → 永遠 401 → 前端「三元組 0」。真實部署仍走 wrangler secret put。
|
||||
KBDB_INTERNAL_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,同上方 CF_SECRETS_API_TOKEN 慣例
|
||||
# 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"
|
||||
|
||||
@@ -138,6 +138,19 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
|
||||
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
|
||||
CONSOLE_TENANT = "leo"
|
||||
|
||||
# 這台實例的**知識命名空間**(Arcrun#108)=知識資料(三元組/卡片/藏書地圖/工作流 KV)
|
||||
# 實際掛在哪個 owner_id 底下。**這裡刻意不寫死**:官方 prod 的知識確實在 `CONSOLE_TENANT`
|
||||
# (leo)底下,未設就沿用它,行為一字不變。
|
||||
#
|
||||
# self-hosted 實例由 `acr update` 自動注入(值=你 `~/.arcrun/config.yaml` 的 `api_key`,
|
||||
# 也就是 CLI push 工作流、小幫手上傳知識、MCP 查詢用的同一個 namespace),
|
||||
# 而且**只在確認那個 namespace 底下真的查得到知識時才寫**(見 cli/src/lib/deploy.ts
|
||||
# namespaceHasKnowledge)——避免把一台原本正常的實例指向空的那一格。
|
||||
#
|
||||
# 為什麼要跟 CONSOLE_TENANT 分開:CONSOLE_TENANT 同時是帳號子 namespace(`{tenant}::portal`)
|
||||
# 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。
|
||||
# ARCRUN_NAMESPACE = "your-namespace"
|
||||
|
||||
# Portal session TTL 秒數(portal-auth P2,#24/#25,routes/portal.ts)。預設 7 天(604800)——
|
||||
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
|
||||
PORTAL_SESSION_TTL = "604800"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- credential template seed(D38 圍牆修復,總管交辦,2026-08-07)
|
||||
-- SDD:無專屬 SDD(D38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
|
||||
--
|
||||
-- D38 鐵律(leo 2026-06-14 立、2026-08-07 擴大):KBDB 三張表打天下,永遠不加新表;
|
||||
-- 新資料類型一律用 template + entries,同 0003_library_map.sql / 0004_execution_log_template.sql
|
||||
-- 的手法——對 templates 表 INSERT OR IGNORE 一列定義,不建新表、不動既有表的結構。
|
||||
--
|
||||
-- 這是「credential 目錄」的第二個家:原本 0002_credentials.sql 在 KBDB 裡多開了一張
|
||||
-- 獨立表(違規,見 kbdb-usage skill「反例」),本檔 + 0006_drop_credentials_table.sql
|
||||
-- 把它改回三張表的形狀——一筆 credential=entries 表一列(entry_type='credential',
|
||||
-- page_name=name 當冪等鍵,owner_id=api_key 做租戶隔離,其餘欄位打包進 metadata_json),
|
||||
-- 儲存精神比照既有 recipe_stat / execution_log(template 只負責文件化,實際資料不走
|
||||
-- entry_values 全展開的多列 record)。
|
||||
--
|
||||
-- 密文本體不在這裡:值仍住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理
|
||||
-- API 唯寫,D19「擁有目錄,不擁有內容物」不變)。這張 template 定義的 slots 全部是目錄
|
||||
-- 欄位,零密文——與舊 0002_credentials.sql 的欄位定義一字不變,只是換了個家。
|
||||
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
|
||||
VALUES (
|
||||
'tpl-credential',
|
||||
'credential',
|
||||
'credential 目錄(D38 圍牆修復:改走 entries 表 entry_type=credential,取代舊 credentials 表;零密文,密文本體住 Workers per-script Secrets)',
|
||||
'["name","service","sensitivity","secret_ref","last_used_at"]',
|
||||
'system'
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 退役 credentials 表(D38 圍牆修復,總管交辦,2026-08-07)
|
||||
-- SDD:無專屬 SDD(D38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
|
||||
--
|
||||
-- 這是本次唯一真的需要動表結構的一支 migration,理由(不是繞過鐵律,是鐵律要求的收尾):
|
||||
-- D38 要求 KBDB 回到「只有三張核心表」的狀態。0002_credentials.sql 當初在 KBDB 裡多開了
|
||||
-- 一張獨立表,是已知違規(kbdb-usage skill 明文列為反例)。要把違規清乾淨,唯一辦法就是
|
||||
-- 真的把那張表拆掉——拆表本身不能只用 API 做(API 不提供「拆表」這種牆內維運操作,
|
||||
-- 也不該提供),所以下面兩句 SQL 標 kbdb-sql-ok:這不是繞過圍牆去存取資料,是圍牆施工
|
||||
-- 本身(kbdb/migrations/ 就是牆內,本檔存在的唯一目的就是讓舊表退場)。
|
||||
--
|
||||
-- 冪等設計(deploy.ts 每次部署都會重跑這支檔案,沒有 migration 追蹤表):
|
||||
-- 1. 先補一份空表存在保底——self-hosted 各實例套用進度不一,有些從沒跑過 0002(表從不
|
||||
-- 存在)、有些已經跑過本檔一次(表已被拆)。沒有這一步,下面的搬資料/退場語句會因表
|
||||
-- 不存在直接整支失敗(D1 對不存在的表沒有條件式跳過語法)。
|
||||
-- 2. 把舊表裡「entries 還沒有對應列」的 row 搬進 entries(entry_type='credential',
|
||||
-- page_name=name 冪等鍵,owner_id=api_key,其餘欄位打包進 metadata_json,欄位對應
|
||||
-- 0005_credential_template.sql 定義的 slots)。NOT EXISTS 判斷防止重跑造成重複列。
|
||||
-- 3. 搬完資料後表就沒有存在的理由,最後一步讓它退場。下次部署若又被步驟 1 重新墊一份
|
||||
-- 空殼,也只是空表、立刻搬 0 筆、立刻退場,不影響任何人(真資料只會被搬一次,因為
|
||||
-- 步驟 2 的判斷是看 entries 裡有沒有,不是看這是不是第一次跑)。
|
||||
CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 表退場施工步驟①保底存在,非資料存取違規,理由見檔頭
|
||||
api_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
service TEXT,
|
||||
sensitivity TEXT NOT NULL DEFAULT 'standard',
|
||||
secret_ref TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
PRIMARY KEY (api_key, name)
|
||||
);
|
||||
|
||||
INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at)
|
||||
SELECT
|
||||
'e_cred_' || lower(hex(randomblob(8))),
|
||||
'credential',
|
||||
c.api_key,
|
||||
c.name,
|
||||
json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at),
|
||||
c.created_at,
|
||||
unixepoch()
|
||||
FROM credentials c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM entries e
|
||||
WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS credentials; -- kbdb-sql-ok: 表退場施工步驟③讓舊表退場,非資料存取違規,理由見檔頭
|
||||
@@ -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);
|
||||
}
|
||||
+283
-13
@@ -216,12 +216,63 @@ const MAX_LIKE_TERMS = 6;
|
||||
|
||||
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
|
||||
|
||||
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
|
||||
// ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(Arcrun#94)────────────────
|
||||
//
|
||||
// 病徵:搜尋框打 `100%` 或 `owner_id`,回來一堆跟那些字無關的東西。
|
||||
// SQLite LIKE 只有兩個萬用字元——`%`(任意長度)與 `_`(任意一個字元),而且
|
||||
// **沒有預設跳脫字元**(不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」)。
|
||||
// 我們把使用者輸入直接內插成 `'%' + q + '%'` ⇒ 他打的符號被當成 pattern 語法:
|
||||
// `100%` → `%100%%` → 「100」開頭後面接什麼都算 ⇒ 撈回一堆不相干的
|
||||
// `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ `ownerXid`、`owner-id` 也中
|
||||
// `%`/`_` 單打 → `%%%`/`%_%` → **整個庫都回來**(`_` 只要有一個字元就中)
|
||||
//
|
||||
// 這是舊病,不是 08-10 斷詞(47c6aae→本檔上一段)引進的:pattern 一直都是這樣拼的。
|
||||
// 之前關鍵字搜尋幾乎恆為 0 命中(整串比對),這個洞被那個洞蓋住,看不出來;
|
||||
// 斷詞讓搜尋真的會回東西之後它才浮出來。**斷詞那段一個字都沒動。**
|
||||
//
|
||||
// 修法:三個字元都跳脫,並在每個 LIKE 後面掛 `ESCAPE '\'`。
|
||||
//
|
||||
// 為什麼**跳脫字元本身(`\`)也要跳脫**(邊界問題的答案,不是順手多做):
|
||||
// 一旦宣告了 ESCAPE,`\` 在 pattern 裡就變成有意義的字元,於是「使用者打的 `\`」
|
||||
// 同樣會被誤讀——而且是更糟的一種,因為它會**把後面那個字吃掉**:
|
||||
// 使用者打 `100\%` → 不跳脫 `\` ⇒ pattern `%100\%%` ⇒ `\%`=字面 %
|
||||
// ⇒ 實際找的是 `100%`,**跟他打的字不一樣**
|
||||
// 使用者打 `C:\` → pattern `%C:\%` ⇒ 尾巴 `\%`=字面 %
|
||||
// ⇒ 找的是 `C:%`,而真正的 `C:\` 反而找不到
|
||||
// ⇒ 三個字元是一組的:宣告 ESCAPE 卻不跳脫 `\` 等於用新的漏洞換掉舊的。
|
||||
// (SQLite 對「`\` 後面接其他字元」是寬容的——照字面匹配下一個字、不報錯——
|
||||
// 所以不跳脫不會炸,只會靜靜地找錯東西,正是最難發現的那種。)
|
||||
//
|
||||
// 為什麼**只有這三個**:SQLite 的 LIKE 萬用字元就只有 `%` 和 `_`(`[...]`、`?`、`*`
|
||||
// 是別的方言/GLOB 的東西,LIKE 不吃),加上自己宣告的跳脫字元 `\`,就這三個。
|
||||
// 不多跳脫其他字元——跳脫沒有語法意義的字元只會白白吃掉 pattern 的 byte 預算。
|
||||
//
|
||||
// 🔴 與 50 bytes 上限的交互作用(不能只加跳脫就收工):跳脫會**變長**(`%`→`\%`),
|
||||
// 所以所有 byte 預算改用「跳脫後」的長度算(likeBytes),否則使用者打一串 `%`
|
||||
// 會讓 pattern 膨脹回 50 bytes 以上 ⇒ 退回 2026-08-03 那個 500。
|
||||
// 不含這三個字元的查詢,likeBytes ≡ utf8Len ⇒ **既有查詢的行為逐字不變**。
|
||||
const LIKE_ESCAPE = '\\';
|
||||
/** 每個 `content LIKE ?` 都要帶著它的 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面。 */
|
||||
const CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
|
||||
|
||||
/** 把使用者輸入當「字面字串」送進 LIKE(純函式,單測用 export)。 */
|
||||
export function escapeLikeLiteral(s: string): string {
|
||||
// 一次掃描、每個字元各自替換 ⇒ 不會發生「先換 % 再換 \ 把剛加的跳脫又跳脫一次」。
|
||||
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
|
||||
}
|
||||
|
||||
/** 這段文字**跳脫後**佔的 byte 數(=它在 LIKE pattern 裡真正佔的長度)。 */
|
||||
const likeBytes = (s: string): number => utf8Len(escapeLikeLiteral(s));
|
||||
|
||||
/** 子字串比對用的 pattern:只有頭尾那兩個 `%` 是萬用字元,中間全是字面。 */
|
||||
const likePattern = (s: string): string => `%${escapeLikeLiteral(s)}%`;
|
||||
|
||||
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。上限算的是**跳脫後**的長度。 */
|
||||
function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = '';
|
||||
for (const ch of s) {
|
||||
if (utf8Len(cur + ch) > maxBytes) {
|
||||
if (likeBytes(cur + ch) > maxBytes) {
|
||||
if (cur) out.push(cur);
|
||||
cur = ch;
|
||||
} else {
|
||||
@@ -237,8 +288,8 @@ function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
|
||||
*/
|
||||
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
|
||||
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
|
||||
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
|
||||
}
|
||||
const terms: string[] = [];
|
||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||
@@ -251,12 +302,215 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
|
||||
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
|
||||
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
|
||||
return {
|
||||
conds: terms.map(() => 'content LIKE ?'),
|
||||
params: terms.map((t) => `%${t}%`),
|
||||
conds: terms.map(() => CONTENT_LIKE),
|
||||
params: terms.map(likePattern),
|
||||
split: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 查詢斷詞 + 覆蓋率排序:讓「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(likePattern(term));
|
||||
}
|
||||
|
||||
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
|
||||
params.push(likePattern(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 +563,11 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||
// 2026-08-12(Arcrun#94):q 裡的 `%` `_` `\` 一律當字面字元(escapeLikeLiteral + ESCAPE 宣告)
|
||||
// ——使用者打什麼字就照那些字找。舊病,見上面 LIKE_ESCAPE 那段。
|
||||
// 2026-08-10:q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||
export async function searchEntries(
|
||||
db: D1Database,
|
||||
q: string,
|
||||
@@ -318,18 +577,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// 標庫 backfill(Arcrun#85 二次裁決,2026-08-11;相關票 Arcrun#87「藏書地圖是空的」)。
|
||||
//
|
||||
// 背景:leo 把向量化優先序講成一句話後又補一刀:「它是兩件事?其實是一件——沒有庫就
|
||||
// 剩一個,但你把庫標好以後,原來的判定要修改對吧」。⇒ 判定標準(embed.ts 的
|
||||
// SelectionCriteria)必須從第一天就同時容納時間與庫,本檔提供「庫」這一半真正的資料。
|
||||
//
|
||||
// base 的 write path 早就支援(`createEntry` 的 `metadata_json.$.library`,t52:「庫由
|
||||
// ingest 蓋章決定」)——既有的搜尋/embed/deprecate-by-library 全都讀這個欄位,
|
||||
// **缺的不是機制,是既有資料沒被蓋章**(library-map.ts 檔頭 2026-07-19 對 prod 核實:
|
||||
// 既有 entries 的 metadata.library 是空的)。「源頭寫入就貼標」是呼叫端(ingest)的事,
|
||||
// base 這裡管不到、也不該猜(base 對內容語意無知的既有原則);triplet 的實際寫入更是
|
||||
// 在另一個 repo(見 kbdb/src/index.ts 檔頭「triplet (separate repo)」)。
|
||||
//
|
||||
// 這個模組只做「補存量」那一半,且刻意設計成呼叫端驅動:
|
||||
// - base 不猜「這筆該屬於哪個庫」——那是語意判斷。呼叫端給一個 target library 值
|
||||
// +一組篩選條件,base 只負責把符合條件、目前未標記的 entries 安全、節流地蓋上這個值。
|
||||
// - 篩選條件有兩種精度(2026-08-11 leo 定案的做法後補上):
|
||||
// ① 精準比對 `page_names`(IN 清單)——leo 定的正解:「有 2 份原稿,在 gitea 和我的
|
||||
// Mac……去 gitea 把每個庫有哪些的卡名列出,跑來遍歷應該就搞定了」。呼叫端(daemon/
|
||||
// #87)從 Gitea repo 列出卡名,逐批把「這些卡名屬於庫 X」精準地寫進來,不必猜。
|
||||
// ② `source_prefix`/`page_name_prefix` 前綴 fallback(同 library-map.ts
|
||||
// recomputeLibraryMap 的 source_prefix 精神,只是這裡是寫入不是聚合)——沒有精準
|
||||
// 清單時的過渡手段,精度不如①,兩者可並用(AND)縮小範圍。
|
||||
// - 冪等:已標記的 entries 不會再入選(WHERE 帶「library 為空」)。
|
||||
//
|
||||
// D69 節流:與 reconcileEmbedGeneration(embed.ts)共用 maintenance-quota.ts 的同一顆
|
||||
// 每日 D1 寫入計數器——兩者都是「多筆 D1 row write、不打 AI」的背景維護操作,不共用
|
||||
// 計數器的話,補存量時會把世代核對的閘繞過去(leo 2026-08-11 二次裁決原話:「每日上限
|
||||
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
|
||||
import type { Bindings } from '../types';
|
||||
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
|
||||
import { updateRecord } from './record-crud';
|
||||
import { ensureTripletLibrarySlot, DEFAULT_TRIPLET_TEMPLATE } from './library-map';
|
||||
|
||||
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
|
||||
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
|
||||
const MAX_PAGE_NAMES = 300;
|
||||
|
||||
export interface LibraryBackfillCriteria {
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
page_names?: string[]; // 精準比對 page_name(IN 清單)——leo 定案的正解:從 Gitea repo
|
||||
// 列出卡名,逐批精準點名「這些卡名屬於庫 X」(見檔頭說明①)。
|
||||
source_prefix?: string; // metadata_json.$.source LIKE prefix%(過渡 fallback,見檔頭②)
|
||||
page_name_prefix?: string; // page_name LIKE prefix%(過渡 fallback,見檔頭②)
|
||||
since?: number; // created_at >= since(unix seconds)
|
||||
until?: number; // created_at < until(unix seconds)
|
||||
}
|
||||
|
||||
export interface LibraryBackfillResult {
|
||||
library: string;
|
||||
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
|
||||
tagged: number; // 本次真的寫入 metadata_json.$.library 的筆數。
|
||||
remaining: number; // 本次之後仍待補標(符合條件、仍未標記)的筆數,不受額度影響。
|
||||
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與 reconcile 共用)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷。
|
||||
}
|
||||
|
||||
// 單次呼叫候選上限(避開 subrequest/CPU/timeout;一批只有 1 次 SELECT + 1 次 UPDATE,
|
||||
// 比 reconcile 多一次 Vectorize 呼叫的成本低,故上限可以放寬一些)。
|
||||
const HARD_LIMIT_CAP = 500;
|
||||
|
||||
function criteriaPredicate(c: LibraryBackfillCriteria): { conds: string[]; params: unknown[] } {
|
||||
// 冪等的核心:只選「目前沒有 library 值」的候選,已標記過的(含標成 'general' 的)不會再入選。
|
||||
const conds: string[] = [
|
||||
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')",
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
if (c.owner_id) { conds.push('owner_id = ?'); params.push(c.owner_id); }
|
||||
if (c.entry_type) { conds.push('entry_type = ?'); params.push(c.entry_type); }
|
||||
if (c.page_names && c.page_names.length > 0) {
|
||||
const names = c.page_names.slice(0, MAX_PAGE_NAMES);
|
||||
conds.push(`page_name IN (${names.map(() => '?').join(',')})`);
|
||||
params.push(...names);
|
||||
}
|
||||
if (c.source_prefix) { conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'"); params.push(c.source_prefix); }
|
||||
if (c.page_name_prefix) { conds.push("page_name LIKE ? || '%'"); params.push(c.page_name_prefix); }
|
||||
if (typeof c.since === 'number') { conds.push('created_at >= ?'); params.push(c.since); }
|
||||
if (typeof c.until === 'number') { conds.push('created_at < ?'); params.push(c.until); }
|
||||
return { conds, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 對「符合條件、目前未標記 library」的既有 entries 批次蓋上 target library 值。
|
||||
* 冪等 + 分批(單次 limit 上限)+ budget(與 reconcile 共用每日 D1 寫入額度,見檔頭)。
|
||||
* 呼叫端(ingest / Arcrun#87)決定「這批是誰、該貼哪個庫」,本函式只負責安全、節流地
|
||||
* 把值寫進去——base 不猜語意,也因此不假裝「這樣就把 #87 做完了」(mindset §7)。
|
||||
*
|
||||
* `owner_id` 刻意設成**必填**(不同於 LibraryBackfillCriteria 其餘欄位皆選填):
|
||||
* 2026-08-11 leo 在票上點出「補標補在錯的 owner 底下等於白做」(實查發現卡片實際掛在
|
||||
* `owner_id=bfezv28v`,換成 `owner_id='leo'` 查卻是空的——兩個候選 owner 已經在互相打架)。
|
||||
* 跟既有的 `deprecateEntriesByLibrary`(同樣是「依 library 批次改一大片既有資料」的操作)
|
||||
* 同一個防線:不給不知道自己在改誰的資料的呼叫端一個「忘記帶 owner_id 就變成跨租戶全庫掃」
|
||||
* 的後門,逼呼叫端明確想清楚「這批是哪個 owner」再動手。
|
||||
*/
|
||||
export async function backfillEntryLibraryTags(
|
||||
db: D1Database,
|
||||
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
|
||||
opts: { library: string; owner_id: string; limit?: number } & Omit<LibraryBackfillCriteria, 'owner_id'>,
|
||||
): Promise<LibraryBackfillResult> {
|
||||
const library = (opts.library ?? '').trim();
|
||||
if (!library) throw new Error('library required');
|
||||
const ownerId = (opts.owner_id ?? '').trim();
|
||||
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
|
||||
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
|
||||
|
||||
const sel = criteriaPredicate({ ...opts, owner_id: ownerId });
|
||||
const where = sel.conds.join(' AND ');
|
||||
const params = sel.params;
|
||||
|
||||
const res = await db
|
||||
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write,與 reconcile 共用同一顆計數器。
|
||||
const budget = await maintenanceBudgetToday(env, db);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
|
||||
let tagged = 0;
|
||||
if (ids.length > 0) {
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`,
|
||||
)
|
||||
.bind(library, ...ids)
|
||||
.run();
|
||||
tagged = ids.length;
|
||||
}
|
||||
|
||||
try {
|
||||
await addMaintenanceUsage(db, tagged);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
|
||||
}
|
||||
|
||||
const remRow = await db
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
return {
|
||||
library,
|
||||
scanned,
|
||||
tagged,
|
||||
remaining: remRow?.c ?? 0,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + tagged,
|
||||
quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待補標統計(回報用):符合條件、目前未標記 library 的筆數。 */
|
||||
export async function libraryBackfillStatus(
|
||||
db: D1Database,
|
||||
opts: LibraryBackfillCriteria = {},
|
||||
): Promise<{ pending: number }> {
|
||||
const sel = criteriaPredicate(opts);
|
||||
const where = sel.conds.join(' AND ');
|
||||
const row = await db
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...sel.params)
|
||||
.first<{ c: number }>();
|
||||
return { pending: row?.c ?? 0 };
|
||||
}
|
||||
|
||||
// ── 三元組(triplet)版:藏書地圖真正讀的那一半(Arcrun#87 二次收尾,2026-08-13)──────────
|
||||
//
|
||||
// 上面 backfillEntryLibraryTags 補的是 entries.metadata_json.$.library(卡片/搜尋/embed 層)。
|
||||
// 藏書地圖(library-map.ts 的 recomputeLibraryMap/liveTripletCountsByLibrary)讀的是**三元組
|
||||
// record 自己的 'library' slot**(entry_values,經 record-crud 的 updateRecord 寫入)——兩者是
|
||||
// 兩個互不相干的存放處(票上 2026-08-11 14:21 comment「庫值有兩個互不相干的存放處」段已釐清),
|
||||
// 補了前者地圖依然是 0。
|
||||
//
|
||||
// 既有通道只有「單筆 PATCH /kbdb/records/:id」(b6ef0f0,2026-08-11)——沒有批次版本。
|
||||
// 補標母體上千筆時逐筆 PATCH 不現實(也不安全:呼叫端要自己刻節流/冪等,容易漏做)。
|
||||
// 本函式是三元組版的批次 backfill,安全原則與上面 entries 版逐條對齊,不重新發明:
|
||||
// - 呼叫端決定 library 值+篩選條件(base 對內容語意無知,不猜哪個 source_uri 該歸哪個庫)
|
||||
// - 冪等:只選「目前沒有 library slot 值」的候選(NOT EXISTS 找缺 library 的那半,同 entries
|
||||
// 版用「library 為空」而非覆蓋已標記過的)
|
||||
// - D69 節流:與 entries 版、embed reconcile 共用同一顆每日 D1 寫入額度計數器(不共用會被繞過)
|
||||
// - owner_id 必填(2026-08-11 leo 直令:批次改一大片既有資料不准無租戶範圍地掃)
|
||||
// - 寫入沿用既有 updateRecord(record-crud.ts)——不手刻第二套 entry_values UPSERT SQL;
|
||||
// 這條寫入路徑已經被 triplet-library-backfill.test.ts 驗證過語意正確(源頭順序/存量補標/
|
||||
// 冪等三案),本函式只是把它包成「呼叫端給 library+source_prefix,一次處理一批」的批次版。
|
||||
export interface TripletLibraryBackfillCriteria {
|
||||
owner_id?: string;
|
||||
triplet_template?: string; // 預設 DEFAULT_TRIPLET_TEMPLATE('triplet')
|
||||
source_prefix?: string; // source_uri LIKE prefix%(本票的規則:^gitea:Leo/<repo>@ → 各庫;^kb:// → kb)
|
||||
}
|
||||
|
||||
function tripletCriteriaSql(c: TripletLibraryBackfillCriteria & { owner_id: string }): { where: string; params: unknown[] } {
|
||||
// 候選:這個 template 底下、有 source_uri 值、owner 符合、source_uri 符合前綴、
|
||||
// 且目前這個 record 沒有任何 'library' slot 值的 record_id(NOT EXISTS 保冪等)。
|
||||
const conds = [
|
||||
'ev.slot_name = ?',
|
||||
'e.content LIKE ? || \'%\'',
|
||||
'e.owner_id = ?',
|
||||
`NOT EXISTS (SELECT 1 FROM entry_values lev WHERE lev.record_id = ev.record_id AND lev.slot_name = 'library')`,
|
||||
];
|
||||
const params: unknown[] = ['source_uri', c.source_prefix ?? '', c.owner_id];
|
||||
return { where: conds.join(' AND '), params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 對「符合條件、目前未標記 library」的既有三元組 record 批次補上 target library 值。
|
||||
* 冪等 + 分批(單次 limit 上限)+ budget(與 entries 版/embed reconcile 共用每日 D1 寫入額度)。
|
||||
* 呼叫端(daemon/來源標籤系統/#87)決定「這批 source_uri 前綴對應哪個 library」,
|
||||
* 本函式只負責安全、節流地把值寫進三元組 record(base 不猜語意,同 backfillEntryLibraryTags)。
|
||||
*/
|
||||
export async function backfillTripletLibraryTags(
|
||||
db: D1Database,
|
||||
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
|
||||
opts: { library: string; owner_id: string; limit?: number } & TripletLibraryBackfillCriteria,
|
||||
): Promise<LibraryBackfillResult> {
|
||||
const library = (opts.library ?? '').trim();
|
||||
if (!library) throw new Error('library required');
|
||||
const ownerId = (opts.owner_id ?? '').trim();
|
||||
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
|
||||
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
|
||||
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
|
||||
|
||||
// 冪等地確保 template 有 library slot(同 recomputeLibraryMap 的既有慣例,不動表)。
|
||||
await ensureTripletLibrarySlot(db, tripletTemplateName);
|
||||
|
||||
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
|
||||
if (!tpl) throw new Error(`triplet template not found: ${tripletTemplateName}`);
|
||||
|
||||
const sel = tripletCriteriaSql({ ...opts, owner_id: ownerId });
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT ev.record_id AS id FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ? AND ${sel.where} ORDER BY ev.record_id ASC LIMIT ?`,
|
||||
)
|
||||
.bind(tpl.id, ...sel.params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write(updateRecord 對「缺 slot」的 grow 路徑正是 1 次
|
||||
// INSERT),與 entries 版/reconcile 共用同一顆計數器。
|
||||
const budget = await maintenanceBudgetToday(env, db);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
|
||||
let tagged = 0;
|
||||
for (const id of ids) {
|
||||
const updated = await updateRecord(db, id, { library });
|
||||
if (updated) tagged += 1;
|
||||
}
|
||||
|
||||
try {
|
||||
await addMaintenanceUsage(db, tagged);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
|
||||
}
|
||||
|
||||
const remRow = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
|
||||
)
|
||||
.bind(tpl.id, ...sel.params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
return {
|
||||
library,
|
||||
scanned,
|
||||
tagged,
|
||||
remaining: remRow?.c ?? 0,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + tagged,
|
||||
quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待補標統計(三元組版,回報用):符合條件、目前未標記 library 的三元組筆數。 */
|
||||
export async function tripletLibraryBackfillStatus(
|
||||
db: D1Database,
|
||||
opts: TripletLibraryBackfillCriteria & { owner_id: string },
|
||||
): Promise<{ pending: number }> {
|
||||
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
|
||||
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
|
||||
if (!tpl) return { pending: 0 };
|
||||
const sel = tripletCriteriaSql(opts);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
|
||||
)
|
||||
.bind(tpl.id, ...sel.params)
|
||||
.first<{ c: number }>();
|
||||
return { pending: row?.c ?? 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 {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// 背景維護寫入的共用 D1 每日額度(Arcrun#85 D69,2026-08-11)。
|
||||
//
|
||||
// 為什麼需要這個模組(不是每個 caller 各自算):
|
||||
// D68 已經替「補算向量」的 Workers AI 呼叫量設了每日軟上限(embed.ts
|
||||
// DEFAULT_BACKFILL_DAILY_LIMIT),但 leo 逐行複核後指出還有一個沒堵的洞——
|
||||
// 世代核對(reconcileEmbedGeneration)**不打 AI,卻一樣逐筆寫 D1**(補標 content_hash
|
||||
// 或重置 is_embedded),47 萬筆候選 ≈ 4.7 倍 D1 免費層 100,000 rows written/日,而它
|
||||
// 當時零保護。2026-08-11 leo 補了第二刀:**標庫(library backfill)也是同一種操作**
|
||||
// ——多筆 D1 row write、不打 AI——若各自設一顆獨立計數器,做標庫時會把 reconcile
|
||||
// 的閘繞過去(兩者加起來還是可能燒穿同一顆 D1)。
|
||||
// ⇒ 兩者必須共用同一顆「今天 D1 背景維護寫入還剩多少」計數器,這裡就是那顆計數器。
|
||||
//
|
||||
// 儲存精神完全比照 execution-log.ts checkUsage/embed.ts getBackfillUsageToday:單一
|
||||
// entries 列/日(entry_type='kbdb_maintenance_usage'),upsert,不新增表(D38)。
|
||||
//
|
||||
// 額度怎麼選(不是拍腦袋,比照 execution-log.ts DEFAULT_DAILY_LIMIT 的既有算法):
|
||||
// D1 免費層 100,000 rows written/日。execution_log 自設 20%(20,000)留給知識卡;
|
||||
// 本模組管的是「背景維護」(reconcile + 標庫 backfill,兩者都是低優先、非使用者
|
||||
// 當下等待的操作),同樣自設 20%(20,000/日)——不是硬性 Cloudflare 限制,是不讓
|
||||
// 背景維護把當天寫入額度和知識卡片的正常寫入/execution_log 搶光的自我節制,
|
||||
// 可用 env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT 覆寫。
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 20000;
|
||||
|
||||
export function maintenanceDailyLimit(env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>): number {
|
||||
const raw = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
}
|
||||
|
||||
function utcDay(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 額度計數器 entries id(單一列/日;不分租戶——D1 rows-written 額度是實例級,非租戶級)。 */
|
||||
function maintenanceUsageId(): string {
|
||||
return `kbdb-maintenance-usage:${utcDay()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今天背景維護寫入已消耗的筆數。讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open,
|
||||
* 精神同 embed.ts getBackfillUsageToday)。
|
||||
*/
|
||||
export async function getMaintenanceUsageToday(db: D1Database): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(maintenanceUsageId())
|
||||
.first<{ metadata_json: string | null }>();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { writes?: number }) : {};
|
||||
return Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 今天背景維護額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
|
||||
export async function addMaintenanceUsage(db: D1Database, by: number): Promise<void> {
|
||||
if (by <= 0) return;
|
||||
const id = maintenanceUsageId();
|
||||
const existing = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ metadata_json: string | null }>();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
|
||||
prev = Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
prev = 0;
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||||
.bind(JSON.stringify({ day: utcDay(), writes: prev + by }), id)
|
||||
.run();
|
||||
} else {
|
||||
await db
|
||||
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'kbdb_maintenance_usage', ?)`)
|
||||
.bind(id, JSON.stringify({ day: utcDay(), writes: by }))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export interface MaintenanceBudget {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
/** 今天還剩多少背景維護 D1 寫入額度(reconcile/標庫 backfill 呼叫前先問這個)。 */
|
||||
export async function maintenanceBudgetToday(
|
||||
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
|
||||
db: D1Database,
|
||||
): Promise<MaintenanceBudget> {
|
||||
const limit = maintenanceDailyLimit(env);
|
||||
let used = 0;
|
||||
try {
|
||||
used = await getMaintenanceUsageToday(db);
|
||||
} catch {
|
||||
used = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連背景維護都做不了
|
||||
}
|
||||
return { limit, used, remaining: Math.max(0, limit - used) };
|
||||
}
|
||||
@@ -65,6 +65,13 @@ export interface RecordResult {
|
||||
record_id: string;
|
||||
template_id: string;
|
||||
values: Record<string, string>;
|
||||
/**
|
||||
* record 的歸屬(=其底層 slot entries 的 owner_id,createRecord 寫入時同一值)。
|
||||
* 2026-08-12 補:`GET /records/:id` 原本不回這欄,所以**呼叫端無從判斷這筆是不是自己的**
|
||||
* ——按 id 直讀等於沒有租戶邊界。要讓 cypher 的 portal 資料面(授權的人/AI 走的那條)
|
||||
* 能對單筆做「不是我的就回 404」,歸屬必須跟著資料一起回來。無歸屬的舊資料 → null。
|
||||
*/
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
|
||||
@@ -85,7 +92,7 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
|
||||
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
||||
.run();
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||
}
|
||||
|
||||
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
||||
@@ -147,17 +154,19 @@ export async function updateRecord(
|
||||
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`,
|
||||
)
|
||||
.bind(recordId)
|
||||
.all<{ slot: string; content: string; template_id: string }>();
|
||||
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||
const rows = res.results ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
const values: Record<string, string> = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
||||
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
|
||||
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||
}
|
||||
|
||||
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
|
||||
@@ -192,19 +201,20 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const evRes = await db
|
||||
.prepare(
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id IN (${placeholders})`,
|
||||
)
|
||||
.bind(...chunk)
|
||||
.all<{ record_id: string; slot: string; content: string; template_id: string }>();
|
||||
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||
for (const r of evRes.results ?? []) {
|
||||
let rec = byId.get(r.record_id);
|
||||
if (!rec) {
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||
byId.set(r.record_id, rec);
|
||||
}
|
||||
rec.values[r.slot] = r.content;
|
||||
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||
}
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
||||
|
||||
+398
-15
@@ -12,6 +12,7 @@
|
||||
// base 只認這個通用旗標 → base 維持對內容語意無知。
|
||||
|
||||
import type { Bindings, Entry } from './types';
|
||||
import { maintenanceBudgetToday, addMaintenanceUsage } from './actions/maintenance-quota';
|
||||
|
||||
// ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)────────────────
|
||||
//
|
||||
@@ -132,7 +133,12 @@ export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean
|
||||
},
|
||||
]);
|
||||
// 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。
|
||||
await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(entry.id).run();
|
||||
// content_hash 順手蓋成「這次嵌入用的模型」(世代戳記,見下方 reconcileEmbedGeneration 的
|
||||
// 說明)——這裡是「新寫的立刻算」的路徑,寫入當下 model 必為現行 model,不會有世代落差。
|
||||
await env.DB
|
||||
.prepare('UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?')
|
||||
.bind(embedModel(env), entry.id)
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -173,12 +179,134 @@ function parseMeta(json: string | null): Record<string, unknown> | null {
|
||||
const BACKFILL_PREDICATE =
|
||||
"is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
|
||||
|
||||
// ── 每日額度上限(D68,2026-08-11:leo「補算向量照時間新到舊、且每天有額度上限」)─────────
|
||||
//
|
||||
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日免費 10,000 neurons(UTC 午夜重置,
|
||||
// 見頂層 wiki ops-facts.md「萃取與向量化吃同一份 Workers AI 額度」)。backfill 是背景低優先
|
||||
// 動作,不該把當天額度燒光讓萃取/今天的新寫入整天卡死(embedOnWrite 不受此上限——「新寫的
|
||||
// 立刻算」是 D68 三條之一,不能被 backfill 的節制連坐)。自設「軟上限」,非 Cloudflare 硬限制,
|
||||
// 可用 env.EMBED_BACKFILL_DAILY_LIMIT 覆寫(精神比照 execution-log.ts 的 DEFAULT_DAILY_LIMIT)。
|
||||
//
|
||||
// 預設值怎麼選(不是拍腦袋,2026-08-11 查證 Cloudflare 官方定價後回推):
|
||||
// bge-m3 定價:1,075 neurons / 1,000,000 input tokens(無輸出 token 成本,embedding 只有輸入)。
|
||||
// 保守估計每筆中文知識卡片 ~800 tokens(寧可高估——CJK tokenizer 密度通常高於英文,
|
||||
// 高估 token 數 ⇒ 算出的「每日可嵌筆數」偏保守,不會撞真的 CF 額度):
|
||||
// 800 tokens × 1,075 / 1,000,000 ≈ 0.86 neurons/entry
|
||||
// backfill 分到日配額 20%(比照 execution-log.ts「自我節制、留大部分給主流程」的既有慣例):
|
||||
// 10,000 × 20% = 2,000 neurons/日
|
||||
// 2,000 ÷ 0.86 ≈ 2,325 entries/日,再打八折留緩衝(token 估計誤差/其他背景消耗):
|
||||
// 2,325 × 0.8 ≈ 1,860 → 取整數 1,800。
|
||||
const DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
|
||||
|
||||
function backfillDailyLimit(env: Pick<Bindings, 'EMBED_BACKFILL_DAILY_LIMIT'>): number {
|
||||
const raw = env.EMBED_BACKFILL_DAILY_LIMIT;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
|
||||
}
|
||||
|
||||
function utcDay(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 額度計數器 entries id(單一列/日,UTC 日期字串,換日自然歸零;不分租戶——Workers AI 額度是帳號級)。 */
|
||||
function backfillUsageId(): string {
|
||||
return `embed-backfill-usage:${utcDay()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今天 backfill 已消耗的筆數。儲存精神完全比照 execution-log.ts 的 checkUsage:單一 entries 列/日
|
||||
* (entry_type='embed_backfill_usage',計數包進 metadata_json),不新增表。
|
||||
* 讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open)。
|
||||
*/
|
||||
async function getBackfillUsageToday(db: D1Database): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(backfillUsageId())
|
||||
.first<{ metadata_json: string | null }>();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { embedded?: number }) : {};
|
||||
return Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
return 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死額度機制
|
||||
}
|
||||
}
|
||||
|
||||
/** 今天 backfill 額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
|
||||
async function addBackfillUsage(db: D1Database, by: number): Promise<void> {
|
||||
if (by <= 0) return;
|
||||
const id = backfillUsageId();
|
||||
const existing = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ metadata_json: string | null }>();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { embedded?: number }) : {};
|
||||
prev = Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
prev = 0;
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||||
.bind(JSON.stringify({ day: utcDay(), embedded: prev + by }), id)
|
||||
.run();
|
||||
} else {
|
||||
await db
|
||||
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`)
|
||||
.bind(id, JSON.stringify({ day: utcDay(), embedded: by }))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 「挑哪一批」可以從外面指定(Arcrun#85,2026-08-11 leo 二度裁決)───────────────
|
||||
//
|
||||
// leo 的優先序不是「一律新到舊」的單一佇列,是**分層**:今天寫的立刻/這週在跑的先跑/
|
||||
// 有查詢紀錄的庫優先/半年前的慢慢跑。分層要能實作,前提是「這次補哪一批」要能從外面
|
||||
// (工作流)指定,不能只靠資料層自己決定的固定排序——策略要住在 leo 打得開的地方
|
||||
// (工作流頁),不是焊死在這裡看不見也改不動。
|
||||
//
|
||||
// 這裡不預先幫 caller 決定「四層怎麼切」(那是策略,屬於呼叫端/工作流,見 Arcrun#85
|
||||
// D70 段落的意圖草案),只提供**同一套篩選形狀**讓任何一層都能表達:
|
||||
// - since/until:時間窗(unix seconds,created_at 半開區間 [since, until))——時間分層
|
||||
// (①今天/②本週/④半年前)都是同一個 since/until 參數,差別只在呼叫端傳的值。
|
||||
// - library:依 metadata_json.$.library 過濾——一旦資料身上有庫這個資訊(Arcrun#87),
|
||||
// 「有查詢紀錄的庫優先」這層可以直接用同一個參數,不必再改介面形狀。
|
||||
// 三個操作(backfillEmbeddings/reconcileEmbedGeneration/backfillEntryLibraryTags,
|
||||
// 見 actions/library-backfill.ts)共用這個形狀,這就是「判定標準只有一份」的意思——
|
||||
// 不是先做時間、之後為了庫再回頭改介面。
|
||||
export interface SelectionCriteria {
|
||||
owner_id?: string;
|
||||
source?: string;
|
||||
library?: string; // 精確比對 metadata_json.$.library(未標記的舊資料一律歸 'general',同 embedOnWrite 慣例)
|
||||
since?: number; // created_at >= since(unix seconds)
|
||||
until?: number; // created_at < until(unix seconds)
|
||||
}
|
||||
|
||||
function selectionCriteriaPredicate(opts: SelectionCriteria): { conds: string[]; params: unknown[] } {
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
|
||||
if (opts.library) {
|
||||
conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?");
|
||||
params.push(opts.library);
|
||||
}
|
||||
if (typeof opts.since === 'number') { conds.push('created_at >= ?'); params.push(opts.since); }
|
||||
if (typeof opts.until === 'number') { conds.push('created_at < ?'); params.push(opts.until); }
|
||||
return { conds, params };
|
||||
}
|
||||
|
||||
export interface BackfillResult {
|
||||
enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。
|
||||
processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
|
||||
skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。
|
||||
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。
|
||||
skipped: number; // 掃到但沒嵌(例如 embedText 回 null,或本批被額度擋下)的筆數。
|
||||
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0,與額度無關——單純候選總量)。
|
||||
scanned: number; // 本批掃出的候選筆數(受 limit 限制)。
|
||||
quota_limit: number; // 今日 backfill 額度上限(env.EMBED_BACKFILL_DAILY_LIMIT 或預設值)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的 backfill 額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有可嵌的候選但今天不再打 AI,等明天/調高上限)。
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,9 +321,14 @@ export interface BackfillResult {
|
||||
*/
|
||||
export async function backfillEmbeddings(
|
||||
env: Bindings,
|
||||
opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {},
|
||||
opts: SelectionCriteria & { limit?: number; reindex?: boolean; offset?: number } = {},
|
||||
): Promise<BackfillResult> {
|
||||
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0,
|
||||
quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
||||
const offset = Math.max(opts.offset ?? 0, 0);
|
||||
|
||||
@@ -210,21 +343,39 @@ export async function backfillEmbeddings(
|
||||
// 🔴 2026-08-05:**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。
|
||||
// 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活,
|
||||
// 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
|
||||
// 「挑哪一批」(Arcrun#85):owner_id/source/library/since/until 全部走同一套
|
||||
// selectionCriteriaPredicate,讓呼叫端(工作流)能表達時間分層與庫分層,不必等
|
||||
// base 幫忙決定;本函式不預設任何一層,caller 傳什麼就篩什麼。
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
|
||||
const params: unknown[] = [...sel.params];
|
||||
const where = conds.join(' AND ');
|
||||
|
||||
// D68:由新到舊——最可能被查到的最先補回來(見檔頭 DEFAULT_BACKFILL_DAILY_LIMIT 段的決策脈絡)。
|
||||
const res = await env.DB
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`)
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
||||
.bind(...params, limit, offset)
|
||||
.all<Entry>();
|
||||
const rows = res.results ?? [];
|
||||
const scanned = rows.length;
|
||||
|
||||
// D68:每日額度上限。額度是「這次呼叫要不要打 AI」的唯一守門——reindex 一樣要打 AI.run,
|
||||
// 同樣受限(不因為是 reindex 就例外,會打 Workers AI 的動作都算)。
|
||||
const dailyCap = backfillDailyLimit(env);
|
||||
let usedToday = 0;
|
||||
try {
|
||||
usedToday = await getBackfillUsageToday(env.DB);
|
||||
} catch {
|
||||
usedToday = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連 backfill 都不做
|
||||
}
|
||||
const remainingQuota = Math.max(0, dailyCap - usedToday);
|
||||
|
||||
let processed = 0;
|
||||
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||
const candidates = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||
// 額度截斷:candidates 已按 created_at DESC 排序,取前 remainingQuota 筆=優先保留最新的。
|
||||
const embeddable = candidates.slice(0, remainingQuota);
|
||||
const quotaExceeded = candidates.length > embeddable.length;
|
||||
|
||||
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
||||
const texts = embeddable.map((e) => (e.content ?? '').trim());
|
||||
const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] };
|
||||
@@ -246,8 +397,18 @@ export async function backfillEmbeddings(
|
||||
await env.VECTORIZE.upsert(vectors);
|
||||
const ids = vectors.map((v) => v.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run();
|
||||
// content_hash 順手蓋成現行模型(世代戳記,見 reconcileEmbedGeneration)。
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`)
|
||||
.bind(embedModel(env), ...ids)
|
||||
.run();
|
||||
processed = vectors.length;
|
||||
try {
|
||||
await addBackfillUsage(env.DB, processed);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的嵌入(別讓 bookkeeping 故障吞掉已做的工);
|
||||
// 代價是下次呼叫可能少算一點用量——比「明明做了卻沒生效」安全(誠實限制,mindset §7)。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +420,16 @@ export async function backfillEmbeddings(
|
||||
// 非 reindex:predicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。
|
||||
// reindex:predicate 不含 is_embedded,COUNT 恆等於總數 → 改用 offset 分頁計 remaining(否則永不終止)。
|
||||
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
|
||||
return { enabled: true, processed, skipped: scanned - processed, remaining, scanned };
|
||||
return {
|
||||
enabled: true,
|
||||
processed,
|
||||
skipped: scanned - processed,
|
||||
remaining,
|
||||
scanned,
|
||||
quota_limit: dailyCap,
|
||||
quota_used_today: usedToday + processed,
|
||||
quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */
|
||||
@@ -283,6 +453,194 @@ export async function backfillStatus(
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
|
||||
export interface ReconcileResult {
|
||||
enabled: boolean;
|
||||
checked: number; // 本批「真的核對+寫回」的筆數(受下方 D1 額度截斷後的量)。
|
||||
confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。
|
||||
reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。
|
||||
remaining: number; // 本次之後仍待核對的筆數(不受額度影響,可重複呼叫直到 0)。
|
||||
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
|
||||
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與標庫 backfill 共用,見 maintenance-quota.ts)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有候選但今天不再寫 D1,等明天/調高上限)。
|
||||
}
|
||||
|
||||
/**
|
||||
* 世代核對(Generation reconciliation,D68 配套修復,2026-08-11)。
|
||||
*
|
||||
* 背景:`is_embedded=1` 只代表「曾經對某個 Vectorize index 嵌過」,不保證是**現行**的
|
||||
* index/模型(見檔頭 2026-08-03 換代註解:換模型必須換 index,舊向量收不進新 index、也刪不掉)。
|
||||
* 從備份整批灌回的資料尤其會帶著對**已退役索引**(例:768 維 `arcrun-kbdb-embed`)的
|
||||
* `is_embedded=1`——現行 backfill 的預設路徑(只補 `is_embedded=0`)永遠不會碰它們,
|
||||
* 語意搜尋對現行(1024 維 `arcrun-kbdb-embed-m3`)索引而言永遠搜不到那批東西,畫面不會說壞掉。
|
||||
*
|
||||
* 做法:不猜(`is_embedded` 本身此刻不可信),直接問現行 Vectorize index「這些 id 真的在你這嗎」
|
||||
* (`env.VECTORIZE.getByIds`,ground truth,而非比對 content_hash 字串本身——後者在這次修復
|
||||
* 之前從未被寫過,所有既有 is_embedded=1 的列 content_hash 皆為 NULL,無法只憑字串判斷「哪些是
|
||||
* 這次修復前的正常資料、哪些是真正的舊世代殘留」,必須問 Vectorize 本身):
|
||||
* - 真的在現行 index → 只是這次修復之前的正常資料,沒補寫過 content_hash。補標記,不重打 AI
|
||||
* (不浪費額度在已經正確的資料上)。
|
||||
* - 不在現行 index → 對現行 index 而言等於沒嵌過,重置 is_embedded=0、清空 content_hash,
|
||||
* 交回正常 backfill 佇列(下一輪照樣受「新到舊」排序+每日額度上限保護,不特別優待)。
|
||||
*
|
||||
* 不消耗 Workers AI 額度:零 AI.run,只有一次 D1 掃描 + 一次 Vectorize.getByIds + D1 寫回。
|
||||
*
|
||||
* D69(Arcrun#85,2026-08-11 leo 逐行複核找到的破口):**這一步雖不打 AI,但逐筆寫 D1**——
|
||||
* 每個候選最多消耗一次 row write(補標 content_hash 或重置 is_embedded,兩條路互斥、恰好一次),
|
||||
* 47 萬筆候選 ≈ 4.7 倍 D1 100,000 rows written/日免費額度。與標庫 backfill(同樣是多筆 D1
|
||||
* write、不打 AI)共用 `actions/maintenance-quota.ts` 的同一顆每日計數器——不共用的話,
|
||||
* 補標庫時會把這裡的閘繞過去(反之亦然)。額度用完 → 誠實截斷候選,不再寫 D1,等明天。
|
||||
*
|
||||
* 「挑哪一批」:owner_id/library/since/until 走 SelectionCriteria(同 backfillEmbeddings/
|
||||
* backfillEntryLibraryTags 共用的篩選形狀),讓時間分層/庫分層能從外面指定。
|
||||
*/
|
||||
export async function reconcileEmbedGeneration(
|
||||
env: Bindings,
|
||||
opts: Pick<SelectionCriteria, 'owner_id' | 'library' | 'since' | 'until'> & { limit?: number } = {},
|
||||
): Promise<ReconcileResult> {
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0,
|
||||
scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
const currentModel = embedModel(env);
|
||||
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [
|
||||
'is_embedded = 1',
|
||||
'(content_hash IS NULL OR content_hash != ?)',
|
||||
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
|
||||
...sel.conds,
|
||||
];
|
||||
const params: unknown[] = [currentModel, ...sel.params];
|
||||
const where = conds.join(' AND ');
|
||||
|
||||
const res = await env.DB
|
||||
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write,直接照剩餘額度砍候選清單長度。
|
||||
const budget = await maintenanceBudgetToday(env, env.DB);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
const checked = ids.length;
|
||||
|
||||
let confirmed_current = 0;
|
||||
let reset_to_pending = 0;
|
||||
if (ids.length > 0 && env.VECTORIZE) {
|
||||
const found = await env.VECTORIZE.getByIds(ids);
|
||||
const foundIds = new Set(found.map((v) => v.id));
|
||||
const presentIds = ids.filter((id) => foundIds.has(id));
|
||||
const missingIds = ids.filter((id) => !foundIds.has(id));
|
||||
|
||||
if (presentIds.length > 0) {
|
||||
const ph = presentIds.map(() => '?').join(',');
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`)
|
||||
.bind(currentModel, ...presentIds)
|
||||
.run();
|
||||
confirmed_current = presentIds.length;
|
||||
}
|
||||
if (missingIds.length > 0) {
|
||||
const ph = missingIds.map(() => '?').join(',');
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`)
|
||||
.bind(...missingIds)
|
||||
.run();
|
||||
reset_to_pending = missingIds.length;
|
||||
}
|
||||
}
|
||||
|
||||
const remRow = await env.DB
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
const written = confirmed_current + reset_to_pending;
|
||||
try {
|
||||
await addMaintenanceUsage(env.DB, written);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的核對寫入(精神同 backfillEmbeddings 的
|
||||
// addBackfillUsage 失敗處理——寧可下次呼叫少算一點用量,也不讓計數故障吞掉已做的工)。
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0,
|
||||
scanned, quota_limit: budget.limit, quota_used_today: budget.used + written, quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfTestResult {
|
||||
enabled: boolean; // embed 模組是否開(binding 都在)
|
||||
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
|
||||
passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測)
|
||||
note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
|
||||
}
|
||||
const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
const where = conds.join(' AND ');
|
||||
const row = await env.DB
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY updated_at DESC LIMIT 1`)
|
||||
.bind(...params)
|
||||
.first<Entry>();
|
||||
if (!row) {
|
||||
return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
|
||||
}
|
||||
const sample = (row.content ?? '').trim().slice(0, 200);
|
||||
if (!sample) {
|
||||
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' };
|
||||
}
|
||||
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
|
||||
let hits: SemanticHit[] | null;
|
||||
try {
|
||||
hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 });
|
||||
} catch (e) {
|
||||
if (e instanceof EmbedQueryFailedError) {
|
||||
// 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。
|
||||
return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (hits === null) {
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
|
||||
}
|
||||
const passed = hits.some((h) => h.id === row.id);
|
||||
return {
|
||||
enabled: true,
|
||||
tested: true,
|
||||
passed,
|
||||
note: passed
|
||||
? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的'
|
||||
: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)',
|
||||
};
|
||||
}
|
||||
|
||||
export interface SemanticHit {
|
||||
id: string;
|
||||
score: number;
|
||||
@@ -292,6 +650,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 +686,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, reconcileEmbedGeneration } from '../embed';
|
||||
|
||||
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -16,11 +16,14 @@ const OFF_HINT =
|
||||
'語義補嵌需先開 embed 模組(Vectorize+AI binding)。叫 CC「幫我開語義查詢」(設 kbdb_embed:true + redeploy 注入 binding)後再呼叫本端點。';
|
||||
|
||||
// POST /embed/backfill — batch-embed existing embeddable entries with is_embedded=0.
|
||||
// body(皆選填):{ limit?:1-100(預設25), owner_id?, source?, reindex?, offset? }。
|
||||
// body(皆選填):{ limit?:1-100(預設25), owner_id?, source?, library?, since?, until?, reindex?, offset? }。
|
||||
// 冪等:重跑不會重複嵌(已 is_embedded=1 的不再入選;upsert 同 id 冪等)。
|
||||
// 分批:單次最多 limit 筆;回傳 remaining>0 表示還有 → 重複呼叫直到 remaining=0。
|
||||
// reindex:true(Arcrun#11):改重推「所有 embeddable」既有向量(含 is_embedded=1),
|
||||
// 讓事後建立的 Vectorize metadata index 收錄它們(否則帶過濾語意查詢回 0);配 offset 分頁。
|
||||
// library/since/until(Arcrun#85,2026-08-11):「挑哪一批」從外面指定——時間分層
|
||||
// (今天/本週/半年前)與庫分層(有查詢紀錄的庫優先)共用同一套 SelectionCriteria,
|
||||
// 由呼叫端(工作流)決定這次要補的是哪一批,不是資料層焊死單一排序(見 embed.ts 檔頭說明)。
|
||||
// 模組未開 → 409 + capability_hint(不假綠)。
|
||||
embedRoutes.post('/backfill', async (c) => {
|
||||
if (!embedEnabled(c.env)) {
|
||||
@@ -33,6 +36,9 @@ embedRoutes.post('/backfill', async (c) => {
|
||||
limit?: number | string;
|
||||
owner_id?: string;
|
||||
source?: string;
|
||||
library?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
reindex?: boolean;
|
||||
offset?: number | string;
|
||||
};
|
||||
@@ -40,6 +46,9 @@ embedRoutes.post('/backfill', async (c) => {
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
owner_id: body.owner_id || undefined,
|
||||
source: body.source || undefined,
|
||||
library: body.library || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
// reindex(Arcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
|
||||
reindex: body.reindex === true,
|
||||
offset: body.offset !== undefined ? Number(body.offset) : undefined,
|
||||
@@ -56,4 +65,46 @@ embedRoutes.get('/backfill/status', async (c) => {
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// POST /embed/reconcile — 世代核對(D68 配套修復,2026-08-11;D69 額度節流同日補上):
|
||||
// 對「is_embedded=1 但 content_hash 非現行模型」的候選,問現行 Vectorize index 是否真的收錄;
|
||||
// 真的在 → 補標 content_hash(不打 AI);不在 → 重置 is_embedded=0,回到正常 /embed/backfill 佇列。
|
||||
// 解「從備份整批灌回、帶著對已退役索引的 is_embedded=1,永遠不被 backfill 碰到」這個坑。
|
||||
// body(皆選填):{ limit?:1-200(預設50), owner_id?, library?, since?, until? }。重複呼叫直到 remaining=0。
|
||||
// D69:每筆候選最多消耗一次 D1 row write,與 POST /entries/backfill-library 共用同一顆每日
|
||||
// 「背景維護 D1 寫入」額度(見 actions/maintenance-quota.ts)——額度用完會誠實回
|
||||
// quota_exceeded:true 並停手,不會把當天 D1 免費額度燒穿(2026-08-11 leo 逐行複核找到的破口)。
|
||||
embedRoutes.post('/reconcile', async (c) => {
|
||||
if (!embedEnabled(c.env)) {
|
||||
return c.json(
|
||||
{ success: false, error: 'embed module not enabled (need VECTORIZE + AI bindings)', capability_hint: OFF_HINT },
|
||||
409,
|
||||
);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
limit?: number | string;
|
||||
owner_id?: string;
|
||||
library?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
};
|
||||
const result = await reconcileEmbedGeneration(c.env, {
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
owner_id: body.owner_id || undefined,
|
||||
library: body.library || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
|
||||
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
|
||||
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
|
||||
// 本端點端到端驗證 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;
|
||||
|
||||
+234
-15
@@ -13,10 +13,29 @@ 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';
|
||||
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../actions/library-backfill';
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
// library 多值參數(逗號分隔,portal-auth P1,design §3.3)。空值/全空白 → undefined(=不過濾,
|
||||
// 行為與未帶參數一字不變——向後相容硬驗收)。
|
||||
function parseLibraryParam(raw: string | undefined): string[] | undefined {
|
||||
@@ -87,9 +106,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 +133,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 +179,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 +221,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 +269,65 @@ 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';
|
||||
let capability_hint: string;
|
||||
let admin_hint: string;
|
||||
if (hits.length === 0) {
|
||||
const status = await backfillStatus(c.env, { owner_id });
|
||||
if (status.embedded === 0 && status.pending > 0) {
|
||||
// 資料在、索引卻一筆都沒建=故障(寫入時嵌入沒成功過)。順手自癒:
|
||||
// 背景補嵌一批(冪等、分批),下次搜尋就有機會直接好——不叫使用者做任何事。
|
||||
empty_reason = 'no_index';
|
||||
capability_hint =
|
||||
'語意搜尋的索引出了狀況,所以暫時搜不到——這是我們系統的問題,不是你打的字有問題。系統正在自動重建,稍後再搜一次看看。';
|
||||
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 但 pending=${status.pending}:資料在、索引從沒建成=寫入端嵌入從未成功(故障)。本次已背景觸發 backfill 自癒(每批 100,冪等)。`;
|
||||
fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
|
||||
} else if (status.embedded === 0) {
|
||||
// 連「該被嵌的資料」都沒有=這個庫還沒有整理好的內容(新裝好還沒同步),不是故障。
|
||||
empty_reason = 'no_index';
|
||||
capability_hint =
|
||||
'這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。';
|
||||
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`;
|
||||
} else {
|
||||
empty_reason = 'no_match';
|
||||
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
|
||||
admin_hint = `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料,但本次查詢在 Vectorize 端零命中(含 embed.ts 絕對門檻過濾)。`;
|
||||
// 順手自癒:pending>0=有卡片在寫入時漏嵌(embedOnWrite 失敗是 fire-and-forget,
|
||||
// 沒有別的機制會回來補)。status 已經查了,不多花查詢,背景補一批。
|
||||
if (status.pending > 0) fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
|
||||
}
|
||||
} else {
|
||||
empty_reason = 'stale_index';
|
||||
capability_hint =
|
||||
'這次比對到的內容源頭已經被移除或下架了,所以沒有可顯示的結果。系統已自動清理過期索引(我們的問題,你不用做任何事),換個關鍵字就能正常搜。';
|
||||
admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。本次已背景觸發向量清理(deleteByIds)。`;
|
||||
}
|
||||
return c.json({
|
||||
success: true, entries, count: entries.length, mode: 'semantic',
|
||||
empty_reason, capability_hint, admin_hint,
|
||||
});
|
||||
}
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
|
||||
}
|
||||
|
||||
@@ -222,6 +368,64 @@ entryRoutes.patch('/deprecate-by-library', async (c) => {
|
||||
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
||||
});
|
||||
|
||||
// POST /entries/backfill-library — 標庫補存量(Arcrun#85 二次裁決/相關票 Arcrun#87,2026-08-11)。
|
||||
// body(必填 library + owner_id):{ library, owner_id, page_names?(string[],精準比對,
|
||||
// leo 定案的正解——見 actions/library-backfill.ts 檔頭「拿原稿遍歷」), entry_type?,
|
||||
// source_prefix?, page_name_prefix?(後兩者為過渡 fallback,精度不如 page_names),
|
||||
// since?, until?, limit?(1-500,預設100) }。
|
||||
// 冪等:只選「目前未標記 library」的候選;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
|
||||
// budget:與 /embed/reconcile 共用同一顆每日 D1 寫入額度(見 actions/maintenance-quota.ts)——
|
||||
// 兩者都是「多筆 D1 write、不打 AI」的背景維護操作,不共用額度的話補存量會把世代核對的閘繞過去。
|
||||
// base 對內容語意無知:不猜「這批該貼哪個庫」,呼叫端(ingest/#87)決定 library 與篩選條件;
|
||||
// owner_id 必填(同 /entries/deprecate-by-library 的既有防線——批次改一大片既有資料不准無租戶範圍地掃)。
|
||||
// 此路由必須在 '/:id' 之前註冊,否則 'backfill-library' 會被當成 id 參數。
|
||||
entryRoutes.post('/backfill-library', async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
library?: string;
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
page_names?: string[];
|
||||
source_prefix?: string;
|
||||
page_name_prefix?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
limit?: number | string;
|
||||
};
|
||||
const library = String(body.library ?? '').trim();
|
||||
const ownerId = String(body.owner_id ?? '').trim();
|
||||
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
|
||||
try {
|
||||
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
|
||||
library,
|
||||
owner_id: ownerId,
|
||||
entry_type: body.entry_type || undefined,
|
||||
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : undefined,
|
||||
source_prefix: body.source_prefix || undefined,
|
||||
page_name_prefix: body.page_name_prefix || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /entries/backfill-library/status?owner_id=&entry_type=&source_prefix=&page_name_prefix=&since=&until=
|
||||
// — 符合條件、目前未標記 library 的筆數(backfill 前後都能查,判斷還剩多少)。
|
||||
entryRoutes.get('/backfill-library/status', async (c) => {
|
||||
const status = await libraryBackfillStatus(c.env.DB, {
|
||||
owner_id: c.req.query('owner_id') || undefined,
|
||||
entry_type: c.req.query('entry_type') || undefined,
|
||||
source_prefix: c.req.query('source_prefix') || undefined,
|
||||
page_name_prefix: c.req.query('page_name_prefix') || undefined,
|
||||
since: c.req.query('since') ? Number(c.req.query('since')) : undefined,
|
||||
until: c.req.query('until') ? Number(c.req.query('until')) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
entryRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
@@ -235,11 +439,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 });
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
|
||||
import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../actions/library-backfill';
|
||||
|
||||
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -57,6 +58,54 @@ recordRoutes.get('/by-template/:template', async (c) => {
|
||||
return c.json({ success: true, records, count: records.length });
|
||||
});
|
||||
|
||||
// POST /records/backfill-library — 三元組版標庫補存量(Arcrun#87 二次收尾,2026-08-13)。
|
||||
// entries 版(POST /entries/backfill-library,Arcrun#85)補的是卡片層 metadata_json.library;
|
||||
// 藏書地圖讀的是三元組 record 自己的 'library' slot,兩者是不同存放處——本端點補後者,
|
||||
// 地圖(GET /map)才會真的從 0 變成有意義的數字。
|
||||
// body(必填 library + owner_id):{ library, owner_id, triplet_template?(預設 'triplet'),
|
||||
// source_prefix?(必要篩選:本票的規則=gitea:Leo/<repo>@ 前綴 → 對應庫;kb:// → 'kb'),
|
||||
// limit?(1-500,預設100) }。
|
||||
// 冪等:只選「目前沒有 library slot 值」的三元組;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
|
||||
// budget:與 /entries/backfill-library、/embed/reconcile 共用同一顆每日 D1 寫入額度(D69)。
|
||||
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
|
||||
recordRoutes.post('/backfill-library', async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
library?: string;
|
||||
owner_id?: string;
|
||||
triplet_template?: string;
|
||||
source_prefix?: string;
|
||||
limit?: number | string;
|
||||
};
|
||||
const library = String(body.library ?? '').trim();
|
||||
const ownerId = String(body.owner_id ?? '').trim();
|
||||
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
|
||||
try {
|
||||
const result = await backfillTripletLibraryTags(c.env.DB, c.env, {
|
||||
library,
|
||||
owner_id: ownerId,
|
||||
triplet_template: body.triplet_template || undefined,
|
||||
source_prefix: body.source_prefix || undefined,
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /records/backfill-library/status?owner_id=&triplet_template=&source_prefix=
|
||||
// — 符合條件、目前未標記 library 的三元組筆數(backfill 前後都能查,判斷還剩多少)。
|
||||
recordRoutes.get('/backfill-library/status', async (c) => {
|
||||
const ownerId = c.req.query('owner_id') || '';
|
||||
if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400);
|
||||
const status = await tripletLibraryBackfillStatus(c.env.DB, {
|
||||
owner_id: ownerId,
|
||||
triplet_template: c.req.query('triplet_template') || undefined,
|
||||
source_prefix: c.req.query('source_prefix') || undefined,
|
||||
});
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// GET /records/:recordId
|
||||
recordRoutes.get('/:recordId', async (c) => {
|
||||
const rec = await getRecord(c.env.DB, c.req.param('recordId'));
|
||||
|
||||
+15
-1
@@ -24,6 +24,18 @@ export type Bindings = {
|
||||
// kbdb/src/actions/execution-log.ts DEFAULT_DAILY_LIMIT 說明)。未設 → 20000
|
||||
// (D1 100,000 rows written/日的 20%,留 80% 給知識卡 entries)。
|
||||
EXECUTION_LOG_DAILY_WRITE_LIMIT?: string;
|
||||
// embed backfill 每日軟上限(D68,2026-08-11:補算向量照時間新到舊、且每天有額度上限)。
|
||||
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日 10,000 免費 neurons(見頂層
|
||||
// wiki ops-facts.md);backfill 是背景低優先動作,自設軟上限不把當天額度燒光。未設 → 見
|
||||
// kbdb/src/embed.ts DEFAULT_BACKFILL_DAILY_LIMIT 說明(含選值算式,非拍腦袋)。
|
||||
EMBED_BACKFILL_DAILY_LIMIT?: string;
|
||||
// 背景維護寫入(reconcile 世代核對 + 標庫 backfill)共用的 D1 每日寫入軟上限
|
||||
// (Arcrun#85 D69 修法,2026-08-11:兩者都是「多筆 D1 row write、不打 AI」的操作,
|
||||
// 各自不設防都會單獨燒穿 D1 100,000 rows/日免費額度——reconcile 47 萬筆 candidate
|
||||
// ≈ 4.7 倍全日額度,已在票上實測;標庫 backfill 同樣是逐筆 D1 write,若各管各的,
|
||||
// 補標庫時會把 reconcile 的閘繞過去。兩者共用同一顆「今天還剩多少」計數器。
|
||||
// 未設 → 見 kbdb/src/actions/maintenance-quota.ts DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT。
|
||||
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT?: string;
|
||||
};
|
||||
|
||||
export type EntryType =
|
||||
@@ -35,7 +47,9 @@ export type EntryType =
|
||||
| 'workflow'
|
||||
| 'recipe_stat'
|
||||
| 'execution_log'
|
||||
| 'execution_log_usage';
|
||||
| 'execution_log_usage'
|
||||
| 'embed_backfill_usage'
|
||||
| 'kbdb_maintenance_usage';
|
||||
|
||||
export interface Entry {
|
||||
id: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user