Compare commits
150 Commits
5783420fcf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a98f53e7 | |||
| cacaa33f7d | |||
| fda9d169d5 | |||
| 7b478378aa | |||
| ceb7638d74 | |||
| 9fa88065ab | |||
| 969acff325 | |||
| 87f6de7c9f | |||
| 576afa2364 | |||
| 730c2af245 | |||
| 9ba6ba75fc | |||
| aa49b4eeb2 | |||
| 5b22d569c9 | |||
| 30c9312c57 | |||
| 1616517ace | |||
| 7dc3e615e9 | |||
| ccd531863a | |||
| 537b520143 | |||
| 45169ab25e | |||
| 5dd1d1e7a9 | |||
| b8a5201e8a | |||
| 340b461f58 | |||
| 5318398849 | |||
| 9609924818 | |||
| 043e9acc31 | |||
| cfb759a774 | |||
| 73bdb90780 | |||
| 9ad95ee3a6 | |||
| 68f042cfd0 | |||
| 5d441aa7d3 | |||
| 43323b1a78 | |||
| 2b807da324 | |||
| 3f2e45f5dc | |||
| c543abaa8a | |||
| 49248b18c5 | |||
| 05e879faa7 | |||
| 0b72b818a0 | |||
| 7498911856 | |||
| 94d5452425 | |||
| 4b6cc159b8 | |||
| 8286c8afec | |||
| ca1ed2aaf6 | |||
| 88f308642e | |||
| c3856470ad | |||
| 1ccee0055e | |||
| 302e024804 | |||
| c2897ba68b | |||
| 614fe44812 | |||
| 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 | |||
| 7ba78552a4 | |||
| 60688c3108 | |||
| 36a5630c63 | |||
| 05b215c9f7 | |||
| 0ff369f818 | |||
| fe0ee8296a | |||
| b9b5d98d75 | |||
| 3d21bf0725 | |||
| f3af5d3631 |
@@ -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
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,713 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-15T15:53:28.612Z",
|
||||
"repo_head": "cacaa33f7d4e51d0011096250a33bb5107c67074",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "9fa88065abdc5a795d3847711f04101480621af0",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 590499,
|
||||
"content_sha256": "94a21ad012ee0c48d78e9a74752048917ddb4616f1ea089976834acd887a9d14",
|
||||
"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": "ceb7638d746f0e8847552aef721f6f53ef87ff0b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 160306,
|
||||
"content_sha256": "47b09d60f8d122f7ec234d146a99bc68b2661c56ad6a98eca146d99ec5de03ea",
|
||||
"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-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "1ccee0055ebdb1f6d74094df3e807f56b849fa6f",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1192612,
|
||||
"content_sha256": "9bc8dffb34d0f36608a78b2cfeb8ee8b5c793f52b4497f5d4d5f69f70700972e",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-array-ops",
|
||||
"source_dir": ".component-builds/array_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-array-ops/worker.mjs",
|
||||
"js_bytes": 67802,
|
||||
"content_sha256": "35ad5be6332dde2299efd10be726300c6cace1eb085ab24f08173536ca7fb950",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-array-ops/component.wasm",
|
||||
"sha256": "74dc9d99f8b9ef43628b55c85c05beb1af8f6f90d78d07cd132ffe4b865147ae"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "array_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-oauth2",
|
||||
"source_dir": ".component-builds/auth_oauth2",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-oauth2/worker.mjs",
|
||||
"js_bytes": 80472,
|
||||
"content_sha256": "a9b0d45ee6a63e0eebd45554df16d9fa4e6e717e0c31dac13b630e132f1a96db",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-oauth2/component.wasm",
|
||||
"sha256": "3622c20644e6c8c39487109e7353e2590bad90b1207139e34dfb0cf1c2696664"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_oauth2"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-service-account",
|
||||
"source_dir": ".component-builds/auth_service_account",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-service-account/worker.mjs",
|
||||
"js_bytes": 81693,
|
||||
"content_sha256": "68675ec76f3f060309492e55301936a47f4ae0a5e9b00865eb861e0e631e40e5",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-service-account/component.wasm",
|
||||
"sha256": "7d0808b4b9a2df4fbb54977eafe34a5bdb99e0cc941d97462c62e26f3651951e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_service_account"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-static-key",
|
||||
"source_dir": ".component-builds/auth_static_key",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-static-key/worker.mjs",
|
||||
"js_bytes": 81217,
|
||||
"content_sha256": "140c163d2e8c609eebf281de25d16e521d85004f8437cc842613c249e44aa7ff",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-static-key/component.wasm",
|
||||
"sha256": "cd0f69a80ced0979749e0ec30b1981795a49eaa56bdb2ba102cd4b64f4670369"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_static_key"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-cron",
|
||||
"source_dir": ".component-builds/cron",
|
||||
"source_commit": "6e92ca03727bf22143499d6c0df06b40cb65c17b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cron/worker.mjs",
|
||||
"js_bytes": 78911,
|
||||
"content_sha256": "17068f03f686aee6c0aec9d7d748bbb1998edc8918260033f985697bc718aa76",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-cron/component.wasm",
|
||||
"sha256": "afb3a14f8c8f00f4f5573f44153cb1dcf6fc715b2fb47c2afb0aaf95b74d3cde"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "cron"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-date-ops",
|
||||
"source_dir": ".component-builds/date_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-date-ops/worker.mjs",
|
||||
"js_bytes": 67781,
|
||||
"content_sha256": "60ebc1d17ab0b9d3bb66f1c71ec63eb5d35bc97ed9f8b4cd1c1c95f4eeb171b7",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-date-ops/component.wasm",
|
||||
"sha256": "4e2c1329ce4014aa86a9a6449d8699e67c6c616f5c07088dc21216d4036ca41e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "date_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-filter",
|
||||
"source_dir": ".component-builds/filter",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-filter/worker.mjs",
|
||||
"js_bytes": 67739,
|
||||
"content_sha256": "c7fd5070ff7c0f0341e9dd364805e393f9d68056a846f48e6bc61ef6f4e67187",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-filter/component.wasm",
|
||||
"sha256": "6a0692b386522918b3b5af79cf7877394d1f02c350018dbaef68ef380dad1b9e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "filter"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-foreach-control",
|
||||
"source_dir": ".component-builds/foreach_control",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-foreach-control/worker.mjs",
|
||||
"js_bytes": 67928,
|
||||
"content_sha256": "e52410c9611a93ac7fe38c7b11291cc18711c7da3370492788c3c55bc0fb03d5",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-foreach-control/component.wasm",
|
||||
"sha256": "8ae161ab37cc78ef6fae65364a47f7accb572378b395c4637daa2ffa57f14632"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "foreach_control"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-http-request",
|
||||
"source_dir": ".component-builds/http_request",
|
||||
"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-if-control",
|
||||
"source_dir": ".component-builds/if_control",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-if-control/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "557ecf4ee955105af07b2227b08365eb9de8c39ad8ffb323b101397704f438bd",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-if-control/component.wasm",
|
||||
"sha256": "87c81e9cf5e664f60c06a364508dd365d49953e1b7afba6a5753fce5a4c82636"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "if_control"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-merge",
|
||||
"source_dir": ".component-builds/merge",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-merge/worker.mjs",
|
||||
"js_bytes": 67718,
|
||||
"content_sha256": "e4216249436000bbb4db0318cc9028f936ca6537d3d656228cfdd3cb31df25f8",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-merge/component.wasm",
|
||||
"sha256": "55a4d143246df172f60b5e15e7b26e55279deda9db436e3d65bccc25c86d7cd9"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "merge"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-number-ops",
|
||||
"source_dir": ".component-builds/number_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-number-ops/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "91821a291117e033bfa4f013cd88de09479cd4ac43708e37a39df3f31c72aaa6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-number-ops/component.wasm",
|
||||
"sha256": "452d1940f6e2b2b36dc97c145abcb7b5bacdbfa7631c742827c2f6f6db87b4ea"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "number_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-set",
|
||||
"source_dir": ".component-builds/set",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-set/worker.mjs",
|
||||
"js_bytes": 67676,
|
||||
"content_sha256": "6cac587802cae7dc1651d6b461232abafe40dcf0fbfe9e0adc5b1d8e35b41cc1",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-set/component.wasm",
|
||||
"sha256": "f5afcf6e57401d2d83b21fb971ec80ca56d5e8e4bdb74a004f3d118875ed57c4"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "set"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-string-ops",
|
||||
"source_dir": ".component-builds/string_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-string-ops/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "c85166e357b76828c7020426d912b591006152ac38f024a095a4d9dcd10dee72",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-string-ops/component.wasm",
|
||||
"sha256": "41f3243ee18bb9f4fcbaa417bd773347128dec2971132e240eddec19ba6a9da2"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "string_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-switch",
|
||||
"source_dir": ".component-builds/switch",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-switch/worker.mjs",
|
||||
"js_bytes": 67739,
|
||||
"content_sha256": "e0e7689dbba735a254556272d6d233396c7fa05cc0fba5835a3e20b0ddf00fca",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-switch/component.wasm",
|
||||
"sha256": "6d993dc9b1affe16b600aaa7cae5af977108cc08513bb11d9d9db908285fea8b"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "switch"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-try-catch",
|
||||
"source_dir": ".component-builds/try_catch",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-try-catch/worker.mjs",
|
||||
"js_bytes": 67802,
|
||||
"content_sha256": "a5fbcbf452ab00269dc857997df3059bee7eb1f2c1b322d249146ab4e5e5bac9",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-try-catch/component.wasm",
|
||||
"sha256": "10eaddc5ef0d3dc68e3f009970541ac7f5fce30dcdbecec9a333afd5a29e3486"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "try_catch"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-validate-json",
|
||||
"source_dir": ".component-builds/validate_json",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-validate-json/worker.mjs",
|
||||
"js_bytes": 67886,
|
||||
"content_sha256": "2d0efa26e9fe38db87eb9585daf72e0f2ba8c859d15d1b65f36798656cc4e83b",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-validate-json/component.wasm",
|
||||
"sha256": "8142ffd3cc57efbb9128faaf450b646111b0f2e2575cc29a7b562a7b185912ab"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "validate_json"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-wait",
|
||||
"source_dir": ".component-builds/wait",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-wait/worker.mjs",
|
||||
"js_bytes": 67697,
|
||||
"content_sha256": "3d1b46e7b1ff056abb2c1aeed738d23d58eaa7c8f36b674e4ea2ed1602015f9e",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-wait/component.wasm",
|
||||
"sha256": "52ca2d184bf3dcc646bb94f29f7f3e69b5540276363e83a54cb435ff33ca2f3c"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "wait"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-rag-ui",
|
||||
"source_dir": "console-ui/public",
|
||||
"source_commit": "614fe448124dbd25d8228e37723473f1704f3a7a",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-rag-ui/worker.mjs",
|
||||
"js_bytes": 524513,
|
||||
"content_sha256": "5825f427e4211dd4b0eee969c94c8bfa856f65b8a48fc718e137f3b5d074e1ff",
|
||||
"modules": [],
|
||||
"compat_date": "2026-07-01",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"ui_fingerprint": "82bc636990e60172663f7ce90c75d8b434193b8532b21f1d4b57c5fb330cbe11",
|
||||
"ui_file_count": 9,
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"excluded": [
|
||||
{
|
||||
"name": "arcrun-claude-api",
|
||||
"dir": ".component-builds/claude_api",
|
||||
"reason": "component.wasm 不在版控(.gitignore 明文排除=這顆不是可出貨的零件)"
|
||||
}
|
||||
],
|
||||
"notes": []
|
||||
}
|
||||
@@ -65,15 +65,26 @@
|
||||
|
||||
## 🔴 第一鐵律:wiki 是判準,不准跳過(2026-07-20/21 leo 兩度點破)
|
||||
|
||||
**要查任何東西之前,先搜尋 wiki——用 grep,不是只讀開頭幾行。**
|
||||
**要查任何東西之前,先照索引走:`system-dev/wiki/INDEX.md` → 下層 `00-INDEX.md` → 卡片。**
|
||||
|
||||
🔴 **2026-08-15 leo 糾正:本條原本寫的是「用 grep」,那是錯的。**
|
||||
grep 是平面搜尋,它繞過索引,於是**索引永遠不會被驗證、也永遠不會變準**——
|
||||
而 AI 又用「索引可能不準」當理由繼續 grep。這是自我實現的。
|
||||
實測那天:`matrix/arcrun` 的 wiki 有 64 張卡,**只有 1 張走得到索引**
|
||||
(唯一那張是 leo 手寫的;63 張機器產的卡從沒加入任何索引),
|
||||
而 AI 從沒發現——**因為 grep 找得到,破損就永遠不會浮現。**
|
||||
|
||||
**grep 不是禁令,是異常訊號**:
|
||||
1. 先照索引走
|
||||
2. 索引走不到 → **那是索引壞了,先講出來**(哪一層缺、缺什麼)
|
||||
3. 講完才准 fallback 用 `grep -rin "<關鍵字>" system-dev/wiki/`
|
||||
|
||||
⇒ 每一次 grep 都該留下一筆「索引缺陷」,而不是變成習慣。
|
||||
(全面禁止 grep 也是錯的——索引壞掉那天 AI 會瞎掉,而且沒人知道。)
|
||||
|
||||
> leo:「花很多力氣去產生 wiki,最重要的就是要可以查詢,**結果要查的時候就跳過,那就白寫了**。」
|
||||
> 「重點是你自己的記憶對嗎?而你有按照規定去切實讀 wiki 嗎?」
|
||||
|
||||
```bash
|
||||
grep -rin "<本題關鍵字>" system-dev/wiki/
|
||||
```
|
||||
|
||||
**三條硬規則**:
|
||||
1. **wiki 與程式碼/歷史文件衝突 → 以 wiki 為準**。程式碼反映「還沒清乾淨」,不等於「還在用」。
|
||||
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。那是當時狀態,不是永久禁令。
|
||||
|
||||
@@ -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
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
|
||||
+647
-80
@@ -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,16 +298,78 @@ 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;
|
||||
}
|
||||
|
||||
/** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */
|
||||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed';
|
||||
/**
|
||||
* 這把 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vectorize index 名(kbdb embed 模組用)。**bge-m3 = 1024 維、cosine。**
|
||||
*
|
||||
* 🔴 2026-08-03 換代(leo 拍板;5 組中文測資實證:舊 `bge-base-en-v1.5` 排序 2/5、
|
||||
* margin −0.0413=**中文根本不能用**;`bge-m3` 5/5、+0.1410、959ms)。
|
||||
* leo 08-05:「換 embed model 當然要合併,當然要換 vectorize,原本的根本不能用」。
|
||||
*
|
||||
* **換模型必須換 index,且必須換「名字」**:
|
||||
* ① 維度 768→1024,舊 index 收不進新向量
|
||||
* ② 就算維度相同也不能沿用——不同模型的向量混在同一 index,比對出來是垃圾;
|
||||
* 而 #58(Vectorize vector delete 未接)代表舊向量刪不掉
|
||||
* ⇒ **開新名字的 index 反而順手繞開 #58**,且新舊並存可回滾。
|
||||
*
|
||||
* ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用(deploy.ts:426)
|
||||
* ⇒ t36 的四個 metadata index(owner_id/entry_type/source/library,Arcrun#11 根因修復)
|
||||
* 會自動建在新 index 上,**不會因為改名而遺失**(已查證,非假設)。
|
||||
*
|
||||
* 既有實例遷移:部署後 `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0,
|
||||
* 確認語意查詢正常後,舊的 `arcrun-kbdb-embed` 可自行刪除。
|
||||
*/
|
||||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3';
|
||||
|
||||
export interface DeployResult {
|
||||
implemented: boolean;
|
||||
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)。*/
|
||||
@@ -200,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,
|
||||
@@ -243,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 絕對路徑)。
|
||||
@@ -277,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) {
|
||||
@@ -317,20 +677,65 @@ export async function downloadAndDeploy(
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql(${migPath})`);
|
||||
}
|
||||
|
||||
// 3.6 credentials 目錄表(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at)。
|
||||
// 現行 credential 規範見 .claude/rules/01-tech-stack.md「Credential 儲存規範」。
|
||||
// 同一顆 D1(與 KBDB base 共用),冪等 IF NOT EXISTS,套用機制與 0001_base.sql 完全相同
|
||||
// (同一個 applyD1Migration helper,同一支 CF D1 query API)。D19:這張表不含密文,
|
||||
// 密文本體住在 Workers per-script Secrets(見 cypher-executor/src/routes/credentials.ts)。
|
||||
const credMigPath = join(root, 'kbdb', 'migrations', '0002_credentials.sql');
|
||||
if (existsSync(credMigPath)) {
|
||||
// 3.6 credential template seed(D38 圍牆修復,總管交辦,2026-08-07):credential 目錄改走
|
||||
// KBDB template 機制(entries 表 entry_type='credential',比照 recipe_stat/execution_log
|
||||
// 慣例),取代舊的獨立 credentials 表(0002,已退役,見該檔頭部說明)。冪等,套用機制
|
||||
// 與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets(見
|
||||
// cypher-executor/src/routes/credentials.ts),D19「擁有目錄不擁有內容物」不變。
|
||||
const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
|
||||
if (existsSync(credTplMigPath)) {
|
||||
try {
|
||||
await applyD1Migration(ctx, readFileSync(credMigPath, 'utf8'));
|
||||
await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0002_credentials (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0002_credentials.sql(${credMigPath})`);
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql(${credTplMigPath})`);
|
||||
}
|
||||
|
||||
// 3.6b 退役舊 credentials 表(D38,2026-08-07):把該表殘留資料(若有)搬進 entries 後
|
||||
// 拆表,讓 KBDB 回到「只有三張核心表」的狀態。冪等且對「從未跑過 0002」的全新實例
|
||||
// 無害(表不存在時本檔第一步先補空殼再立刻拆掉,詳見檔頭)。每次部署都會重跑,
|
||||
// 但真資料只搬一次(NOT EXISTS 判斷防重複)。
|
||||
const dropCredMigPath = join(root, 'kbdb', 'migrations', '0006_drop_credentials_table.sql');
|
||||
if (existsSync(dropCredMigPath)) {
|
||||
try {
|
||||
await applyD1Migration(ctx, readFileSync(dropCredMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0006_drop_credentials_table (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql(${dropCredMigPath})`);
|
||||
}
|
||||
|
||||
// 3.7 execution_log template seed(KV 額度事故修復,2026-08-07):workflow 執行紀錄改走
|
||||
// KBDB template 機制(entries 表 entry_type='execution_log',比照 recipe_stat 慣例;
|
||||
// schema 零異動,只 seed 一列 template 定義,同 0001_base.sql §3 手法,self-hosted 同步套用)。
|
||||
const execLogMigPath = join(root, 'kbdb', 'migrations', '0004_execution_log_template.sql');
|
||||
if (existsSync(execLogMigPath)) {
|
||||
try {
|
||||
await applyD1Migration(ctx, readFileSync(execLogMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0004_execution_log_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql(${execLogMigPath})`);
|
||||
}
|
||||
|
||||
// 3.8 樹狀 record 模型(0007,v7 定稿 2026-08-15):record 有身分、關係是唯一機制、
|
||||
// entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列
|
||||
// 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句
|
||||
// ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時
|
||||
// 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。
|
||||
const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql');
|
||||
if (existsSync(treeMigPath)) {
|
||||
try {
|
||||
await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql(${treeMigPath})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +767,35 @@ export async function downloadAndDeploy(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐句套 migration,容錯 duplicate column(0007 專用)。
|
||||
*
|
||||
* 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止——
|
||||
* 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate column(SQLite 沒有欄位級
|
||||
* IF NOT EXISTS),整檔送 ⇒ 第二次部署起 migration 永遠假紅、後面的資料搬遷
|
||||
* 語句永遠不被執行。逐句+把 duplicate column 視為「已套用」,其餘錯誤照樣拋。
|
||||
* 切句手法與安裝器 compile-migrations.mjs 同款(剝 -- 註解、依分號切;
|
||||
* 0007 的字串常值不含分號,前提成立)。
|
||||
*/
|
||||
async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise<void> {
|
||||
const statements = sql
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/--.*$/, ''))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await applyD1Migration(ctx, stmt);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
|
||||
throw new Error(`${stmt.slice(0, 60)}… → ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。
|
||||
* 用 init 已驗的 ctx.apiToken + accountId;query 端點接受多語句檔,一次送整份 0001_base.sql。
|
||||
@@ -386,33 +820,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=768/metric=cosine,對齊 bge-base-en-v1.5)。
|
||||
* 冪等:已存在(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: 768, metric: 'cosine' },
|
||||
description: 'arcrun KBDB optional embed module (issue #7)',
|
||||
}),
|
||||
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;
|
||||
|
||||
@@ -421,9 +828,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',
|
||||
@@ -449,11 +859,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 假綠根因)。
|
||||
@@ -546,21 +958,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(
|
||||
@@ -579,14 +1085,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] 的不動。
|
||||
@@ -618,7 +1116,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,289 @@
|
||||
// @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';
|
||||
|
||||
/**
|
||||
* 清單端點每頁抓幾筆。100 是 CF 這幾支端點通用的安全上限(KV 官方上限就是 100)。
|
||||
* 這個數字**不影響正確性**——`cfListAll` 會一直翻到底;它只決定要打幾次 API。
|
||||
*/
|
||||
const LIST_PER_PAGE = 100;
|
||||
|
||||
/**
|
||||
* 翻頁的安全上限。100 頁 × 100 筆 = 10,000 顆,遠超 CF 的帳號上限
|
||||
* (KV namespace 每帳號 1,000)⇒ 正常帳號永遠碰不到。
|
||||
* 碰到了就是 CF 那邊的行為變了,這種時候**寧可 throw 也不回一份不完整的清單**。
|
||||
*/
|
||||
const LIST_MAX_PAGES = 100;
|
||||
|
||||
/**
|
||||
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
|
||||
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
|
||||
* @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 不存在」和「其他錯誤」時用)。
|
||||
* `resultInfo` = CF 回應裡的 `result_info`(不分頁的端點是 `null`),`cfListAll` 靠它翻頁。
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: 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, resultInfo: data?.result_info ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一支「列出帳號上有什麼」的端點**翻到底**,回傳全部項目。
|
||||
*
|
||||
* 【為什麼非翻不可——這是 Arcrun#123 的續集,不是效能優化】
|
||||
* 三支清單方法原本只打 `?per_page=100`,也就是**只看第一頁**。同一個截斷,
|
||||
* 在 #123 的修法前後,後果**不一樣**:
|
||||
*
|
||||
* | 被截掉的那顆 | 規則走到哪 | 結果 |
|
||||
* |---|---|---|
|
||||
* | #123 修好**前**:worker 綁著它,但它落在第二頁 | 2b 判「綁著的資源不見了」 | 產生 blocker,**停手**(過度保守,但安全) |
|
||||
* | #123 修好**後**:名字落在第二頁 | 2c 判「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** |
|
||||
*
|
||||
* ⇒ 修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。所以規約是:
|
||||
* **看不完整就不准當作看完了**——翻不完、或翻出來的數量對不上 CF 自己回報的
|
||||
* `total_count`,一律 throw,讓 `planResources` 把它變成 blocker
|
||||
* (README 規則第 3 條:說不準就整趟停手,一顆都不建)。
|
||||
*
|
||||
* 【三支端點的分頁行為不一樣,這裡刻意不假設它們同款】(2026-08-14 在 geek6688 帳號實測)
|
||||
* - `/storage/kv/namespaces`:真分頁,`result_info` = `{page, per_page, count, total_count, total_pages}`
|
||||
* - `/d1/database`:真分頁,但 `result_info` **沒有 `total_pages`**(實測 `{page, per_page, count, total_count}`)
|
||||
* ⇒ **不准拿 `total_pages` 當終止條件**,那個欄位在 D1 上是 `undefined`
|
||||
* - `/vectorize/v2/indexes`:**不分頁**,`result_info` 是 `null`,帶 `page`/`per_page` 也被忽略(一次回全部)
|
||||
*
|
||||
* 所以終止條件只用「三支都有、或三支都沒有」的兩件事:`result_info` 在不在、`total_count` 對不對得上。
|
||||
* 對不分頁的那支,這支等於只打一次就回來(那兩個被忽略的參數實測無害);
|
||||
* 而萬一 CF 哪天替它補上分頁,這支會自己跟著翻——不必等下一次災情才想起來改。
|
||||
*
|
||||
* @param {string} path 不含分頁參數的端點路徑(可自帶其他 query)
|
||||
* @param {string} what 出錯訊息裡怎麼稱呼它
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
async function cfListAll(path, what) {
|
||||
/** @type {any[]} */
|
||||
const items = [];
|
||||
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
|
||||
const sep = path.includes('?') ? '&' : '?';
|
||||
const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`列 ${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`);
|
||||
}
|
||||
const batch = Array.isArray(res.result) ? res.result : [];
|
||||
items.push(...batch);
|
||||
|
||||
const info = res.resultInfo;
|
||||
// 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。
|
||||
if (!info) return items;
|
||||
|
||||
const total = Number(info.total_count);
|
||||
if (Number.isFinite(total)) {
|
||||
if (items.length >= total) return items;
|
||||
// CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。**
|
||||
if (batch.length === 0) {
|
||||
throw new Error(
|
||||
`列 ${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` +
|
||||
`看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`,
|
||||
);
|
||||
}
|
||||
continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位)
|
||||
}
|
||||
|
||||
// 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。
|
||||
if (batch.length < LIST_PER_PAGE) return items;
|
||||
}
|
||||
throw new Error(
|
||||
`列 ${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` +
|
||||
`這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cfRaw,
|
||||
|
||||
/**
|
||||
* 讀一顆已部署 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}>} */
|
||||
// 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll)
|
||||
const result = await cfListAll('/storage/kv/namespaces', 'KV namespace');
|
||||
const map = new Map();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
},
|
||||
|
||||
/** @returns {Promise<Map<string, string>>} name → uuid */
|
||||
async listD1Databases() {
|
||||
/** @type {Array<{uuid: string, name: string}>} */
|
||||
// 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。
|
||||
const result = await cfListAll('/d1/database', 'D1 資料庫');
|
||||
const map = new Map();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
},
|
||||
|
||||
/** @returns {Promise<string[]>} */
|
||||
async listVectorizeIndexes() {
|
||||
/** @type {Array<{name: string}>} */
|
||||
// 這支端點**目前不分頁**(`result_info` 是 null),走 cfListAll 等同只打一次;
|
||||
// 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。
|
||||
const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index');
|
||||
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,649 @@
|
||||
// @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
|
||||
* @property {boolean} [createNameIsOurs]
|
||||
* 呼叫端在此**聲明**:`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
|
||||
* ⇒ 帳號上若已經有一顆**恰好同名**的資源,它只可能是我們上一次沒裝完留下的(Arcrun#123)。
|
||||
*
|
||||
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 false(fail-closed)。
|
||||
* 只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
|
||||
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
|
||||
* (slug = `SHA-256('arcrun-rag:' + email)` 取前 8 碼,同一個 email 每次算出同一組名字,
|
||||
* 別人算不到、也不會不小心撞上)。
|
||||
*
|
||||
* ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
|
||||
* createName 是裸 binding 名(`WEBHOOKS`)或 toml 宣告的庫名(`arcrun-kbdb`)——
|
||||
* 那種名字使用者自己也可能拿去用,**證明不了是我們的**,所以那條路一律不聲明。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} PlannedAdopt
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {string} from 從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
|
||||
* **沒有任何 worker 綁著它正是接收它的前提**(Arcrun#123),不是漏填。
|
||||
* @property {boolean} [reclaimed]
|
||||
* true = 這顆不是從某顆 worker 的綁定讀出來的,而是「帳號上已經有一顆我們自己命名的同名資源、
|
||||
* 卻沒有人綁著」⇒ 上一次沒裝完留下的,這次把它接回來用(Arcrun#123)。
|
||||
* 給呼叫端做診斷/統計用;**使用者不必知道「殘骸」這個詞**,對外一律講「沿用你原本的資源」。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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
|
||||
* 🔴 接回上次沒裝完留下的那顆(`reclaimed`)**仍然算 `adopted`**,不另開第三種值——
|
||||
* 它本來就是「沿用既有資源」,而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
|
||||
* (安裝器那句「沿用你原本的 N 項資源」)不會因為多一種值就悄悄漏數。
|
||||
* @property {string} [from]
|
||||
* @property {boolean} [reclaimed] 見 PlannedAdopt.reclaimed(Arcrun#123)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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]);
|
||||
}
|
||||
|
||||
// 帳號上現有的資源,一種只查一次。**名字 → 身分**(KV/D1 是 id,Vectorize 的身分就是名字)。
|
||||
//
|
||||
// 為什麼連名字都收下來(本來只留 `.values()`):
|
||||
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
|
||||
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
|
||||
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
|
||||
/** @type {Map<ResourceKind, Map<string, string>>} */
|
||||
const existingCache = new Map();
|
||||
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
|
||||
const listExistingByName = async (kind) => {
|
||||
const hit = existingCache.get(kind);
|
||||
if (hit) return hit;
|
||||
/** @type {Map<string, string>} */
|
||||
let map;
|
||||
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
|
||||
else if (kind === 'd1') map = await api.listD1Databases();
|
||||
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
|
||||
existingCache.set(kind, map);
|
||||
return map;
|
||||
};
|
||||
|
||||
for (const [, reqs] of byKey) {
|
||||
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 {Map<string, string>} */
|
||||
let existing;
|
||||
try {
|
||||
existing = await listExistingByName(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||
`(${msg(e)})。不確定就不動——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (![...existing.values()].includes(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、全新帳號,
|
||||
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
|
||||
//
|
||||
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
|
||||
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
|
||||
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
|
||||
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
|
||||
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
|
||||
//
|
||||
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
|
||||
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
|
||||
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
|
||||
// ——會弄丟資料的是那個動作,不是這個。
|
||||
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
|
||||
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
|
||||
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
|
||||
// (`createNameIsOurs`),沒聲明就停手。
|
||||
/** @type {Map<string, string>} */
|
||||
let existingByName;
|
||||
try {
|
||||
existingByName = await listExistingByName(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
|
||||
`(${msg(e)})。不確定就不建——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const createName = reqs[0].createName;
|
||||
const sameName = existingByName.get(createName);
|
||||
if (sameName !== undefined) {
|
||||
if (!reqs[0].createNameIsOurs) {
|
||||
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
|
||||
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
|
||||
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
|
||||
blockers.push(
|
||||
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]},` +
|
||||
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
|
||||
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
|
||||
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
|
||||
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
|
||||
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
|
||||
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
|
||||
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
create.push({
|
||||
kind,
|
||||
binding,
|
||||
createName,
|
||||
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,
|
||||
...(a.reclaimed ? { reclaimed: true } : {}),
|
||||
});
|
||||
}
|
||||
/** @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,635 @@
|
||||
/**
|
||||
* 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> {
|
||||
// 🔴 Arcrun#123:真的 Cloudflare **不准同名**——
|
||||
// `a namespace with this account ID and title already exists`。
|
||||
// 這個假帳號原本沒有模擬這條限制,於是「上一次裝到一半死掉」的帳號在測試裡
|
||||
// 看起來只是「多建幾顆孤兒」,實際上是**再也裝不起來**。少了這一行,#123 測不出來。
|
||||
if (this.kv.has(title)) {
|
||||
throw new Error('a namespace with this account ID and title already exists');
|
||||
}
|
||||
const id = `NEW-kvid-${this.createdKv.length}`;
|
||||
this.kv.set(title, id);
|
||||
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
|
||||
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());
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Arcrun#123 —— 「上一次裝到一半死掉」的帳號,再裝一次要能成功
|
||||
//
|
||||
// 這一格與 #97 的差別:#97 是「worker 綁著資源,卻被重新綁到新建的空殼」(會弄丟資料);
|
||||
// #123 是「資源建好了、worker 一顆都還沒部署」——**沒有任何綁定可以當事實**,
|
||||
// 而帳號上偏偏已經有一批同名資源 ⇒ 舊規則判「可以新建」⇒ CF 拒絕 ⇒ 這個帳號從此裝不起來。
|
||||
//
|
||||
// 封測者 1.4.45 實撞。逃過驗證的原因:我們只測乾淨帳號與完整安裝。
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 安裝器那條路的需求:`createName` 是安裝器**自己替這台實例算出來的**
|
||||
* (`arcrun-rag-<slugFromEmail(email)>-…`)⇒ 有資格聲明 createNameIsOurs。
|
||||
* 對照 collectRequirements()(走 toml,createName 是裸 binding 名 ⇒ 不得聲明)。
|
||||
*/
|
||||
function installerRequirements(claimOwnership = true): BindingRequirement[] {
|
||||
const own = claimOwnership ? { createNameIsOurs: true } : {};
|
||||
const out: BindingRequirement[] = [];
|
||||
for (const { requirements } of [collectRequirements()]) {
|
||||
for (const r of requirements) {
|
||||
if (r.kind === 'kv_namespace') {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kv-${r.binding.toLowerCase()}`, ...own });
|
||||
} else if (r.kind === 'd1') {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kbdb`, ...own });
|
||||
} else {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-embed`, ...own });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('#123 ①:上次裝到一半死掉的帳號 → 接回上次留下的那批,一顆都不必新建', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true }); // 資源在、worker 一顆都沒有
|
||||
const kvBefore = cf.kv.size;
|
||||
|
||||
const plan = await planResources(cf, installerRequirements(), 'init');
|
||||
assert.deepEqual(plan.blockers, [], '半殘帳號不該有 blocker——用戶只要再按一次就該裝得起來');
|
||||
assert.deepEqual(plan.create, [], '一顆都不該新建');
|
||||
assert.ok(plan.adopt.length > 0 && plan.adopt.every((a) => a.reclaimed === true),
|
||||
'全部都是「接回上次留下的」');
|
||||
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
|
||||
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
|
||||
assert.equal(cf.kv.size, kvBefore, '帳號上的顆數不變(沒有再留下一批孤兒)');
|
||||
|
||||
// 綁到的是上次建的那幾顆本尊
|
||||
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.value, 'kvid-webhooks');
|
||||
assert.equal(resolved.get(bindingKey('d1', 'DB'))!.value, 'd1id-kbdb');
|
||||
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.origin, 'adopted',
|
||||
'origin 維持 adopted——呼叫端既有的 adopted/created 統計不會漏數');
|
||||
});
|
||||
|
||||
test('#123 ①對照組:修好之前,同一個帳號會被 CF 用「title already exists」擋死', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
// 不聲明所有權 ⇒ 走的是修好之前的判斷(「沒人綁著就可以新建」)。
|
||||
// 舊版會直接送 POST → 撞 CF 的同名限制;新版在 plan 階段就停手,兩者都裝不起來,
|
||||
// 差別在**新版一顆資源都不會被建出來**,而且訊息說得出人話。
|
||||
const plan = await planResources(cf, installerRequirements(false), 'init');
|
||||
assert.ok(plan.blockers.length > 0, '證明不了是自己的 → fail-closed 停手');
|
||||
assert.match(plan.blockers.join('\n'), /RES-NAME-TAKEN/, '要給得出可回報的錯誤碼');
|
||||
assert.doesNotMatch(plan.blockers.join('\n'), /後台|dashboard/,
|
||||
'不准叫使用者自己去 Cloudflare 後台處理(#121/D88)');
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, [], '被擋下時一顆都不能被建出來');
|
||||
});
|
||||
|
||||
test('#123 ②紅線:名字真的不是我們的(走 toml 的裸 binding 名)→ 停手,不接管也不新建', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
await cf.createKvNamespace('WEBHOOKS'); // 使用者自己建的、剛好叫這個名字
|
||||
cf.createdKv.length = 0;
|
||||
const { requirements } = collectRequirements(); // createName = 裸 binding 名,不得聲明所有權
|
||||
|
||||
const plan = await planResources(cf, requirements, 'init');
|
||||
assert.ok(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
|
||||
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, [], '一顆都沒建');
|
||||
});
|
||||
|
||||
test('#123 ③:全新帳號照舊建得出整套(D82 第一步不可退化)', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; // 真正的空帳號
|
||||
|
||||
const plan = await planResources(cf, installerRequirements(), 'init');
|
||||
assert.deepEqual(plan.blockers, [], '全新帳號不該有 blocker');
|
||||
assert.ok(plan.adopt.length === 0, '全新帳號沒有東西可沿用');
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length, '9 顆 KV 全部建出來');
|
||||
assert.equal(cf.createdD1.length, 1, 'D1 建一顆(兩個綁定共用)');
|
||||
assert.ok([...resolved.values()].every((r) => r.origin === 'created'), '全部都是新建的');
|
||||
});
|
||||
|
||||
test('CfAccountClient.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>
|
||||
@@ -831,7 +840,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
|
||||
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
|
||||
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
|
||||
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +
|
||||
// Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、
|
||||
// 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板
|
||||
// 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。
|
||||
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組' +
|
||||
(!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0
|
||||
? '<span class="dim">・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)</span>' : '') + '</span>' +
|
||||
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -862,7 +876,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 +939,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 +985,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 +1472,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 +1510,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 +1541,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>
|
||||
@@ -605,18 +696,55 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設且 source 是 gitea:// 才給 href;
|
||||
// 其餘回空字串=維持純文字(行為與未設時一字不變)。chunk 錨點(#n)捨棄。
|
||||
var SOURCE_WEB_BASE = "";
|
||||
function encSrcPath(p) { return p.split('/').map(encodeURIComponent).join('/'); }
|
||||
// 🔴 t21(移植自已刪分支 fix/portal-source-scheme-t21,2026-08-05 分支整理救回):
|
||||
// 新 ingest 鏈(rag_ingest_card/rag_ingest_direct)寫的 metadata.source 是 **kb://<path>**,
|
||||
// 但這裡原本只認 gitea:// ⇒ 收卡上雲後溯源連結一律失效(B4 部好溯源後一經重灌即再斷鏈)。
|
||||
// 三種 scheme 各自的正解:
|
||||
// gitea://<path> (舊 rag_ingest v2)→ {base}/<path>,行為一字不變
|
||||
// gitea:<org/repo>@<path> (km-wiki-ingest 實形)→ {base}/<org/repo>/src/branch/main/<path>
|
||||
// kb://<path> (新 ingest 鏈實形)→ **雲端沒有對應網頁,回空字串=維持純文字,不造死鏈**
|
||||
function srcHref(src) {
|
||||
if (!SOURCE_WEB_BASE || String(src).indexOf('gitea://') !== 0) return '';
|
||||
var p = String(src).slice(8).replace(/#\d+$/, '');
|
||||
return SOURCE_WEB_BASE.replace(/\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/');
|
||||
src = String(src);
|
||||
if (!SOURCE_WEB_BASE) return '';
|
||||
var base = SOURCE_WEB_BASE.replace(/\/+$/, '');
|
||||
if (src.indexOf('gitea://') === 0) {
|
||||
return base + '/' + encSrcPath(src.slice(8).replace(/#\d+$/, ''));
|
||||
}
|
||||
if (src.indexOf('gitea:') === 0) {
|
||||
var rest = src.slice(6).replace(/#[^#]*$/, '');
|
||||
var at = rest.indexOf('@');
|
||||
if (at <= 0 || at >= rest.length - 1) return '';
|
||||
return base + '/' + encSrcPath(rest.slice(0, at)) + '/src/branch/main/' + encSrcPath(rest.slice(at + 1));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
// kb:// 來源的本地相對路徑(去 scheme、去錨點);非 kb:// 回空字串。
|
||||
function srcLocalPath(src) {
|
||||
src = String(src);
|
||||
if (src.indexOf('kb://') !== 0) 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(); });
|
||||
@@ -658,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);
|
||||
@@ -729,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();
|
||||
})
|
||||
@@ -772,10 +923,28 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// 判不出 OS 時**兩個都給**,不替用戶猜;且無論判成哪個,頁面都留「不是這個系統?」的另一版連結
|
||||
// ——UA 會判錯,判錯時用戶要有路可走(施工圖 §3 注意事項 1、3)。
|
||||
var DAEMON_BASE_DEFAULT = 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/';
|
||||
var DAEMON_MAC = 'ArcrunRAG-mac-unsigned.zip';
|
||||
// 🔴 2026-08-05 leo:「Mac 強調要包裝成 application + dmg 的格式,拖進去就會放到
|
||||
// application,**解決之前直接在下載資料夾啟動造成更新問題**」
|
||||
// ⇒ Mac 一律給 **DMG**(開啟後是「把 Arcrun 拖進 Applications」的標準畫面),
|
||||
// 不再給 zip——zip 解開就是 .app,使用者很可能直接在「下載」資料夾雙擊啟動,
|
||||
// 而自更新會蓋錯位置(t184 Oscar 的病)。
|
||||
// ⚠️ 用**固定檔名**(不帶版號),否則每出一版都要改這裡的 code。
|
||||
//
|
||||
// 🔴 2026-08-06 升級(leo:「Portal 和 rag.arcrun.dev 應該顯示同步器的版本⋯⋯
|
||||
// **連我都沒辦法確認**,所以用戶到底是否最新版他自己也不知道」):
|
||||
// 下面兩個常數**降級為退路**,正常情況改向 `/api/latest` 取
|
||||
// `daemon.version` 與 `daemon.downloads`(真相源=bundles 的 manifest)。
|
||||
// ⚠️ 這**沒有違背**上面那條「不要每出一版就改 code」——網址現在是**取來的**,
|
||||
// 一樣不用改 code;而且順便解掉固定別名的兩個老問題:
|
||||
// ① 別名指向 @main,會吃到 CDN/ref 快取拿到舊檔(08-04 撞過)
|
||||
// ② 檔名不帶版號 ⇒ 頁面上無從顯示「這是哪一版」=leo 這次抱怨的正題
|
||||
// 取不到就退回這兩個固定檔名,按鈕不會變死連結。
|
||||
var DAEMON_MAC = 'ArcrunRAG-mac.dmg';
|
||||
var DAEMON_WIN = 'ArcrunRAG-win-unsigned.zip';
|
||||
// Mac 那顆 21MB > jsDelivr 單檔 20MB 上限(實測回 "File size exceeded...")→ 一律走 raw;
|
||||
// Windows 13MB 雖在限內,同走 raw 保持單一來源、少一個會壞的地方。
|
||||
// 由 /api/latest 填入(見下方 loadDaemonLatest);null=還沒取到或取不到。
|
||||
var DAEMON_LATEST = null;
|
||||
// Mac 那顆 > jsDelivr 單檔 20MB 上限(實測回 "File size exceeded...")→ 一律走 raw;
|
||||
// Windows 同走 raw 保持單一來源、少一個會壞的地方。
|
||||
function daemonBase() {
|
||||
var cfg = (window.ARCRUN_CONFIG || {});
|
||||
if (cfg.daemonBase) return String(cfg.daemonBase).replace(/\/?$/, '/');
|
||||
@@ -796,8 +965,11 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var isMobile = /iPhone|iPad|iPod|Android|Mobile/i.test(ua);
|
||||
var isWin = !isMobile && /Windows NT/i.test(ua);
|
||||
var isMac = !isMobile && /Macintosh|Mac OS X/i.test(ua) && !/Windows/i.test(ua);
|
||||
var mac = { os: 'mac', label: '下載 Mac 版', url: base + DAEMON_MAC };
|
||||
var win = { os: 'win', label: '下載 Windows 版', url: base + DAEMON_WIN };
|
||||
// 有取到真相源就用它的網址(帶版號、指向釘點 sha),否則退回固定別名。
|
||||
var dlm = (DAEMON_LATEST && DAEMON_LATEST.downloads && DAEMON_LATEST.downloads.mac) || (base + DAEMON_MAC);
|
||||
var dlw = (DAEMON_LATEST && DAEMON_LATEST.downloads && DAEMON_LATEST.downloads.win) || (base + DAEMON_WIN);
|
||||
var mac = { os: 'mac', label: '下載 Mac 版', url: dlm };
|
||||
var win = { os: 'win', label: '下載 Windows 版', url: dlw };
|
||||
if (isWin) return { pick: win, other: mac, sure: true };
|
||||
if (isMac) return { pick: mac, other: win, sure: true };
|
||||
return { pick: null, other: null, sure: false, mac: mac, win: win };
|
||||
@@ -805,34 +977,69 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// 封測期第一次開啟的擋關提示(未簽章)——Mac/Windows 攔法不同,話術也不同。
|
||||
function daemonHint(os) {
|
||||
if (os === 'win') return '(封測版未簽章,Windows 第一次會跳藍色視窗擋下來——點「更多資訊」→「仍要執行」就好)';
|
||||
return '(封測版未簽章,第一次請右鍵→打開)';
|
||||
// DMG 版的步驟與 zip 不同:先拖進「應用程式」再從那裡開,才不會在下載資料夾啟動。
|
||||
return '(開啟後把 Arcrun 拖進「應用程式」,再從啟動台開啟;封測版未簽章,第一次請右鍵→打開)';
|
||||
}
|
||||
// 07-27:設定頁的常駐入口(下載小幫手/換 AI 金鑰)——與一次性卡片同一組 API
|
||||
(function () {
|
||||
// 🔴 2026-08-06 改成「先用退路畫、取到真相源再重畫」:
|
||||
// fetch 是非同步的,若等它回來才畫,網路慢時使用者會看到一顆沒有網址的按鈕。
|
||||
// ⇒ 先用固定別名畫出可用的按鈕,取到 /api/latest 後再重畫成帶版號的網址+版本行。
|
||||
// renderDaemonDownload 必須**可重複呼叫**(第二次要清掉第一次插進去的兄弟節點),
|
||||
// 否則重畫會疊出兩份「不是這個系統?」。
|
||||
function renderDaemonDownload() {
|
||||
var dl = $('st-daemon-dl');
|
||||
if (dl) {
|
||||
var d = daemonPick();
|
||||
if (d.sure) {
|
||||
dl.setAttribute('href', d.pick.url);
|
||||
dl.textContent = d.pick.label;
|
||||
// 判對了也要留另一版的路(UA 會判錯)
|
||||
var alt = document.createElement('span');
|
||||
alt.className = 'muted';
|
||||
alt.style.cssText = 'font-size:12.5px;margin-left:8px';
|
||||
alt.innerHTML = '不是這個系統?<a href="' + d.other.url + '">' + d.other.label + '</a>';
|
||||
if (dl.parentNode) dl.parentNode.insertBefore(alt, dl.nextSibling);
|
||||
} else {
|
||||
// 判不出來=兩個都給,不預設 Mac
|
||||
dl.setAttribute('href', d.mac.url);
|
||||
dl.textContent = d.mac.label;
|
||||
var both = document.createElement('span');
|
||||
both.className = 'muted';
|
||||
both.style.cssText = 'font-size:12.5px;margin-left:8px';
|
||||
both.innerHTML = '或 <a href="' + d.win.url + '">' + d.win.label + '</a>';
|
||||
if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling);
|
||||
}
|
||||
if (!dl) return;
|
||||
// 清掉上一輪插入的節點(用 id 標記,才不會誤刪別人的東西)
|
||||
['st-daemon-alt', 'st-daemon-ver'].forEach(function (id) {
|
||||
var old = $(id); if (old && old.parentNode) old.parentNode.removeChild(old);
|
||||
});
|
||||
var d = daemonPick();
|
||||
var alt = document.createElement('span');
|
||||
alt.id = 'st-daemon-alt';
|
||||
alt.className = 'muted';
|
||||
alt.style.cssText = 'font-size:12.5px;margin-left:8px';
|
||||
if (d.sure) {
|
||||
dl.setAttribute('href', d.pick.url);
|
||||
dl.textContent = d.pick.label;
|
||||
// 判對了也要留另一版的路(UA 會判錯)
|
||||
alt.innerHTML = '不是這個系統?<a href="' + d.other.url + '">' + d.other.label + '</a>';
|
||||
} else {
|
||||
// 判不出來=兩個都給,不預設 Mac
|
||||
dl.setAttribute('href', d.mac.url);
|
||||
dl.textContent = d.mac.label;
|
||||
alt.innerHTML = '或 <a href="' + d.win.url + '">' + d.win.label + '</a>';
|
||||
}
|
||||
})();
|
||||
if (dl.parentNode) dl.parentNode.insertBefore(alt, dl.nextSibling);
|
||||
|
||||
// 版本行:leo 要的「用戶看得出自己是不是最新版」。
|
||||
// 取不到就**不顯示**,不要編一個數字(同 landing 的原則:寧可空著也不說謊)。
|
||||
if (DAEMON_LATEST && DAEMON_LATEST.version) {
|
||||
var ver = document.createElement('div');
|
||||
ver.id = 'st-daemon-ver';
|
||||
ver.className = 'muted';
|
||||
ver.style.cssText = 'font-size:12.5px;margin-top:6px';
|
||||
ver.innerHTML = '最新的同步器版本是 <strong>' + esc(DAEMON_LATEST.version) + '</strong>'
|
||||
+ '(你手上那支的版本,在同步器視窗左下角)'
|
||||
+ ' · <a href="https://rag.arcrun.dev/docs/help/changelog/" target="_blank" rel="noopener">這一版改了什麼</a>';
|
||||
if (alt.parentNode) alt.parentNode.insertBefore(ver, alt.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
function loadDaemonLatest() {
|
||||
// 不用 INSTALLER_ORIGIN:它宣告在本區塊之後(var 提升 ⇒ 這裡是 undefined)。
|
||||
fetch('https://install.arcrun.dev/api/latest')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) {
|
||||
if (j && j.daemon && j.daemon.version) {
|
||||
DAEMON_LATEST = j.daemon;
|
||||
renderDaemonDownload(); // 取到了才重畫
|
||||
}
|
||||
})
|
||||
.catch(function () { /* 取不到就維持退路的固定別名,按鈕仍可用 */ });
|
||||
}
|
||||
|
||||
renderDaemonDownload();
|
||||
loadDaemonLatest();
|
||||
|
||||
// t176(leo 08-03):AI 設定的前端邏輯整段移除。
|
||||
// 雲端聊天走 Workers AI(免金鑰);地端萃取金鑰改由同步小幫手托盤「AI 設定…」自己設。
|
||||
@@ -844,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);
|
||||
@@ -859,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;
|
||||
@@ -868,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 {}; }
|
||||
}
|
||||
@@ -883,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;
|
||||
@@ -894,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
|
||||
@@ -916,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(); }); // 只藏本次,下次進站再提醒
|
||||
}
|
||||
@@ -1008,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;
|
||||
@@ -1040,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) {
|
||||
@@ -1054,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>';
|
||||
@@ -1098,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>';
|
||||
@@ -1198,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>' +
|
||||
@@ -1226,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>'
|
||||
@@ -1235,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;
|
||||
@@ -1441,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;
|
||||
@@ -1710,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);
|
||||
@@ -1726,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; })
|
||||
@@ -1741,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;
|
||||
@@ -1783,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,10 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
|
||||
// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)
|
||||
//
|
||||
// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語
|
||||
// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。
|
||||
// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。
|
||||
const start = html.indexOf('var DAEMON_BASE_DEFAULT');
|
||||
const endMark = "return '(封測版未簽章,第一次請右鍵→打開)';\n }";
|
||||
const end = html.indexOf(endMark) + endMark.length;
|
||||
if (start < 0 || end < start) throw new Error('抽不到函式區塊');
|
||||
const hintAt = html.indexOf('function daemonHint', start);
|
||||
const endMark = '\n }';
|
||||
const end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;
|
||||
if (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');
|
||||
const src = html.slice(start, end);
|
||||
|
||||
const cases = [
|
||||
@@ -26,11 +31,13 @@ for (const [name, ua] of cases) {
|
||||
console.log(` url: ${url}`);
|
||||
if (name==='Windows') {
|
||||
chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);
|
||||
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('mac-unsigned.zip'));
|
||||
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));
|
||||
chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));
|
||||
}
|
||||
if (name==='Mac') {
|
||||
chk('Mac 給 mac zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac-unsigned.zip'));
|
||||
// 2026-08-05:Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip
|
||||
// ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。
|
||||
chk('Mac 給 dmg(不是 zip)', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));
|
||||
chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));
|
||||
chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,98 +21,73 @@ import type { Bindings } from '../types';
|
||||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
import { createArcrunHostFunctions } from '../lib/wasi-shim';
|
||||
import { getCredentialSecretRefs, touchLastUsed } from '../routes/credentials';
|
||||
|
||||
// ── credential-store 遷移 T6/T7(方案 A,D19)────────────────────────────────
|
||||
// ── credential-store 遷移 T6/T7(方案 A,D19)+ D38 圍牆修復(2026-08-07)───────────
|
||||
//
|
||||
// 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的
|
||||
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
|
||||
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref],T4)取明文
|
||||
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
|
||||
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||||
// 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref)→ 用 secret_get(ref)
|
||||
// (即 env[ref],T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
|
||||
// WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||||
//
|
||||
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
|
||||
// D38(leo 2026-06-14 立、2026-08-07 擴大):目錄不再直連 D1,改走 KBDB HTTP API
|
||||
// (`credentials.ts` 的 `getCredentialSecretRefs`,內建 60 秒租戶級快取——這是熱路徑,
|
||||
// 每次 workflow 執行都會呼叫,映射「幾乎不變」故快取後多數命中零網路呼叫,效能不因改走
|
||||
// API 而變差,見 credentials.ts 檔頭「效能」段的實測數字)。
|
||||
//
|
||||
// 嚴格邊界(rule 02 §2.2):本檔只做「查目錄拿 ref → secret_get 取值 → 當字串塞 payload」。
|
||||
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
|
||||
// 內,解密/注入邏輯仍全在 WASM 零件。
|
||||
|
||||
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
|
||||
interface CredentialRefRow {
|
||||
name: string;
|
||||
secret_ref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
|
||||
*
|
||||
* 流程:查 D1 `credentials`(api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
|
||||
* (host function,實作 = env[ref])取值。
|
||||
* 流程:查 KBDB credential 目錄(api_key + name,快取命中零網路呼叫)拿 `secret_ref`
|
||||
* → 用 `secret_get(ref)`(host function,實作 = env[ref])取值。
|
||||
*
|
||||
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||||
* ⚠️ 只把「目錄有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||||
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
|
||||
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
|
||||
*
|
||||
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
|
||||
* 取到值的 name 順手更新 last_used_at(§2.5 治理面 last_used,見 touchLastUsed——
|
||||
* fire-and-forget、非同步、不阻塞本函式回傳,失敗吞掉)。
|
||||
*
|
||||
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
|
||||
* KBDB 不可達 / 這個租戶還沒有任何 credential → 回空 map(整組走 fallback),
|
||||
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
|
||||
*/
|
||||
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
|
||||
type ResolvedSecretMap = Record<string, string>;
|
||||
|
||||
export async function resolveSecretsFromNewHome(
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
names: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const resolved: Record<string, string> = {};
|
||||
): Promise<ResolvedSecretMap> {
|
||||
const resolved: ResolvedSecretMap = {};
|
||||
if (names.length === 0) return resolved;
|
||||
|
||||
const db = env.CREDENTIALS_DB;
|
||||
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
|
||||
|
||||
// 1. 查 D1 拿每個 name 的 secret_ref
|
||||
let rows: CredentialRefRow[];
|
||||
try {
|
||||
const placeholders = names.map(() => '?').join(', ');
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT name, secret_ref FROM credentials
|
||||
WHERE api_key = ? AND name IN (${placeholders})`,
|
||||
)
|
||||
.bind(apiKey, ...names)
|
||||
.all<CredentialRefRow>();
|
||||
rows = result.results ?? [];
|
||||
} catch {
|
||||
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
|
||||
return resolved;
|
||||
}
|
||||
if (rows.length === 0) return resolved;
|
||||
// 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts)
|
||||
const refs = await getCredentialSecretRefs(env, apiKey);
|
||||
if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 fallback
|
||||
|
||||
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref])
|
||||
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
|
||||
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
|
||||
|
||||
const resolvedNames: string[] = [];
|
||||
for (const row of rows) {
|
||||
const value = await secretGet(row.secret_ref);
|
||||
for (const name of names) {
|
||||
const ref = refs[name];
|
||||
if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback
|
||||
const value = await secretGet(ref);
|
||||
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
|
||||
if (value === null) continue;
|
||||
resolved[row.name] = value;
|
||||
resolvedNames.push(row.name);
|
||||
resolved[name] = value;
|
||||
resolvedNames.push(name);
|
||||
}
|
||||
|
||||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
|
||||
if (resolvedNames.length > 0) {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const placeholders = resolvedNames.map(() => '?').join(', ');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE credentials SET last_used_at = ?
|
||||
WHERE api_key = ? AND name IN (${placeholders})`,
|
||||
)
|
||||
.bind(now, apiKey, ...resolvedNames)
|
||||
.run();
|
||||
} catch {
|
||||
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
|
||||
}
|
||||
}
|
||||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name;fire-and-forget,非關鍵路徑)
|
||||
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -270,6 +245,35 @@ export async function resolveCredentialRefs(
|
||||
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KV(T7)。
|
||||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
|
||||
|
||||
// ── 全部取到 → 不必打 auth_static_key worker(Arcrun#124,2026-08-14)────────────
|
||||
//
|
||||
// 這條路徑上,那顆 worker 唯一比這裡多做的事,是「幫**沒取到**的 name 回頭翻舊 KV」
|
||||
// (T7 雙讀 fallback)。當 nameList 每一個都已經從新家取到明文時,它收到
|
||||
// `resolved_secrets` 後就是原樣回傳——回填本來就由下面的 `replaceCredentialRefs`
|
||||
// 在本檔做。**所以那趟往返在「全取到」時不產生任何結果差異。**
|
||||
//
|
||||
// 為什麼這不是效能微調,是修 bug:`wasmWorkerUrl` 推導出的
|
||||
// `arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` **在網頁安裝器裝出來的實例上不存在**
|
||||
// (首裝只裝 cypher-executor / kbdb / http_request / code 四顆,auth primitive 不在其中,
|
||||
// 也沒有「用到才長」的機制)。於是 fetch 撞 CF 邊緣的 404 `error code: 1042`
|
||||
// ⇒ 下面的 `!res.ok` throw ⇒ **凡是工作流用到 {{credential.X}} 的節點全部 500**。
|
||||
// 實測:leo21c 與全新安裝的 youlin 兩台,問答與寫入路徑一字不差地同死。
|
||||
//
|
||||
// 🔴 給下一個人:**這個 1042 跟 d2048e2/95a1462 那兩輪的 1042 不是同一個病。**
|
||||
// 那兩輪是「same-zone fetch 被 CF 擋」(解法=`global_fetch_strictly_public` flag,
|
||||
// 且 service binding 的解法**已被 revert、不要再拿回來**)。本案是「**目標 worker 根本不存在**」
|
||||
// ——同一個錯誤碼、兩種原因。照舊帳去動 flag 或 binding 對本案完全無效(flag 早就在了)。
|
||||
//
|
||||
// 邊界(rule 02 §2.2)不變:本檔仍然不解密、不展開模板、不組 JWT。
|
||||
// 明文的來源是 `secret_get`(host function,實作 = env[ref]),與改動前完全同一條路;
|
||||
// 這裡只是不再為了「拿一份自己已經拿到的東西」去 fetch 一顆可能不存在的 worker。
|
||||
//
|
||||
// 刻意保留 WASM 路徑:只要有任何一個 name 沒從新家取到(舊租戶、遷移過渡期的 KV 存量),
|
||||
// 就照舊全量交給 WASM 走 T7 雙讀——**不縮減既有能力,只在等價時省掉那一跳**。
|
||||
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
|
||||
return replaceCredentialRefs(data, resolvedSecrets) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
/**
|
||||
* Execution Logger — 執行結果寫入 ANALYTICS_KV(fire-and-forget)
|
||||
* Execution Logger — 執行結果寫入 KBDB(fire-and-forget)
|
||||
*
|
||||
* 設計:每次 workflow 執行後,將統計數據寫入 ANALYTICS_KV(key = stats:{workflowId})。
|
||||
* Phase 7 可升級為 POST 至 registry.arcrun.dev/analytics/record。
|
||||
* KV 額度事故修復(總管交辦,2026-08-07):舊版寫 ANALYTICS_KV(Workers KV),
|
||||
* key = stats:{workflowId}:{timestamp}(註解寫「避免覆蓋」)⇒ 只增不減、永不覆蓋。
|
||||
* 封測者 Evan 處理約 690 個檔案,KV 免費層 write 上限 1,000/日被打爆(實測 1,070 write)。
|
||||
*
|
||||
* KBDB 鐵律(leo 2026-06-14):KBDB=API-as-Wall,零 SQL——任何存取一律走 KBDB 的 HTTP API,
|
||||
* 不准直接對它的 D1 下 SQL。本檔因此**不直連任何 D1**,改 fire-and-forget POST
|
||||
* `{KBDB_BASE_URL}/execution-log/record`(連法/認證頭完全比照既有 recordRecipeStats
|
||||
* 慣例,見 webhook-handlers.ts;儲存/降級實作在 kbdb/src/actions/execution-log.ts)。
|
||||
*
|
||||
* leo 兩條判準:
|
||||
* ① 執行紀錄是稽核資料 → 搬去 D1(entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。
|
||||
* ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/
|
||||
* duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(截斷長度不對稱,見 KBDB 端)。
|
||||
*
|
||||
* A2 自我降級(門檻與降級邏輯全在 KBDB 端,見 execution-log.ts):D1 額度仍與知識卡共用,
|
||||
* 超過門檻 KBDB 會回報 mode='skip'/'log_failure_only',但**這件事對呼叫端透明**——
|
||||
* 本函式不管 KBDB 決定寫或不寫,一律 fire-and-forget、永不 throw,workflow 執行不受影響。
|
||||
*/
|
||||
|
||||
import type { Bindings, GraphNode } from '../types';
|
||||
import { kbdbBase } from '../routes/kbdb-proxy';
|
||||
|
||||
export interface ExecutionVerdict {
|
||||
workflow_id: string;
|
||||
component_ids: string[];
|
||||
verdict: 'success' | 'failed';
|
||||
duration_ms: number;
|
||||
message: string;
|
||||
recorded_at: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入執行結果至 ANALYTICS_KV(fire-and-forget,不阻擋主流程)
|
||||
* 由 c.executionCtx.waitUntil() 包裹呼叫
|
||||
* 從觸發時的 trigger context 擷取這次處理的目標(page_name / path),供「哪些檔沒進去」
|
||||
* 這種問題答得出來。只認這兩個 key(少記,不做窮舉式欄位挖掘/猜測)。
|
||||
*/
|
||||
function extractTarget(input?: Record<string, unknown>): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const raw = input.page_name ?? input.path;
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
return typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入執行結果至 KBDB(fire-and-forget,不阻擋主流程)。
|
||||
* 由 c.executionCtx.waitUntil() 包裹呼叫。
|
||||
*
|
||||
* @param nodes 保留參數相容既有呼叫端簽名(原本用來算 component_ids);「不記每節點」
|
||||
* 是本次修復的明確要求(少記),此參數現不使用。
|
||||
* @param input 觸發時的 trigger context(可選)——只用來抓 page_name / path 當 target,
|
||||
* 不整包送出(少記:不留每節點輸入輸出,這裡也不例外)。
|
||||
* @param apiKey 觸發者的租戶(可選,/execute 舊路徑無租戶概念)。
|
||||
*/
|
||||
export async function writeExecutionVerdict(
|
||||
env: Bindings,
|
||||
@@ -27,27 +59,25 @@ export async function writeExecutionVerdict(
|
||||
verdict: 'success' | 'failed',
|
||||
durationMs: number,
|
||||
message: string,
|
||||
input?: Record<string, unknown>,
|
||||
apiKey?: string,
|
||||
): Promise<void> {
|
||||
void nodes; // 少記:不再從節點算 component_ids,保留參數只為呼叫端相容
|
||||
try {
|
||||
const componentIds = nodes
|
||||
.filter(n => n.type === 'Component' && n.componentId)
|
||||
.map(n => n.componentId!);
|
||||
|
||||
const record: ExecutionVerdict = {
|
||||
workflow_id: workflowId,
|
||||
component_ids: componentIds,
|
||||
verdict,
|
||||
duration_ms: durationMs,
|
||||
message,
|
||||
recorded_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// ANALYTICS_KV key = stats:{workflowId}:{timestamp}(避免覆蓋)
|
||||
const key = `stats:${workflowId}:${Date.now()}`;
|
||||
await env.ANALYTICS_KV.put(key, JSON.stringify(record), {
|
||||
expirationTtl: 60 * 60 * 24 * 90, // 保留 90 天
|
||||
const { base, headers } = kbdbBase(env);
|
||||
await fetch(`${base}/execution-log/record`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
workflow_id: workflowId,
|
||||
owner_id: apiKey ?? null,
|
||||
verdict,
|
||||
duration_ms: Math.max(0, Math.round(durationMs)),
|
||||
message: message ?? '',
|
||||
target: extractTarget(input) ?? null,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// fire-and-forget:不拋錯,不影響主流程
|
||||
// fire-and-forget:任何錯誤(含 KBDB 端額度打滿、網路失敗)都吞掉、不影響主流程
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,365 @@
|
||||
/**
|
||||
* 認證儲存(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。
|
||||
*
|
||||
* `tokenOverride`(2026-08-14,arcrun-rag#99):`env.CF_SECRETS_API_TOKEN` 從沒被安裝器種過,
|
||||
* 這是每台新實例都會撞的硬斷點(不是 leo 個人的環境問題)。安裝精靈裝機當下手上有一把
|
||||
* 自己還有效的 OAuth token,讓 `/console/setup`/`/portal/admin/bootstrap` 把它隨請求帶入,
|
||||
* 這裡就把它算進「寫得進去嗎」的判斷——見 `routes/credentials.ts putWorkerSecret` 的完整說明。
|
||||
*/
|
||||
export function authStoreWritable(env: Bindings, tokenOverride?: string): boolean {
|
||||
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀出完整認證資料。**同步、零網路呼叫**——這就是分離的意義:
|
||||
* 登入不依賴 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, tokenOverride?: string): Promise<void> {
|
||||
if (!authStoreWritable(env, tokenOverride)) {
|
||||
// arcrun-rag#99(2026-08-14,leo 傳實測畫面點破):舊文案叫用戶「重新執行安裝/更新」,
|
||||
// 但在這個訊息會出現的所有情況下(包含安裝精靈本身,若它沒能把 install token 帶到)那句話
|
||||
// 都不保證成立——安裝/更新從來不會把 CF_SECRETS_API_TOKEN 種成一個常駐的值。
|
||||
// 對一個做不到的動作下指令=把人導向死路(他會以為自己操作錯誤,反覆重試)。
|
||||
// 改成誠實描述現況+給得出去的下一步(回報支援),不再承諾一個我們自己都不確定會生效的動作。
|
||||
// 只列 CF_SECRETS_API_TOKEN:CF_ACCOUNT_ID 已查證全新實例一定有
|
||||
//(installer/oauth-prototype/worker.js:1354 無條件注入每一顆部署的 worker),
|
||||
// 列兩項只會讓下一個人以為兩項都要查,白工一次。
|
||||
throw new AuthStoreWriteError(
|
||||
'這台實例目前寫不進認證儲存(缺可用的 Cloudflare 寫入憑證:CF_SECRETS_API_TOKEN)。' +
|
||||
'這是平台端的已知限制,不是你操作錯誤——目前沒有你自己在畫面上能做的下一步,' +
|
||||
'請把這則訊息完整截圖/複製給支援,並註明你剛才在做什麼(例如:安裝精靈裡建立第一個帳號、' +
|
||||
'事後新增使用者、或修改密碼),會需要人工協助排除。',
|
||||
);
|
||||
}
|
||||
|
||||
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], tokenOverride);
|
||||
}
|
||||
for (const name of existing) {
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
|
||||
}
|
||||
|
||||
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>,
|
||||
tokenOverride?: string,
|
||||
): Promise<AuthStoreData> {
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next, tokenOverride);
|
||||
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,20 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
// D61 補充(2026-08-14,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
|
||||
// D61 把這組管理員帳密搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器
|
||||
// 照名字重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
|
||||
// `CF_SECRETS_API_TOKEN`,而這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
|
||||
// 第一個帳號(arcrun-rag#99)。
|
||||
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`(Arcrun#97)解掉——
|
||||
// 現在每次安裝/更新都會沿用既有 binding,不會再把 SESSIONS_KV 重指到空資源。既然病根已解,
|
||||
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳密改回住 SESSIONS_KV**
|
||||
// (`console:credentials`,binding,永不需要外部 CF token)。
|
||||
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
|
||||
// 零外部憑證需求(只有寫入才要 token)——查到就順手搬回 SESSIONS_KV(見 loadCredentials)。
|
||||
import { readAuthStore } from '../lib/portal-auth-store';
|
||||
// Arcrun#108:租戶字串唯一產地。
|
||||
import { knowledgeOwner } from '../lib/tenant';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -66,20 +80,92 @@ 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);
|
||||
}
|
||||
|
||||
// ── 帳密的家(2026-08-14 起:SESSIONS_KV 為主,認證儲存為舊實例回退讀路徑)────────────
|
||||
|
||||
/**
|
||||
* 讀出 console 管理員帳密。**SESSIONS_KV(binding)優先**;沒有才回退讀舊家
|
||||
* (D61 的認證儲存,CF Workers Secrets——讀取零成本、零外部憑證需求),
|
||||
* 且一旦從舊家讀到就順手搬回 SESSIONS_KV(best-effort,搬不動不影響本次登入)。
|
||||
*/
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'kv' | 'legacy-secrets' | 'none' }> {
|
||||
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
return { creds: JSON.parse(raw) as StoredCredentials, source: 'kv' };
|
||||
} catch {
|
||||
/* KV 這份壞了,當作沒有,往下查舊家 */
|
||||
}
|
||||
}
|
||||
|
||||
// 舊家(D61 的認證儲存):純讀 env 字串,零網路呼叫、不需要任何外部 CF 憑證。
|
||||
const legacy = readAuthStore(env).console;
|
||||
if (!legacy) return { creds: null, source: 'none' };
|
||||
try {
|
||||
// best-effort 搬回 SESSIONS_KV——這是 binding put,本來就不需要外部 token,
|
||||
// 幾乎不會失敗;失敗也不影響本次用這份舊資料繼續(狀態看 /console/auth-status)。
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
|
||||
} catch {
|
||||
/* 照舊用這份,下次再試著搬一次 */
|
||||
}
|
||||
return { creds: legacy as StoredCredentials, source: 'legacy-secrets' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入 console 管理員帳密——**只寫 SESSIONS_KV**(binding,永不需要外部 CF token)。
|
||||
* 不再寫回認證儲存(Workers Secrets):那是要被淘汰的舊家,寫回去等於把債種回土裡。
|
||||
*/
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
}
|
||||
|
||||
/** `/console/auth-status`、`/health` 共用的儲存狀態區塊(不洩漏 email/雜湊,只回統計)。 */
|
||||
function consoleAuthStoreStatus(env: Bindings): { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean } {
|
||||
return {
|
||||
home: 'sessions-kv',
|
||||
writable: true, // binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
|
||||
legacy_secrets_present: readAuthStore(env).console !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
return c.json({ configured: !!existing });
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
// 多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(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 +176,14 @@ 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 {
|
||||
// 2026-08-14 起:寫 SESSIONS_KV(binding),不再需要安裝精靈遞任何臨時 CF token
|
||||
// (arcrun-rag#99 那個結構性缺口——見檔頭說明——已經隨儲存層搬回 binding 一併解掉)。
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
// 寫不進去就誠實回報(不假綠:binding put 幾乎不會失敗,於是沒人處理過這條路)
|
||||
return c.json({ error: `帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
@@ -101,9 +194,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,15 +210,29 @@ 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) {
|
||||
return c.json({ error: `新帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, 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) {
|
||||
// 明顯失敗(#10「寧可明顯失敗,不要靜默錯置」):這是「這台實例讀不到認證資料」,
|
||||
// 不是「你帳密打錯」——兩句話混成一句正是 2026-08-09 leo 被誤鎖 15 分鐘的根因。
|
||||
return c.json(
|
||||
{
|
||||
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: consoleAuthStoreStatus(c.env),
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim().toLowerCase();
|
||||
|
||||
@@ -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(整串回寫的前提),順便守兩道邊界:
|
||||
|
||||
@@ -7,24 +7,41 @@
|
||||
* 寫入(POST 建立 / PUT 覆寫):
|
||||
* 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫,
|
||||
* arcrun 自己也讀不回值——D19「不持有內容物」)。
|
||||
* 2. D1 `credentials` 表只寫「目錄」(api_key/name/service/sensitivity/secret_ref/
|
||||
* created_at),**不含密文**。
|
||||
* 2. 目錄(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,
|
||||
* **不含密文**)走 KBDB HTTP API 寫,不再直連任何 D1。
|
||||
* 不再寫 KV / 不再寫明文密文到 D1。
|
||||
*
|
||||
* 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體
|
||||
* 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03
|
||||
* 定案並已落地的做法,取代更早的 `{name, encrypted, iv}` 格式;rule 01 已同步。)
|
||||
*
|
||||
* D38 圍牆修復(總管交辦,2026-08-07;leo「任何東西禁止用 SQL 語句存取資料,一律 API」):
|
||||
* 目錄舊家是 KBDB 裡多開的一張獨立 credentials 表(0002_credentials.sql,違規),現改走
|
||||
* KBDB 三張核心表——entries 表一列(entry_type='credential',page_name=name 當冪等鍵,
|
||||
* owner_id=api_key 隔離租戶,其餘欄位打包進 metadata_json),template 定義見
|
||||
* kbdb/migrations/0005_credential_template.sql,舊表資料遷移+拆表見 0006。連法比照既有
|
||||
* execution-logger.ts / portal.ts 慣例:kbdbBase(env) 組 base+headers,直接 fetch KBDB
|
||||
* HTTP API,不經自己的 /kbdb/* proxy route(那支是給 CLI 用的,server 端直連 base 更省一跳)。
|
||||
*
|
||||
* 效能(D38 評估要求「帶數字」,見 system-dev/wiki/decisions-summary.md D38 段):
|
||||
* 熱路徑(auth-dispatcher.ts resolveSecretsFromNewHome,每次 workflow 執行都會查一次)原本
|
||||
* 直連 D1、零快取;改走 HTTP 後若一樣「每次查一次」延遲只會變差(多一趟公網往返)。這份
|
||||
* name→secret_ref 映射「幾乎不變」(D38 評估原話),故本檔加一個租戶級記憶體快取
|
||||
* (dirCache,per-isolate,TTL 60 秒),寫入(POST/PUT/DELETE)時主動失效,讓熱路徑多數
|
||||
* 命中零網路呼叫。見下方 getCredentialDirectory / invalidateCredentialCache。
|
||||
*
|
||||
* 治理端點:
|
||||
* - `GET /credentials`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留
|
||||
* `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||
* - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row;
|
||||
* 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。
|
||||
* - `GET /credentials`:改讀 KBDB entries(與 `/credentials/catalog` 共用同一份查詢,同時
|
||||
* 保留 `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||
* - `DELETE /credentials/:name`:先查 KBDB 拿 secret_ref → 有則刪 Workers Secret + entries
|
||||
* row;沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的
|
||||
* 孤兒資料。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { sha256Prefix } from '../lib/hash';
|
||||
import { kbdbBase } from './kbdb-proxy';
|
||||
|
||||
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -61,7 +78,7 @@ export async function storeCredential(
|
||||
): Promise<void> {
|
||||
const secretRef = await deriveSecretRef(apiKey, name);
|
||||
await putWorkerSecret(env, secretRef, value);
|
||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service, 'standard', secretRef);
|
||||
await upsertCredentialEntry(env, apiKey, name, service, 'standard', secretRef);
|
||||
}
|
||||
|
||||
function validateName(name: unknown): name is string {
|
||||
@@ -75,9 +92,21 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
|
||||
/**
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
|
||||
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
|
||||
*
|
||||
* `tokenOverride`(2026-08-14,arcrun-rag#99:全新帳號卡在註冊,`writable:false`):
|
||||
* 本 worker 自己的 `env.CF_SECRETS_API_TOKEN` 從安裝那天起就沒被種過(07-29 已知缺口,記在
|
||||
* pending-changes.md「credential 走 n8n 模式」——當時只降級某個功能;D61 認證分離之後升級成
|
||||
* 「連第一個帳號都建不起來」的硬斷點,因為 `/console/setup`/`/portal/admin/bootstrap`
|
||||
* 現在都走這條寫入路徑)。安裝精靈裝機當下手上有一把**自己還有效**的 OAuth token
|
||||
* (`workers-scripts.write` scope,跟部署零件、種 credential 用的是同一把——見
|
||||
* `installer/oauth-prototype/worker.js` 的 `putWorkerSecretDirect`/`seedCredential`,
|
||||
* 是同一個「安裝器代寫」精神,D36 第1步)。讓呼叫端把這把 token **隨請求帶入、不落地**,
|
||||
* 補的正是「cypher 自己永遠拿不到長效寫入憑證」這個結構性缺口,不是新開一條路。
|
||||
* 優先權:呼叫端提供 > worker 自身 env。
|
||||
*/
|
||||
async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string, tokenOverride?: string): Promise<void> {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error(
|
||||
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
|
||||
'credential-store-migration.md T3:acr init/update 應確保這兩項就緒)',
|
||||
@@ -87,7 +116,7 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: secretRef, text: value, type: 'secret_text' }),
|
||||
@@ -104,15 +133,19 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
|
||||
/**
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
||||
*
|
||||
* `tokenOverride`:與 `putWorkerSecret` 同一組理由(見該函式註解)。auth store 分片重切時
|
||||
* 會刪多出來的舊分片,這條路徑也要能吃到安裝精靈臨時遞來的 token。
|
||||
*/
|
||||
async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string, tokenOverride?: string): Promise<void> {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
||||
}
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.status === 404) return;
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
@@ -124,32 +157,197 @@ async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
// ── KBDB 目錄存取(D38:零 SQL,一律走 entries HTTP API)──────────────────────────
|
||||
|
||||
const CREDENTIAL_ENTRY_TYPE = 'credential';
|
||||
|
||||
/** entries 表回來的一列(本檔只取用得到的欄位,避免耦合 KBDB 內部型別)。 */
|
||||
interface KbdbEntryRow {
|
||||
id: string;
|
||||
page_name: string | null;
|
||||
owner_id: string | null;
|
||||
metadata_json: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface CredentialMeta {
|
||||
service: string | null;
|
||||
sensitivity: 'standard' | 'high';
|
||||
secret_ref: string;
|
||||
last_used_at: number | null;
|
||||
}
|
||||
|
||||
/** name → secret_ref 對照(給熱路徑用;獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
|
||||
type CredentialRefMap = Record<string, string>;
|
||||
|
||||
function parseMeta(row: KbdbEntryRow): CredentialMeta {
|
||||
try {
|
||||
const m = row.metadata_json ? (JSON.parse(row.metadata_json) as Record<string, unknown>) : {};
|
||||
return {
|
||||
service: typeof m.service === 'string' ? m.service : null,
|
||||
sensitivity: m.sensitivity === 'high' ? 'high' : 'standard',
|
||||
secret_ref: typeof m.secret_ref === 'string' ? m.secret_ref : '',
|
||||
last_used_at: typeof m.last_used_at === 'number' ? m.last_used_at : null,
|
||||
};
|
||||
} catch {
|
||||
// 壞資料誠實視為空目錄列,不讓損毀的 metadata_json 炸整條路徑
|
||||
return { service: null, sensitivity: 'standard', secret_ref: '', last_used_at: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** 對 KBDB base 發 request(server 端直連,不經 /kbdb/* proxy——那支是給 CLI 用的)。 */
|
||||
async function kbdbCredFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
return fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 熱路徑快取(D38 效能要求:這份映射幾乎不變,帶快取才不會比舊版 D1 直查慢)─────────
|
||||
//
|
||||
// per-isolate 記憶體快取,key=apiKey,TTL 60 秒。auth-dispatcher.ts 的
|
||||
// resolveSecretsFromNewHome() 每次 workflow 執行都會呼叫,命中快取=零網路呼叫;
|
||||
// 未命中才打一次 KBDB(一次列出該租戶全部 credential,通常個位數到十位數筆,遠比逐名查便宜)。
|
||||
// 寫入路徑(upsert/delete)主動 invalidate,保證「剛存的 credential 立刻查得到」不受 TTL 拖延。
|
||||
// 快取容器用 plain object——apiKey 皆為服務端衍生字串,非使用者可控鍵名。
|
||||
interface CachedDirRow {
|
||||
id: string;
|
||||
name: string;
|
||||
secret_ref: string;
|
||||
service: string | null;
|
||||
sensitivity: 'standard' | 'high';
|
||||
last_used_at: number | null;
|
||||
}
|
||||
interface CachedDir {
|
||||
rows: CachedDirRow[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
const DIR_CACHE_TTL_MS = 60_000;
|
||||
const dirCache: Record<string, CachedDir> = {};
|
||||
|
||||
/** 寫入(建立/覆寫/刪除)後呼叫,讓下次熱路徑查詢重新打一次 KBDB(不吃到過期快取)。 */
|
||||
export function invalidateCredentialCache(apiKey: string): void {
|
||||
delete dirCache[apiKey];
|
||||
}
|
||||
|
||||
/** 拉某租戶全部 credential 目錄列(快取層,60 秒 TTL)。給熱路徑(auth-dispatcher)與治理端點共用。 */
|
||||
async function getCredentialDirectory(env: Bindings, apiKey: string): Promise<CachedDirRow[]> {
|
||||
const now = Date.now();
|
||||
const cached = dirCache[apiKey];
|
||||
if (cached && now - cached.fetchedAt < DIR_CACHE_TTL_MS) return cached.rows;
|
||||
|
||||
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) {
|
||||
// KBDB 不可達 / 回錯:誠實回空(呼叫端各自決定 fallback,不快取失敗結果避免卡住恢復)
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||
const rows: CachedDirRow[] = (body?.entries ?? [])
|
||||
.filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
|
||||
.map((e) => {
|
||||
const meta = parseMeta(e);
|
||||
return {
|
||||
id: e.id,
|
||||
name: e.page_name,
|
||||
secret_ref: meta.secret_ref,
|
||||
service: meta.service,
|
||||
sensitivity: meta.sensitivity,
|
||||
last_used_at: meta.last_used_at,
|
||||
};
|
||||
});
|
||||
dirCache[apiKey] = { rows, fetchedAt: now };
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* D1 upsert credential 目錄 row(不含密文)。
|
||||
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新
|
||||
* service/sensitivity/secret_ref(secret_ref 是純函式衍生自 api_key+name,理論上覆寫時
|
||||
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
|
||||
* 給熱路徑(auth-dispatcher.ts)用:回這個租戶所有 credential 的 name→secret_ref 對照。
|
||||
* 快取命中=零網路呼叫;未命中打一次 KBDB list(見 getCredentialDirectory)。
|
||||
*/
|
||||
async function upsertCredentialRow(
|
||||
db: D1Database,
|
||||
export async function getCredentialSecretRefs(env: Bindings, apiKey: string): Promise<CredentialRefMap> {
|
||||
const rows = await getCredentialDirectory(env, apiKey);
|
||||
const out: CredentialRefMap = {};
|
||||
for (const r of rows) {
|
||||
if (r.secret_ref) out[r.name] = r.secret_ref;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 治理面 last_used_at 更新(非關鍵路徑,best-effort,不阻塞呼叫端)。
|
||||
* 直接用快取裡已知的 id/其餘欄位組 PATCH,不額外多打一次查詢。找不到快取(代表這個租戶
|
||||
* 本次請求根本沒查到目錄,不太可能發生——resolveSecretsFromNewHome 只在有 secret_ref 命中時
|
||||
* 才會呼叫本函式)就跳過,不為了治理欄位額外多打一輪 KBDB。
|
||||
* 呼叫端刻意不 await 本函式的內部 fetch(fire-and-forget,見 auth-dispatcher.ts),失敗吞掉。
|
||||
*/
|
||||
export function touchLastUsed(env: Bindings, apiKey: string, names: string[]): void {
|
||||
const cached = dirCache[apiKey];
|
||||
if (!cached || names.length === 0) return;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const r of cached.rows) {
|
||||
if (!names.includes(r.name)) continue;
|
||||
const meta: CredentialMeta = {
|
||||
service: r.service, sensitivity: r.sensitivity, secret_ref: r.secret_ref, last_used_at: now,
|
||||
};
|
||||
kbdbCredFetch(env, `/entries/${encodeURIComponent(r.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
|
||||
}).catch(() => { /* 治理面欄位,非關鍵路徑,失敗不影響任何主流程 */ });
|
||||
r.last_used_at = now; // 快取內同步更新,避免同一 TTL 視窗內下一次讀到舊值
|
||||
}
|
||||
}
|
||||
|
||||
/** 找某租戶某 credential 的 entry(page_name=name 精確比對,entry_type=credential 隔離)。 */
|
||||
async function findCredentialEntry(env: Bindings, apiKey: string, name: string): Promise<KbdbEntryRow | null> {
|
||||
const qs = new URLSearchParams({
|
||||
owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, page_name: name, limit: '1',
|
||||
});
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) throw new Error(`KBDB /entries 查詢失敗:HTTP ${res.status}`);
|
||||
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||
return body?.entries?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* upsert credential 目錄列(不含密文)。
|
||||
* created_at 只在首次建立時寫入(entries 表自帶 created_at,PATCH 不會動它);
|
||||
* last_used_at 覆寫時保留原值——secret_ref 是純函式衍生自 api_key+name,理論上覆寫時值不會
|
||||
* 變,這裡仍走同一條寫入路徑以求同時支援「首次建立」與「覆寫」兩種呼叫路徑(比照舊 D1 版本)。
|
||||
*/
|
||||
async function upsertCredentialEntry(
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
name: string,
|
||||
service: string | null,
|
||||
sensitivity: 'standard' | 'high',
|
||||
secretRef: string,
|
||||
): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(api_key, name) DO UPDATE SET
|
||||
service = excluded.service,
|
||||
sensitivity = excluded.sensitivity,
|
||||
secret_ref = excluded.secret_ref`,
|
||||
)
|
||||
.bind(apiKey, name, service, sensitivity, secretRef, now)
|
||||
.run();
|
||||
const existing = await findCredentialEntry(env, apiKey, name);
|
||||
const meta: CredentialMeta = {
|
||||
service, sensitivity, secret_ref: secretRef,
|
||||
last_used_at: existing ? parseMeta(existing).last_used_at : null,
|
||||
};
|
||||
if (existing) {
|
||||
const res = await kbdbCredFetch(env, `/entries/${encodeURIComponent(existing.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`credential 目錄更新失敗:HTTP ${res.status}`);
|
||||
} else {
|
||||
const res = await kbdbCredFetch(env, `/entries`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entry_type: CREDENTIAL_ENTRY_TYPE, owner_id: apiKey, page_name: name,
|
||||
metadata_json: JSON.stringify(meta),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`credential 目錄建立失敗:HTTP ${res.status}`);
|
||||
}
|
||||
invalidateCredentialCache(apiKey);
|
||||
}
|
||||
|
||||
interface CredentialRow {
|
||||
@@ -160,25 +358,26 @@ interface CredentialRow {
|
||||
last_used_at: number | null;
|
||||
}
|
||||
|
||||
/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||
async function listCredentialRows(db: D1Database, apiKey: string): Promise<CredentialRow[]> {
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT name, service, sensitivity, created_at, last_used_at
|
||||
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
|
||||
)
|
||||
.bind(apiKey)
|
||||
.all<CredentialRow>();
|
||||
return rows.results ?? [];
|
||||
/** KBDB 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||
async function listCredentialRows(env: Bindings, apiKey: string): Promise<CredentialRow[]> {
|
||||
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) throw new Error(`credential 目錄查詢失敗:HTTP ${res.status}`);
|
||||
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||
const rows = (body?.entries ?? [])
|
||||
.filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
|
||||
.map((e) => {
|
||||
const meta = parseMeta(e);
|
||||
return { name: e.page_name, service: meta.service, sensitivity: meta.sensitivity, created_at: e.created_at, last_used_at: meta.last_used_at };
|
||||
});
|
||||
// entries API 已用 created_at DESC 排序,這裡不重排(保持與舊版 D1 query 相同排序語意)
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */
|
||||
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`)
|
||||
.bind(apiKey, name)
|
||||
.first<{ secret_ref: string }>();
|
||||
return row?.secret_ref ?? null;
|
||||
/** 給 `GET /portal/admin/ai` 之類「只要知道有沒有存過、不要值」的呼叫端用。 */
|
||||
export async function hasCredential(env: Bindings, apiKey: string, name: string): Promise<boolean> {
|
||||
const entry = await findCredentialEntry(env, apiKey, name);
|
||||
return entry !== null;
|
||||
}
|
||||
|
||||
interface CredentialWriteBody {
|
||||
@@ -203,13 +402,13 @@ async function writeCredential(
|
||||
// 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回)
|
||||
await putWorkerSecret(env, secretRef, value);
|
||||
|
||||
// 2. D1 目錄(不含密文)
|
||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
// 2. KBDB 目錄(不含密文)
|
||||
await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
|
||||
return { secretRef, sensitivity };
|
||||
}
|
||||
|
||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄)
|
||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + KBDB entries 目錄)
|
||||
credentialsRouter.post('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
@@ -272,17 +471,17 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
|
||||
const name = c.req.param('name');
|
||||
try {
|
||||
const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
|
||||
if (secretRef) {
|
||||
await deleteWorkerSecret(c.env, secretRef);
|
||||
await c.env.CREDENTIALS_DB
|
||||
.prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`)
|
||||
.bind(apiKey, name)
|
||||
.run();
|
||||
const entry = await findCredentialEntry(c.env, apiKey, name);
|
||||
if (entry) {
|
||||
const meta = parseMeta(entry);
|
||||
if (meta.secret_ref) await deleteWorkerSecret(c.env, meta.secret_ref);
|
||||
const res = await kbdbCredFetch(c.env, `/entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`credential 目錄刪除失敗:HTTP ${res.status}`);
|
||||
invalidateCredentialCache(apiKey);
|
||||
return c.json({ success: true, name, source: 'workers-secrets' });
|
||||
}
|
||||
// D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||
// 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||
// KBDB 沒有這筆 entry:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||
// 避免「GET 改讀新家看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||
await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`);
|
||||
return c.json({ success: true, name, source: 'legacy-kv' });
|
||||
} catch (e) {
|
||||
@@ -290,8 +489,8 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||
// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑;
|
||||
// GET /credentials/catalog — 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||
// 與 GET /credentials(下方,改讀同一份 KBDB 查詢)是同一份資料的兩個路徑;
|
||||
// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。
|
||||
credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
@@ -299,22 +498,22 @@ credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
const rows = await listCredentialRows(c.env, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
|
||||
// 誠實回報:KBDB 不可達 / 回錯(不假綠回空陣列裝沒事)
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials — 列出 credential 目錄(T9:改讀 D1,只回 metadata,絕不含值/secret_ref)
|
||||
// GET /credentials — 列出 credential 目錄(改讀 KBDB,只回 metadata,絕不含值/secret_ref)
|
||||
credentialsRouter.get('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
const rows = await listCredentialRows(c.env, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user