Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 525faaf5d0 | |||
| b6ef0f07dc | |||
| 507620e313 | |||
| 1e94f8451e | |||
| dcb6ad693b | |||
| 3b0238bb28 | |||
| 788295ed71 | |||
| 797e7f751c | |||
| d1c44a5878 | |||
| a7e23badf2 | |||
| eebb691426 | |||
| c76e10d314 | |||
| 9a29eb5af6 | |||
| 8d49d883c0 | |||
| 417d69ceb3 | |||
| 035e8b255b | |||
| 1c630ecfd4 | |||
| a99d3e5a3e | |||
| 894408181f | |||
| d022ca067b | |||
| 6715402bcc | |||
| c4cee35adb | |||
| ae81d22775 | |||
| 9740794050 | |||
| 453dec60b1 | |||
| bfc98fe41a | |||
| be9b92eb28 | |||
| 19c82df05f | |||
| 23d36b311a | |||
| ebd4bf5d97 | |||
| 889b70b8f9 | |||
| 21be6d19f7 | |||
| 6846d6ddae | |||
| 8eb10049b8 | |||
| 831cb62d2e | |||
| 894d9abeb1 | |||
| 3447efc94e | |||
| 4ca23c256a | |||
| aa6b899276 | |||
| 466e56bc2d | |||
| 07cc7f51b5 | |||
| 42cb1d7aa9 | |||
| e730b3f831 | |||
| 84471659af | |||
| 93b1140bf1 | |||
| 962d863ef7 | |||
| 7dbd4f59e7 | |||
| d779a11958 | |||
| c7a0b317cb | |||
| 046ceba29c | |||
| ac4fb56c91 | |||
| 1e2ef6806a | |||
| 5388f40c03 | |||
| 83aa1f6bb2 | |||
| 9344562258 | |||
| 7ba78552a4 | |||
| 60688c3108 | |||
| 36a5630c63 | |||
| 05b215c9f7 | |||
| 0ff369f818 | |||
| fe0ee8296a | |||
| b9b5d98d75 | |||
| 3d21bf0725 | |||
| 5783420fcf | |||
| 1eefce952b | |||
| 6c5706ba7d | |||
| 5d78806806 | |||
| 1b6b775c0e | |||
| 66f1b59f7f | |||
| eee3f93815 | |||
| f3af5d3631 | |||
| 47c6aaea03 | |||
| 5b983c47b8 | |||
| 36cdc8fba6 | |||
| 0d49989c19 | |||
| c735b911a0 | |||
| eefbed98b6 | |||
| a9a47b7c37 | |||
| e688d805ea | |||
| c9feb15a37 | |||
| 5f5c0a89e2 | |||
| 323ccc8475 | |||
| 9f777ff43e | |||
| 46afea83c2 | |||
| e28e19069f | |||
| cdca296044 | |||
| e8bd518efa | |||
| 159f0b07dc | |||
| 9d38d580f2 | |||
| 0860e84d22 | |||
| 6d4980d3d7 | |||
| ccb86481ae | |||
| eb9f2db513 | |||
| 429b2d965f | |||
| f6728974ea | |||
| 2ff36962be | |||
| e36cd2d990 | |||
| ba92d10a3f | |||
| a909072dc1 | |||
| 11e772496f | |||
| 53c6334fd9 | |||
| d7fab7c6aa | |||
| 139d4c5ed1 | |||
| e7fe83a872 | |||
| 8d19d0b2d7 | |||
| a8d246ebe9 | |||
| 11ffd42899 | |||
| 24f989d818 | |||
| 9bbd25fdfc | |||
| 76b89be6d7 | |||
| beb0653e15 | |||
| 5491409003 | |||
| 0617dab70a |
@@ -32,6 +32,33 @@ SDD 協議要求:code 和 SDD 必須同步更新。
|
|||||||
EOF
|
EOF
|
||||||
fi
|
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.md 變動,提醒 commit
|
||||||
TASKS_DIFF=$(git -C "$(pwd)" status --porcelain -- 'docs/3-specs/**/tasks.md' 2>/dev/null | head -5)
|
TASKS_DIFF=$(git -C "$(pwd)" status --porcelain -- 'docs/3-specs/**/tasks.md' 2>/dev/null | head -5)
|
||||||
if [[ -n "$TASKS_DIFF" ]]; then
|
if [[ -n "$TASKS_DIFF" ]]; then
|
||||||
|
|||||||
+20
@@ -6,6 +6,10 @@ dist/
|
|||||||
# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署
|
# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署
|
||||||
# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6)
|
# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6)
|
||||||
!.component-builds/**/component.wasm
|
!.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 從
|
# 例外:code 零件(自足 Worker)的 vendored quickjs.wasm 同屬部署物 —— acr init/update 從
|
||||||
# repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件
|
# repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件
|
||||||
# @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs
|
# @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs
|
||||||
@@ -52,3 +56,19 @@ backup-*.sql
|
|||||||
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
||||||
.github-public/
|
.github-public/
|
||||||
wrangler.leo21c.toml
|
wrangler.leo21c.toml
|
||||||
|
|
||||||
|
# deploy-all.mjs 產的共用依賴(部署時 npm 安裝 wrangler 等,非 repo 內容)
|
||||||
|
# 2026-08-07:每次本機跑部署都會冒出來吵未推警察,且含不該進版控的鎖檔
|
||||||
|
/package.json
|
||||||
|
/package-lock.json
|
||||||
|
|
||||||
|
# console-ui 部署產物(deploy.mjs 依 deploy.targets.json 即時產生,不是原始碼)
|
||||||
|
console-ui/.staging/
|
||||||
|
# 「上一次通過線上實測的部署」紀錄——本機事實,不隨 repo 走
|
||||||
|
# (刻意不進版控:新 checkout 沒有紀錄 ⇒ 狀態未知 ⇒ 該被大聲提醒,而不是繼承別人的綠燈)
|
||||||
|
console-ui/.deploy-state.json
|
||||||
|
|
||||||
|
# Wrangler 本機開發用的密鑰檔——絕不進版控(2026-08-09 補:原本沒被擋,
|
||||||
|
# 而同目錄有 agent 在動工,一次 git add -A 就會把金鑰推上去)
|
||||||
|
.dev.vars
|
||||||
|
**/.dev.vars
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"built_for": "arcrun-tier2-worker-artifacts",
|
||||||
|
"generated_at": "2026-08-11T05:33:37.988Z",
|
||||||
|
"repo_head": "d8bbf2241bd6b117d76fb27d9e386ecfb0ffe8f7",
|
||||||
|
"repo_dirty": false,
|
||||||
|
"workers": [
|
||||||
|
{
|
||||||
|
"name": "arcrun-cypher-executor",
|
||||||
|
"source_dir": "cypher-executor",
|
||||||
|
"source_commit": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
|
||||||
|
"main_module": "worker.mjs",
|
||||||
|
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||||
|
"js_bytes": 568855,
|
||||||
|
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
|
||||||
|
"modules": [],
|
||||||
|
"compat_date": "2025-02-19",
|
||||||
|
"compat_flags": [
|
||||||
|
"nodejs_compat",
|
||||||
|
"global_fetch_strictly_public"
|
||||||
|
],
|
||||||
|
"requires": {
|
||||||
|
"kv": [
|
||||||
|
"EXEC_CONTEXT",
|
||||||
|
"WEBHOOKS",
|
||||||
|
"CREDENTIALS_KV",
|
||||||
|
"ANALYTICS_KV",
|
||||||
|
"RECIPES",
|
||||||
|
"USERS_KV",
|
||||||
|
"SESSIONS_KV"
|
||||||
|
],
|
||||||
|
"d1": [
|
||||||
|
{
|
||||||
|
"binding": "CREDENTIALS_DB",
|
||||||
|
"database_name": "arcrun-kbdb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vectorize": 0,
|
||||||
|
"ai": true,
|
||||||
|
"vars": {
|
||||||
|
"ENVIRONMENT": "production",
|
||||||
|
"CF_ACCOUNT_ID": "",
|
||||||
|
"WORKER_SUBDOMAIN": "uncle6-me",
|
||||||
|
"KBDB_BASE_URL": "https://arcrun-kbdb.uncle6-me.workers.dev",
|
||||||
|
"CONSOLE_TENANT": "leo",
|
||||||
|
"PORTAL_SESSION_TTL": "604800",
|
||||||
|
"PORTAL_SHOW_WORKFLOWS": "admin",
|
||||||
|
"GITEA_BASE_URL": "https://git.uncle6.me",
|
||||||
|
"GITEA_SPRINT_REPO": "Leo/InkStoneCo",
|
||||||
|
"GITEA_SPRINT_DIR": "system-dev/docs/3-specs/autonomy-dispatch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stripped": {
|
||||||
|
"services": 13
|
||||||
|
},
|
||||||
|
"warnings": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "arcrun-kbdb",
|
||||||
|
"source_dir": "kbdb",
|
||||||
|
"source_commit": "a7e23badf2a771be779a861e69e7efa6e8141dfe",
|
||||||
|
"main_module": "worker.mjs",
|
||||||
|
"main_file": "arcrun-kbdb/worker.mjs",
|
||||||
|
"js_bytes": 135910,
|
||||||
|
"content_sha256": "5e5a7a030f4fd1f5549ace6791c3827b6497b0bfdd9add46af041af47c472905",
|
||||||
|
"modules": [],
|
||||||
|
"compat_date": "2025-02-19",
|
||||||
|
"compat_flags": [
|
||||||
|
"nodejs_compat"
|
||||||
|
],
|
||||||
|
"requires": {
|
||||||
|
"kv": [],
|
||||||
|
"d1": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "arcrun-kbdb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vectorize": 0,
|
||||||
|
"ai": false,
|
||||||
|
"vars": {
|
||||||
|
"ENVIRONMENT": "production"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warnings": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "arcrun-http-request",
|
||||||
|
"source_dir": ".component-builds/http_request",
|
||||||
|
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
||||||
|
"main_module": "worker.mjs",
|
||||||
|
"main_file": "arcrun-http-request/worker.mjs",
|
||||||
|
"js_bytes": 80073,
|
||||||
|
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "component.wasm",
|
||||||
|
"type": "application/wasm",
|
||||||
|
"file": "arcrun-http-request/component.wasm",
|
||||||
|
"sha256": "cc15cc785703e7bbb8dbff2d38dc84a4ac24e2f44316182730abae0f170ef133"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compat_date": "2025-02-19",
|
||||||
|
"compat_flags": [
|
||||||
|
"nodejs_compat",
|
||||||
|
"global_fetch_strictly_public"
|
||||||
|
],
|
||||||
|
"requires": {
|
||||||
|
"kv": [],
|
||||||
|
"d1": [],
|
||||||
|
"vectorize": 0,
|
||||||
|
"ai": false,
|
||||||
|
"vars": {
|
||||||
|
"COMPONENT_ID": "http_request"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warnings": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "arcrun-code",
|
||||||
|
"source_dir": "registry/components/code",
|
||||||
|
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||||
|
"main_module": "worker.mjs",
|
||||||
|
"main_file": "arcrun-code/worker.mjs",
|
||||||
|
"js_bytes": 153671,
|
||||||
|
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "quickjs.wasm",
|
||||||
|
"type": "application/wasm",
|
||||||
|
"file": "arcrun-code/quickjs.wasm",
|
||||||
|
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compat_date": "2025-02-19",
|
||||||
|
"compat_flags": [],
|
||||||
|
"requires": {
|
||||||
|
"kv": [],
|
||||||
|
"d1": [],
|
||||||
|
"vectorize": 0,
|
||||||
|
"ai": false,
|
||||||
|
"vars": {
|
||||||
|
"COMPONENT_ID": "code"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warnings": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "arcrun-mcp",
|
||||||
|
"source_dir": "mcp",
|
||||||
|
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
||||||
|
"main_module": "worker.mjs",
|
||||||
|
"main_file": "arcrun-mcp/worker.mjs",
|
||||||
|
"js_bytes": 1165130,
|
||||||
|
"content_sha256": "be15033f32e605f03f69bd10cd87782dafa34dbafeee2ce367bd7361a062a291",
|
||||||
|
"modules": [],
|
||||||
|
"compat_date": "2024-11-27",
|
||||||
|
"compat_flags": [
|
||||||
|
"nodejs_compat"
|
||||||
|
],
|
||||||
|
"requires": {
|
||||||
|
"kv": [
|
||||||
|
"OAUTH_KV"
|
||||||
|
],
|
||||||
|
"d1": [],
|
||||||
|
"vectorize": 0,
|
||||||
|
"ai": false,
|
||||||
|
"vars": {}
|
||||||
|
},
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": []
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
**讓 AI 用的工作流軟體(目前只支援 Claude Code)**
|
**讓 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 及時間,但絕大部分是重複內容,例如登入認證、存取資料庫等。
|
AI 很會寫程式,就要除錯,過程浪費很多 Token 及時間,但絕大部分是重複內容,例如登入認證、存取資料庫等。
|
||||||
|
|
||||||
@@ -310,7 +313,7 @@ acr update self-hosted:拉新版零件/引擎並重新
|
|||||||
acr update --force 強制重部所有 worker(忽略未變動跳過快取)
|
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 工具鏈與這份文件,由以下貢獻者共同打造:
|
arcrun 的核心架構、WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造:
|
||||||
|
|
||||||
- **[@richblack](https://github.com/richblack)** — 創始人,產品設計與架構決策
|
- **[@youlinhsieh](https://github.com/youlinhsieh)** — 創始人,產品設計與架構決策
|
||||||
- **Claude(Anthropic)** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查
|
- **Claude(Anthropic)** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查
|
||||||
|
|
||||||
歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md)
|
想投稿零件?看 [CONTRIBUTING-components.md](CONTRIBUTING-components.md)。
|
||||||
|
|||||||
+49
-25
@@ -1,41 +1,65 @@
|
|||||||
<!-- arcrun-harness:start -->
|
<!-- arcrun-harness:start -->
|
||||||
## 這個專案用 arcrun 做自動化
|
## 這個專案用 Arcrun 做自動化
|
||||||
|
|
||||||
當需要「串服務 / 排程 / 打 API / 資料處理自動化」時,用 arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
|
當需要「串服務/排程/打 API/資料處理自動化」時,用 Arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。
|
||||||
|
Arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
|
||||||
|
|
||||||
arcrun 只有三種東西,先分清楚就不會做歪:
|
⚠️ **不要上網搜 Arcrun 文件**——網路上沒有,找到的都是錯的。
|
||||||
|
語法與範本全在 **arcrun-mindset** Skill 裡,`acr` 指令查得到現況。
|
||||||
|
|
||||||
|
### 怎麼開始:先寫「意圖」,不要先寫 YAML
|
||||||
|
|
||||||
|
你**不需要先知道有哪些零件**。把需求寫成一串「誰接誰」,丟去查,系統會告訴你哪些存在:
|
||||||
|
|
||||||
|
```
|
||||||
|
input >> ON_SUCCESS >> fetch_rows
|
||||||
|
fetch_rows >> 對每個 row >> notify
|
||||||
|
```
|
||||||
|
|
||||||
|
- 第一個節點固定是 `input`(觸發時帶進來的資料)
|
||||||
|
- **邊有這些**:`ON_SUCCESS`、`對每個 <變數>`(FOREACH)、`ON_TRUE`/`ON_FALSE`(配 `if_control`)、`ON_BRANCH`+`branch:`(配 `switch`/`try_catch`)
|
||||||
|
- **沒有** `ON_FAILURE`——要處理失敗用 `try_catch` + `ON_BRANCH(catch)`。
|
||||||
|
|
||||||
|
完整語法、四份實跑過的範本、節點命名慣例 → 讀 **arcrun-mindset** Skill。
|
||||||
|
|
||||||
|
### Arcrun 只有三種東西,先分清楚就不會做歪
|
||||||
|
|
||||||
| 東西 | 是什麼 | 你能做的 |
|
| 東西 | 是什麼 | 你能做的 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
||||||
| **recipe** | 打「一個固定外部 API」的設定(http_request + endpoint/header/body 模板) | 自由寫、**而且該投稿**(見下) |
|
| **recipe** | 打「一個固定外部 API」的設定(endpoint/header/body 模板) | 自由寫、**而且該投稿** |
|
||||||
| **零件(component)** | WASM 程式(流程控制 / 資料處理 / http_request / auth),固定一小套 | **你不自製**,由維護者管,走 GitHub PR |
|
| **零件(component)** | WASM 程式(流程控制/資料處理/`http_request`/auth),固定一小套 | **你不自製**,走 PR |
|
||||||
|
|
||||||
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||||
|
>
|
||||||
|
> ⚠️ 常見錯誤:把 `telegram_send`/`gmail_send`/`kbdb_get` 當**零件**寫。它們是 **recipe**。
|
||||||
|
|
||||||
### 三個最常踩的坑(讀懂這三條,就不會像前人誤解四輪)
|
### 查詢回 `not_found` 時:兩條路,都不准改寫成 code
|
||||||
|
|
||||||
**① recipe 是公共資產,發現缺的就自己做一個投稿,不必問人。**
|
查詢會誠實回報(兩庫都查過才點名):
|
||||||
`acr recipe push` = 把 recipe **投稿到公共庫**,不是存私人腳本。公開/私有只是 recipe 的一個屬性(同一身份可有公私版本並存),不是兩條不同的路。
|
|
||||||
→ 所以「想打某個 API 但沒有現成 recipe」時,**正解是自己寫一個 recipe 並 push 上去**(會 inject credential、push 時幫你檢查打不打得通)。這是被鼓勵的,別誤以為要自用、不上傳。
|
|
||||||
|
|
||||||
**② 缺一個能力 → 去補 API(cypher endpoint),不准用 recipe / 多步工作流拼裝出來。**
|
| status | 意思 | 你該做什麼 |
|
||||||
判準口訣:**「這段邏輯換一個介面(CLI→MCP)要不要重寫?要重寫 → 它是『能力』,該長在 API。」**
|
|---|---|---|
|
||||||
- ❌ 缺 `upsert` → 在工作流裡拼「先查、沒有再建」、或寫個 recipe 假裝補上。
|
| `found` / `resolved` | 有現成的可用 | **只填 payload** |
|
||||||
- ✅ 缺 `upsert` → 去 API 加一個 `upsert` endpoint,CLI/MCP/recipe 都呼叫它。
|
| `not_found` | 零件庫與 recipe 庫都沒有 | 照回應的 `suggestion` 走(見下兩條路),並看 `similar_components`/`similar_recipes` 有沒有能直接用的 |
|
||||||
recipe 只負責「打一個固定外部 API」這件單純事;它不是用來補 arcrun 自己缺的能力的。缺能力就回報 / 補在 API,不要繞。
|
| `unknown` | 查不到 registry(未部署/網路失敗) | **不代表不存在**,別據此改寫成 code |
|
||||||
|
|
||||||
**③ 已經有自製零件(例如 mira 的那幾個)→ 讓它退場,別再加新的。**
|
- **缺外部 API** → **自己寫一個 recipe** 並 `acr recipe push`(幾行 YAML,不用部署 Worker、不用寫程式)。
|
||||||
你不該自製零件;既有的自製零件要往這三條退場:
|
recipe 是公共資產,發現缺的就補一個投稿,不必問人。
|
||||||
- `claude_api` 之類「工作流回頭叫 LLM」→ **刪掉**,需要 AI 判斷時是**你(操盤的 CC)自己做**,再叫工作流做確定性的下一步。arcrun 是 AI 用的工具,不是工具回頭用 AI。
|
- **缺計算能力**(加解密/壓縮這類純運算) → 投稿**零件 PR**(要人類確認,罕見)。
|
||||||
- `kbdb_*` 之類資料存取 → 改走已備好的 **`acr kbdb` 薄殼 / `kbdb_*` MCP 工具**(template + record 模型),不要當零件。
|
|
||||||
- 純粹打某個固定外部 API 的假零件 → **改寫成 recipe** 投稿(見①)。
|
🔴 **查不到就改寫成 `code` 節點 =「腹語術」**(表面用 Arcrun、實際全寫 JS)。
|
||||||
|
`code` 只用於**局部整形**(例:剝掉 LLM 回應的雜訊、切段落),不用來取代零件與流程控制。
|
||||||
|
> 實錄:每一個寫進 `code` 的 `if` 都是沒被測過的新 bug;零件的價值是「被測過 1000 次」,寫進 code 就歸零。
|
||||||
|
|
||||||
### 其餘鐵律
|
### 其餘鐵律
|
||||||
|
|
||||||
- **先查能力再動手**:`acr parts`(看可用零件)、`acr auth-recipe list`(看支援的認證服務)、`acr kbdb`(資料存取)。
|
- **先查能力再動手**:`acr search <關鍵字>`(一次掃零件/recipe/auth-recipe/workflow)、
|
||||||
- **暴露資料要人類同意**:部署對外 webhook / push recipe 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
|
`acr parts`(零件)、`acr recipe list`(recipe)、`acr auth-recipe list`(支援的認證)。
|
||||||
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx / trace 為證,不口頭宣布。
|
- **需要 AI 判斷時你自己做**,不要讓工作流回頭呼叫 LLM。Arcrun 是 AI 用的工具,不是工具回頭用 AI。
|
||||||
|
- **金鑰只拿名字**:定義裡只寫 `{{credential.<名字>}}`,真身絕不寫進 workflow/recipe 檔案。
|
||||||
|
- **暴露資料要人類同意**:`acr push`/`acr recipe push` 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
|
||||||
|
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx/trace 為證,不口頭宣布。
|
||||||
|
|
||||||
開始前讀 **arcrun-mindset** Skill(世界觀)。使用者技術細節交給你,CLI 操作你來做。
|
開始前讀 **arcrun-mindset** Skill(意圖語法+範本+世界觀)。使用者技術細節交給你,CLI 操作你來做。
|
||||||
<!-- arcrun-harness:end -->
|
<!-- arcrun-harness:end -->
|
||||||
|
|||||||
@@ -1,26 +1,60 @@
|
|||||||
# 用 arcrun 完成這個自動化需求
|
# 用 Arcrun 完成這個自動化需求
|
||||||
|
|
||||||
使用者想做一個自動化。你的任務:用 arcrun 做出來,全程不要讓使用者自己寫程式。
|
使用者想做一個自動化。你的任務:用 Arcrun 做出來,全程不要讓使用者自己寫程式。
|
||||||
|
|
||||||
|
⚠️ **不要上網搜 Arcrun 文件**(網路上沒有)。先讀 **arcrun-mindset** Skill,再用 `acr` 指令查現況。
|
||||||
|
|
||||||
## 鐵則
|
## 鐵則
|
||||||
- **用 arcrun 工作流 / recipe,絕不自己寫 Python/Node 腳本。** 使用者選 arcrun 就是不想要一次性腳本。
|
- **用 Arcrun 工作流/recipe,絕不自己寫 Python/Node 腳本。** 使用者選 Arcrun 就是不想要一次性腳本。
|
||||||
- 打外部 API → 寫 recipe(`acr recipe push`),不自刻 HTTP client。
|
- **打外部 API → 寫 recipe**(`acr recipe push`),不自刻 HTTP client。缺 recipe 就自己補一個,不必問人。
|
||||||
- 不自製零件(WASM)—— 零件由 arcrun 維護。你能用的是現有零件 + recipe + 工作流。
|
- **不自製零件(WASM)**——零件由 Arcrun 維護。你能用的是現有零件 + recipe + 工作流。
|
||||||
- 需要 AI 判斷時你自己做,不要讓工作流回頭呼叫 LLM。
|
- **需要 AI 判斷時你自己做**,不要讓工作流回頭呼叫 LLM。
|
||||||
|
- 🔴 **查不到零件就改寫成 `code` 節點 = 腹語術**,禁止。缺 API 寫 recipe、缺能力投稿零件。
|
||||||
|
|
||||||
## 步驟
|
## 步驟
|
||||||
1. 先讀 **arcrun-mindset** Skill(世界觀 + 資源去哪取)。
|
|
||||||
2. 跑 `acr parts` 看零件、`acr auth-recipe list` 看支援的認證。**先查再動手。**
|
|
||||||
3. 把使用者需求拆成工作流(哪些零件、什麼順序、什麼條件),寫成 `.yaml`。
|
|
||||||
4. 需要 credential(API key / token)→ 用 `acr auth-recipe scaffold <service>` 看要哪些,
|
|
||||||
明確告訴使用者去哪取得、怎麼 `acr creds push`。
|
|
||||||
5. `acr validate` 通過後 `acr push` 部署,告訴使用者 webhook URL / 怎麼 `acr run`。
|
|
||||||
6. 完成給客觀證據(HTTP 2xx / trace),不要只說「做好了」。
|
|
||||||
|
|
||||||
## 遇到要暴露資料(對外 webhook)
|
### 1. 先寫「意圖」,不要先寫 YAML
|
||||||
|
把使用者的需求寫成一串「誰接誰」(**不必是真實零件名**,用你想得到的名字即可):
|
||||||
|
|
||||||
|
```
|
||||||
|
input >> ON_SUCCESS >> fetch_rows
|
||||||
|
fetch_rows >> 對每個 row >> notify
|
||||||
|
```
|
||||||
|
|
||||||
|
- 第一個節點固定是 `input`
|
||||||
|
- 邊有 `ON_SUCCESS`、`對每個 <變數>`(FOREACH)、`ON_TRUE`/`ON_FALSE`(配 `if_control`)、`ON_BRANCH`+`branch:`(配 `switch`/`try_catch`);**沒有** `ON_FAILURE`
|
||||||
|
- 需要判斷 → 用條件邊(`if_control` 配 `ON_TRUE`/`ON_FALSE`),不要寫 code 判斷
|
||||||
|
|
||||||
|
語法細節、四份實跑過的範本、節點命名慣例 → **arcrun-mindset** Skill。
|
||||||
|
|
||||||
|
### 2. 丟去查,讓系統告訴你有什麼
|
||||||
|
`acr search <關鍵字>` 一次掃零件/recipe/auth-recipe/workflow;
|
||||||
|
或把意圖串丟 `/cypher/search`,逐節點拿 `found` / `resolved` / `not_found` / `unknown`。
|
||||||
|
|
||||||
|
- `found`/`resolved` → **只填 payload**
|
||||||
|
- `not_found` → 照回應的 `suggestion` 走(缺 API 寫 recipe、缺計算能力投稿零件),
|
||||||
|
並看 `similar_components`/`similar_recipes` 有沒有現成能用的
|
||||||
|
- `unknown` → **不代表不存在**,別據此改寫成 code
|
||||||
|
|
||||||
|
### 3. 把意圖變成 workflow YAML
|
||||||
|
節點填上查到的真實零件/recipe + payload。
|
||||||
|
需要 credential 時:`acr auth-recipe scaffold <service>` 看要哪些,明確告訴使用者去哪取得、怎麼 `acr creds push`。
|
||||||
|
🔑 定義裡只寫 `{{credential.<名字>}}`,**真身絕不寫進檔案**。
|
||||||
|
|
||||||
|
### 4. 驗證 → 部署 → 給證據
|
||||||
|
```bash
|
||||||
|
acr validate <workflow>.yaml # 先驗
|
||||||
|
acr push <workflow>.yaml # 部署(暴露動作,見下)
|
||||||
|
acr run <workflow> # 觸發一次
|
||||||
|
acr logs <workflow> # 看執行紀錄
|
||||||
|
```
|
||||||
|
完成要給客觀證據(HTTP 2xx/trace),不要只說「做好了」。
|
||||||
|
|
||||||
|
## 遇到要暴露資料(對外 webhook/recipe 投稿)
|
||||||
停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。不要替他決定公開。
|
停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。不要替他決定公開。
|
||||||
|
非互動環境下把完整指令印給使用者自己貼上跑。
|
||||||
|
|
||||||
## 還沒設定好 arcrun?
|
## 還沒設定好 Arcrun?
|
||||||
若 `acr` 指令不存在或還沒 `acr init`:先帶使用者完成前置設定
|
若 `acr` 指令不存在或還沒 `acr init`:先帶使用者完成前置設定
|
||||||
(裝 CLI → 拿 Cloudflare 帳號的兩串憑證 → `acr init --self-hosted`)。
|
(裝 CLI → 拿 Cloudflare 帳號的兩串憑證 → `acr init --self-hosted`)。
|
||||||
拿 Cloudflare 憑證時用白話照抄式引導,不要對使用者講 KV / Worker / R2 等術語。
|
拿 Cloudflare 憑證時用白話照抄式引導,不要對使用者講 KV / Worker / R2 等術語。
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ if echo "$CMD" | grep -qE "acr (push|recipe push)\b"; then
|
|||||||
if echo "$EXEC_PART" | grep -qE "(^|[;&|][[:space:]]*)acr[[:space:]]+(push|recipe[[:space:]]+push)\b"; then
|
if echo "$EXEC_PART" | grep -qE "(^|[;&|][[:space:]]*)acr[[:space:]]+(push|recipe[[:space:]]+push)\b"; then
|
||||||
if [ ! -t 0 ] && [ "${ARCRUN_HUMAN_CONFIRMED:-}" != "1" ]; then
|
if [ ! -t 0 ] && [ "${ARCRUN_HUMAN_CONFIRMED:-}" != "1" ]; then
|
||||||
block "在非互動環境自動執行暴露動作(acr push / recipe push 會讓東西可被外部呼叫)" \
|
block "在非互動環境自動執行暴露動作(acr push / recipe push 會讓東西可被外部呼叫)" \
|
||||||
"交人類在終端機執行(真 TTY 會自動放行)。可把指令完整複製給使用者貼上自己跑:\`acr push <你的 workflow.yaml>\`。或使用者先在對話明示同意後親自於終端機執行。不要替使用者決定公開。"
|
"交人類在終端機執行(真 TTY 會自動放行)。可把指令完整複製給使用者貼上自己跑:\`acr push <你的 workflow.yaml>\`。或使用者先在對話明示同意後親自於終端機執行。不要替使用者決定公開。(部署前的正路見 arcrun-mindset Skill:先 \`acr validate\`)"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -76,15 +76,29 @@ fi
|
|||||||
if echo "$CMD" | grep -qE "(^|[;&| ])(python3?|node)[ ]+[^ ]+\.(py|js|mjs|ts)\b"; then
|
if echo "$CMD" | grep -qE "(^|[;&| ])(python3?|node)[ ]+[^ ]+\.(py|js|mjs|ts)\b"; then
|
||||||
# 排除明顯的測試 / 既有工具呼叫(pytest / npm test / jest 等)降低誤判
|
# 排除明顯的測試 / 既有工具呼叫(pytest / npm test / jest 等)降低誤判
|
||||||
if ! echo "$CMD" | grep -qE "(pytest|jest|vitest|npm (run )?test|mocha|\btest_)"; then
|
if ! echo "$CMD" | grep -qE "(pytest|jest|vitest|npm (run )?test|mocha|\btest_)"; then
|
||||||
remind "偵測到用 python/node 跑腳本。這專案用 arcrun,串服務/自動化不要自刻一次性腳本。" \
|
remind "偵測到用 python/node 跑腳本。這專案用 Arcrun,串服務/自動化不要自刻一次性腳本。" \
|
||||||
"先跑 \`acr parts\` 看有哪些零件,把需求寫成 workflow.yaml 用 \`acr run\`。若這確實不是自動化(例如跑測試/別的工具),忽略本提醒。"
|
"讀 arcrun-mindset Skill,先把需求寫成「意圖」串(\`input >> ON_SUCCESS >> <下一步>\`,邊只有 ON_SUCCESS 與「對每個 X」),再用 \`acr search <關鍵字>\` 查哪些零件/recipe 存在,最後才寫 workflow.yaml → \`acr validate\` → \`acr run\`。若這確實不是自動化(例如跑測試/別的工具),忽略本提醒。"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 提醒(不硬擋):自寫打固定 API 的 script,而非 recipe ──────────────
|
# ── 提醒(不硬擋):自寫打固定 API 的 script,而非 recipe ──────────────
|
||||||
if echo "$CMD" | grep -qE "(curl|fetch|requests\.(get|post)|axios).*https?://"; then
|
if echo "$CMD" | grep -qE "(curl|fetch|requests\.(get|post)|axios).*https?://"; then
|
||||||
remind "偵測到自己打外部 API。arcrun 裡「打固定 endpoint」應寫成 recipe,不自刻 HTTP 呼叫。" \
|
remind "偵測到自己打外部 API。Arcrun 裡「打固定 endpoint」應寫成 recipe,不自刻 HTTP 呼叫。" \
|
||||||
"用 \`acr recipe push\` 把這個 API 包成 recipe,workflow 裡用 component 引用它。見 arcrun-mindset Skill。"
|
"先 \`acr recipe search <服務名>\` 看有沒有現成的;沒有就自己寫幾行 YAML(canonical_id/endpoint/method/auth_service)用 \`acr recipe push\` 投稿,workflow 裡用 \`http_request\` + 該 recipe 引用它。缺 recipe 就自己補,不必問人。寫法見 arcrun-mindset Skill。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 提醒(不硬擋):把 code 節點當成缺零件的替代品(「腹語術」)──────────────
|
||||||
|
# 查詢回 not_found 就改寫成 code = 表面用 Arcrun、實際全寫 JS。這是現世代最常見的走歪。
|
||||||
|
if [ "$TOOL" = "Write" ] || [ "$TOOL" = "Edit" ] || [ "$TOOL" = "MultiEdit" ]; then
|
||||||
|
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
|
||||||
|
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // ""')
|
||||||
|
if echo "$FILE" | grep -qE '\.(ya?ml)$' && echo "$CONTENT" | grep -qE 'component:[[:space:]]*["'"'"']?code\b'; then
|
||||||
|
# 只在 code 內容看起來在做流程控制/取代零件時提醒(含 if/for/fetch),單純整形不吵
|
||||||
|
if echo "$CONTENT" | grep -qE '\b(if[[:space:]]*\(|for[[:space:]]*\(|fetch\(|await[[:space:]]+fetch)'; then
|
||||||
|
remind "workflow 裡的 \`code\` 節點含流程控制/HTTP 呼叫——這可能是「腹語術」(表面用 Arcrun、實際全寫 JS)。" \
|
||||||
|
"\`code\` 只用於局部整形(例:剝掉 LLM 回應的雜訊、切段落)。缺外部 API → 寫 recipe(\`acr recipe push\`);缺計算能力 → 投稿零件 PR;要判斷 → 用條件邊(\`if_control\` 配 \`ON_TRUE\`/\`ON_FALSE\`,或 \`switch\`/\`try_catch\` 配 \`ON_BRANCH\`),不要寫 code 判斷。每個寫進 code 的 if 都是沒被測過的新 bug。見 arcrun-mindset Skill。"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": ".claude/hooks/arcrun-guard.sh",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,285 @@
|
|||||||
---
|
---
|
||||||
name: arcrun-mindset
|
name: arcrun-mindset
|
||||||
description: >-
|
description: >-
|
||||||
arcrun 的世界觀 — 用 arcrun 開發自動化時的預設心態 + 資源去哪取。當你(AI 操盤手)要在
|
在 Arcrun 上做任何事時使用(用戶說「幫我用 Arcrun 做 X」「用 arcrun 寫一個工作流」「把 X 自動化」)。
|
||||||
arcrun 上做任何事(串服務、處理資料、認證、把東西開放給人用)前讀這個。它讓你做出「方向對」
|
Arcrun 是跑在 Cloudflare 上的工作流引擎——你用 `>>` 寫「意圖」,系統告訴你有哪些現成零件與 recipe,
|
||||||
的選擇、知道資源在哪,避免技術上能跑但架構上錯、或自己重刻 arcrun 已有的東西。
|
你只填 payload,不必自己寫程式。**不要上網搜 Arcrun 文件**(網路上沒有),也不要自己猜 YAML 格式:
|
||||||
|
先讀本 skill,再用 `acr` 指令(或 MCP 工具)查現成零件。
|
||||||
|
涵蓋:意圖工作流語法、四份實跑過的範本、零件 vs recipe 的分別、缺件的兩條路、已知的坑。
|
||||||
---
|
---
|
||||||
|
|
||||||
# arcrun mindset(給 AI 操盤手)
|
# Arcrun:怎麼寫意圖工作流
|
||||||
|
|
||||||
你在 arcrun 上幫使用者開發自動化。arcrun 很簡單,簡單到你常會把它想複雜、或退回自己熟悉的
|
> **你已經配備 Arcrun**(此專案裝了 `acr` CLI,可能另有 `arcrun_*` MCP 工具)。
|
||||||
Python/Node 自刻。這份幫你在岔路上選對方向,並告訴你資源在哪。
|
> **別上網找文件**——網路上沒有 Arcrun 的文件,找到的都是錯的。答案都在本 skill 與 `acr` 指令裡。
|
||||||
|
|
||||||
|
## 先做這三件(照順序)
|
||||||
|
|
||||||
|
1. `acr whoami` — 確認連到哪個帳號(**勿自行 curl 猜帳號 URL**)
|
||||||
|
2. 讀本 skill 下面的語法與範本 → 寫出 `>>` 意圖
|
||||||
|
3. `acr parts`/`acr recipe list`(或 `acr search <關鍵字>` 一次掃全部)— 確認零件與 recipe 真的存在
|
||||||
|
|
||||||
|
**卡住時**:`acr search <關鍵字>` 跨類搜尋;有 MCP 就 `arcrun_get_skill('INDEX')` 拿全館導航。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 一句話世界觀
|
## 0. 一句話世界觀
|
||||||
|
|
||||||
**arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 = 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
|
**Arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 = 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
|
||||||
你大部分時間在寫紙、改紙,不是在造新零件、也不是自己寫腳本。
|
你大部分時間在**寫紙、改紙**,不是在造新零件、也不是自己寫腳本。
|
||||||
|
|
||||||
|
**Arcrun 只有三種東西,先分清楚就不會做歪:**
|
||||||
|
|
||||||
|
| 東西 | 是什麼 | 你能做的 |
|
||||||
|
|---|---|---|
|
||||||
|
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
||||||
|
| **recipe** | 打「一個固定外部 API」的設定(endpoint/header/body 模板) | 自由寫、**而且該投稿**(缺就自己補) |
|
||||||
|
| **零件(component)** | WASM 程式(流程控制/資料處理/`http_request`/auth),固定一小套 | **你不自製**,走 PR 由維護者管 |
|
||||||
|
|
||||||
|
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 工作流是 default,不要退回自己寫 Python
|
<!-- 以下正文由 registry/skills/write_intent_workflow.md 於建置期複製而來(單一真相源)。
|
||||||
|
不要直接編輯本段——改 registry 那份,然後跑 `npm run build:harness`。 -->
|
||||||
|
|
||||||
使用者選 arcrun,就是不要「每次重刻、跑完即丟」的腳本。所以你的預設順序:
|
## 1. 意圖工作流的語法
|
||||||
|
|
||||||
1. **先想能不能用工作流做**(串現有零件 / recipe + 流程控制)。99% 可以。
|
一串「誰接誰」,每行一個關係:
|
||||||
2. 要打的服務有 HTTP API、但沒有對應 recipe → **寫一個 recipe**(http_request + 固定設定 YAML,不用部署、不用審核)。
|
|
||||||
3. **只有**封閉純邏輯(流程控制 / 資料處理)、現有零件不夠、且值得全 arcrun 重用 → 才考慮零件(而零件走 PR,不是你現在做)。
|
|
||||||
|
|
||||||
> 典型走歪:「我先用 Python 測一下」。停。使用者要的是 arcrun 工作流。先 `acr parts` 看有什麼,用工作流串。
|
```
|
||||||
|
<節點A> >> <邊> >> <節點B>
|
||||||
|
```
|
||||||
|
|
||||||
## 2. 資源去哪取(不要自己重造 arcrun 已有的)
|
- **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名**
|
||||||
|
- **邊**=什麼情況下往下走
|
||||||
|
|
||||||
|
## 2. 邊有這些
|
||||||
|
|
||||||
|
| 邊 | 意思 | 真例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ON_SUCCESS` | 上一步成功就往下 | `input >> ON_SUCCESS >> prep` |
|
||||||
|
| `對每個 <變數>` | 上一步產出清單,逐項處理(FOREACH)| `parse_card >> 對每個 block >> post_block` |
|
||||||
|
| `ON_TRUE` / `ON_FALSE` | 條件成立/不成立各走一條(配 `if_control`)| `判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram` |
|
||||||
|
| `ON_BRANCH`+`branch:` | 依標籤選路(配 `switch` 每個 case、`try_catch` 的 try/catch)| `my_switch >> ON_BRANCH(branch_active) >> 處理啟用` |
|
||||||
|
|
||||||
|
### 2.1 條件分支怎麼寫(2026-08-01 起引擎支援)
|
||||||
|
|
||||||
|
**需要判斷時,用分支邊,不要寫 `code` 判斷。**
|
||||||
|
三顆流程控制零件都輸出 `data.branch` 標籤,引擎依標籤選路:
|
||||||
|
|
||||||
|
| 零件 | 輸出的標籤 | 接法 |
|
||||||
|
|---|---|---|
|
||||||
|
| `if_control` | `"true"` / `"false"` | `ON_TRUE`/`ON_FALSE` 各一條 |
|
||||||
|
| `switch` | 你在 `cases[].branch` 取的名字(沒中則 `default_branch`)| 每條路一條 `ON_BRANCH`,邊上標 `branch` |
|
||||||
|
| `try_catch` | `"try"`(沒錯)/`"catch"`(有錯)| 兩條 `ON_BRANCH`,標 `try` 與 `catch` |
|
||||||
|
|
||||||
|
```
|
||||||
|
判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram
|
||||||
|
判斷有沒有新資料 >> ON_FALSE >> 結束
|
||||||
|
```
|
||||||
|
中文語意詞亦可:「成立時」=`ON_TRUE`、「否則」=`ON_FALSE`。
|
||||||
|
|
||||||
|
💡 **不必背**:查零件時回應會附 `branch_hint`(有哪些標籤、用哪些邊型、可照抄的範例),
|
||||||
|
照著接就對了。
|
||||||
|
|
||||||
|
⚠️ 仍然**不要寫 `ON_FAILURE`**(沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)`)。
|
||||||
|
|
||||||
|
### 2.2 怎麼確認分支真的走對了(**別看不懂就以為壞掉**)
|
||||||
|
|
||||||
|
分支工作流「有沒有成功」看兩件事,**不是看某條沒走的路沒有輸出**:
|
||||||
|
|
||||||
|
1. **`verdict`**:`GET /workflows/<name>/executions?limit=1`
|
||||||
|
→ `data.executions[0].verdict === "success"` 就是成功了。
|
||||||
|
2. **`trace` 裡有沒有出現該走的節點**:走 TRUE 路時 FALSE 路的節點**本來就不該出現**
|
||||||
|
——**那是正確行為,不是失敗**。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 條件成立 → 只有 true 那條的節點在 trace
|
||||||
|
{"amount": 5000} → if_control 回 branch="true" → 走 ON_TRUE 那條
|
||||||
|
{"amount": 100} → if_control 回 branch="false" → 走 ON_FALSE 那條
|
||||||
|
```
|
||||||
|
|
||||||
|
🔴 **實撞(2026-08-01 考試)**:有考生的分支工作流**其實完全正常**
|
||||||
|
(`amount=5000`→true、`amount=100`→false 都對),但它以為「跑不通」而放棄改寫成 code。
|
||||||
|
**看到只有一條路有輸出=分支正在正確運作**,不要因此判定失敗。
|
||||||
|
|
||||||
|
## 3. 第一個節點固定是 `input`
|
||||||
|
|
||||||
|
所有真範本都以 `input` 起頭——那是「觸發時帶進來的資料」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 真範本(照抄結構、改內容)
|
||||||
|
|
||||||
|
> 以下四份**全部是實際部署且 `verdict=success` 的 workflow**,不是簡化示範。
|
||||||
|
> 用 `acr logs <name>`(有 MCP 則 `arcrun_get_workflow(<name>)`) 可以拿完整定義。
|
||||||
|
|
||||||
|
### A. 最短:取資料 → 處理 (`graph_neighbors`)
|
||||||
|
```
|
||||||
|
input >> ON_SUCCESS >> fetch_triplets
|
||||||
|
fetch_triplets >> ON_SUCCESS >> bfs_neighbors
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. 長鏈:多次查詢 → 組裝 → 問 AI → 收尾 (`rag_chat`)
|
||||||
|
```
|
||||||
|
input >> ON_SUCCESS >> prep
|
||||||
|
prep >> ON_SUCCESS >> kw_search
|
||||||
|
kw_search >> ON_SUCCESS >> sem_search
|
||||||
|
sem_search >> ON_SUCCESS >> fetch_triplets
|
||||||
|
fetch_triplets >> ON_SUCCESS >> fetch_blocks_a
|
||||||
|
fetch_blocks_a >> ON_SUCCESS >> assemble
|
||||||
|
assemble >> ON_SUCCESS >> ask_llm
|
||||||
|
ask_llm >> ON_SUCCESS >> finalize
|
||||||
|
```
|
||||||
|
`prep` 前處理/`assemble` 組 prompt/`finalize` 收拾回應——三個常見的整形節點。
|
||||||
|
|
||||||
|
### C. 一節點分岔兩條 FOREACH (`rag_ingest_card`)
|
||||||
|
```
|
||||||
|
input >> ON_SUCCESS >> parse_card
|
||||||
|
parse_card >> 對每個 block >> post_block
|
||||||
|
parse_card >> 對每個 rel >> post_triplet
|
||||||
|
```
|
||||||
|
同一節點可有多條出邊,各自處理不同清單。
|
||||||
|
|
||||||
|
### D. 混合:直線 + 兩段 FOREACH (`rag_takedown_direct`)
|
||||||
|
```
|
||||||
|
input >> ON_SUCCESS >> prep
|
||||||
|
prep >> ON_SUCCESS >> list_dead_blocks
|
||||||
|
list_dead_blocks >> ON_SUCCESS >> build_deprecations
|
||||||
|
build_deprecations >> 對每個 dead_entry >> deprecate_entry
|
||||||
|
build_deprecations >> ON_SUCCESS >> list_triplets
|
||||||
|
list_triplets >> ON_SUCCESS >> pick_dead_triplets
|
||||||
|
pick_dead_triplets >> 對每個 dead_record >> deprecate_triplet
|
||||||
|
```
|
||||||
|
`build_deprecations` 同時有 FOREACH 出邊與 `ON_SUCCESS` 出邊——
|
||||||
|
前者處理清單、後者繼續主線。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 節點怎麼命名(照真範本的模式,查詢較容易媒合)
|
||||||
|
|
||||||
|
| 意圖 | 模式 | 真例 |
|
||||||
|
|---|---|---|
|
||||||
|
| 前處理/正規化 | `prep` | `rag_chat.prep` |
|
||||||
|
| 取一批資料 | `fetch_*`/`list_*` | `fetch_triplets`/`list_dead_blocks` |
|
||||||
|
| 搜尋 | `*_search` | `kw_search`/`sem_search` |
|
||||||
|
| 解析/切塊 | `parse_*` | `parse_card` |
|
||||||
|
| 寫入 | `post_*` | `post_block`/`post_triplet` |
|
||||||
|
| 組裝 | `assemble`/`build_*` | `assemble`/`build_deprecations` |
|
||||||
|
| 問 AI | `ask_llm` | `rag_chat.ask_llm` |
|
||||||
|
| 收尾整形 | `finalize` | `rag_chat.finalize` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 寫完一定要查(**不要直接部署**)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://arcrun-cypher-executor.<subdomain>.workers.dev/cypher/search \
|
||||||
|
-H 'content-type: application/json' -H 'X-Arcrun-API-Key: <namespace>' \
|
||||||
|
-d '{"triplets":["input >> ON_SUCCESS >> fetch_data","fetch_data >> ON_SUCCESS >> notify"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
回應的每個節點會有:
|
||||||
|
|
||||||
|
| status | 意思 | 你該做什麼 |
|
||||||
|
|---|---|---|
|
||||||
|
| `found` | 有這個節點。`source: component` 附 `input_schema`(怎麼填 payload)與 `success_rate`;`source: recipe` 附 description/endpoint | **只填 payload** |
|
||||||
|
| `not_found` | **兩庫(零件 registry+recipe 庫)都查過,確定沒有** | 照 `suggestion` 欄走:缺 API → 寫 recipe(skill `write_recipe`);缺計算能力 → 投稿零件 PR(skill `add_new_wasm_component`)。`similar_components`/`similar_recipes` 是相近候選——先看有沒有現成的能直接用 |
|
||||||
|
| `unknown` | 查不到 registry | **不代表不存在**,別據此改寫成 code |
|
||||||
|
|
||||||
|
> 註(2026-07-31):`/cypher/search` 曾對任何節點名都回假 `found`,已修為真查兩庫。
|
||||||
|
> 舊實例(未更新部署)仍可能假 found——status 可信度以該實例部署版本為準。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 常犯的錯
|
||||||
|
|
||||||
|
1. **用不存在的邊**(`ON_FAILURE`)→ 沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)`
|
||||||
|
⚠️ `ON_TRUE`/`ON_FALSE`/`ON_BRANCH` **是存在的**(2026-08-01 起),見 §2.1——
|
||||||
|
本行以前寫「ON_TRUE 不存在」是舊世代,已更正
|
||||||
|
2. **第一個節點不是 `input`**
|
||||||
|
3. **把 recipe 當零件寫**——`telegram_send`/`gmail`/`kbdb_get` 是 **recipe** 不是零件
|
||||||
|
→ 寫成 `http_request` + 該 recipe
|
||||||
|
4. 🔴 **查詢回 `not_found` 就改寫成 `code` 節點**
|
||||||
|
→ 那叫「腹語術」(表面用 Arcrun、實際全寫 JS)。正解:缺 API 寫 recipe、缺能力投稿零件。
|
||||||
|
`code` 只用在**局部整形**(例:剝掉 LLM 回應的雜訊),不用來取代零件與流程控制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 相關
|
||||||
|
|
||||||
|
- 完整版指引與十題考卷(含 haiku 實測 10/10):
|
||||||
|
頂層 repo `system-dev/docs/3-specs/arcrun-usable/`
|
||||||
|
- 下一步該讀哪支 skill(需 MCP):`arcrun_list_skills()`
|
||||||
|
- 定期掃資料 → `build_watcher_workflow`
|
||||||
|
- RAG 檢索問答 → `rag_with_arcrun`
|
||||||
|
- workflow 卡住不動 → `debug_paused_workflow`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 資源去哪取(不要自己重造 Arcrun 已有的)
|
||||||
|
|
||||||
| 你想知道 | 跑這個 |
|
| 你想知道 | 跑這個 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 有哪些零件可用 | `acr parts` |
|
| 有哪些零件可用 | `acr parts` |
|
||||||
| 某零件的設定範本 | `acr parts scaffold <name>` |
|
| 某零件的設定範本 | `acr parts scaffold <name>` |
|
||||||
|
| 有哪些 recipe | `acr recipe list`/`acr recipe search <關鍵字>` |
|
||||||
| 支援哪些服務的認證 | `acr auth-recipe list` |
|
| 支援哪些服務的認證 | `acr auth-recipe list` |
|
||||||
| 某服務認證要哪些 credential + 範例 | `acr auth-recipe scaffold <service>` |
|
| 某服務認證要哪些 credential + 範例 | `acr auth-recipe scaffold <service>` |
|
||||||
| 已上傳的 recipe | `acr recipe list` |
|
| **一次掃全部**(零件/recipe/auth-recipe/workflow) | `acr search <關鍵字>` |
|
||||||
|
| 已部署的 workflow | `acr list` |
|
||||||
|
| 某次執行為什麼失敗 | `acr logs <workflow>` |
|
||||||
| 工作流語法、指令 | `acr --help` |
|
| 工作流語法、指令 | `acr --help` |
|
||||||
|
|
||||||
**先查再動手**——arcrun 多半已經有你要的零件 / recipe / 認證,不要自刻。
|
**先查再動手**——Arcrun 多半已經有你要的零件/recipe/認證,不要自刻。
|
||||||
|
|
||||||
## 3. arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
|
## 10. 做出來以後:驗證 → 部署
|
||||||
|
|
||||||
需要智慧判斷 / 自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
|
```bash
|
||||||
**不要在工作流中間放零件回頭呼叫 LLM**。arcrun 的大腦就是操盤的你。
|
acr validate <workflow>.yaml # 先驗,別直接部署
|
||||||
|
acr push <workflow>.yaml # 部署(暴露動作,見 §12)
|
||||||
|
acr run <workflow> # 觸發一次,看實際結果
|
||||||
|
acr logs <workflow> # 看執行紀錄/失敗原因
|
||||||
|
```
|
||||||
|
|
||||||
## 4. arcrun 不替你做授權判斷
|
需要 credential(API key/token)時:`acr auth-recipe scaffold <service>` 看要哪些,
|
||||||
|
明確告訴使用者去哪取得、怎麼 `acr creds push`。
|
||||||
|
🔑 **金鑰只拿名字**:workflow/recipe 裡只寫 `{{credential.<名字>}}`,
|
||||||
|
**真身絕不寫進定義檔**(執行前才由系統回填)。
|
||||||
|
|
||||||
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 arcrun 的 bug、不是你做錯**。
|
## 11. Arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
|
||||||
不要在 arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
|
|
||||||
|
|
||||||
## 5. 把東西開放給別人用 = 要使用者明示同意
|
需要智慧判斷/自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
|
||||||
|
**不要在工作流中間放零件回頭呼叫 LLM**——Arcrun 的大腦就是操盤的你。
|
||||||
|
(唯一例外:`ask_llm` 這種「內容生成本身就是流程的一步」,見範本 B。)
|
||||||
|
|
||||||
部署對外 webhook、push recipe 會讓資料/能力**可被外部呼叫**(暴露面):
|
## 12. 把東西開放給別人用 = 要使用者明示同意
|
||||||
|
|
||||||
|
`acr push`(部署 workflow)與 `acr recipe push`(投稿 recipe)會讓資料/能力**可被外部呼叫**:
|
||||||
- 停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。**不替他決定公開。**
|
- 停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。**不替他決定公開。**
|
||||||
- 非互動環境(你直跑)遇到 → 停,要人類確認,絕不自己塞 confirm 假裝同意。
|
- 非互動環境(你直跑)遇到 → 停,把完整指令印給使用者自己貼上跑,絕不自己塞 confirm 假裝同意。
|
||||||
- arcrun 可提供保護(要求呼叫者帶 key / 限流)——提醒使用者。
|
- Arcrun 可提供保護(要求呼叫者帶 key/限流)——提醒使用者。
|
||||||
|
|
||||||
## 6. 誠實(最重要)
|
## 13. Arcrun 不替你做授權判斷
|
||||||
|
|
||||||
|
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 Arcrun 的 bug、不是你做錯**。
|
||||||
|
不要在 Arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
|
||||||
|
|
||||||
|
## 14. 誠實(最重要)
|
||||||
|
|
||||||
- **不假綠**:沒打通就誠實說。缺 credential 打不到 2xx → 標「未驗收:缺 X」,不 mock 充綠燈。
|
- **不假綠**:沒打通就誠實說。缺 credential 打不到 2xx → 標「未驗收:缺 X」,不 mock 充綠燈。
|
||||||
- **不假裝防偽 / 不代替人類確認**有風險的動作(暴露資料)。
|
- **不假裝防偽/不代替人類確認**有風險的動作(暴露資料)。
|
||||||
- **完成 = 客觀證據**(HTTP 2xx + trace),不是口頭「做好了」。
|
- **完成 = 客觀證據**(HTTP 2xx + trace),不是口頭「做好了」。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 怎麼用這份 mindset
|
## 動手前的自檢清單
|
||||||
|
|
||||||
每次準備動手,先過一遍:
|
1. 我把意圖寫成 `>>` 串了嗎?(還是直接跳去寫 YAML/寫程式)
|
||||||
1. 這能用工作流 / recipe 做嗎?(多半能 → 別自己寫 Python、別造零件)
|
2. 我查過 `acr search` / `acr parts` / `acr recipe list` 了嗎?
|
||||||
2. 我查過 `acr parts` / `acr auth-recipe` 了嗎?(arcrun 可能已有)
|
3. 查詢回 `not_found` 時,我走的是 recipe/零件 PR 兩條路,**還是偷偷改寫成 `code`**?(後者=腹語術)
|
||||||
3. 我是不是讓工作流回頭呼叫 AI?(是 → 改成我自己做)
|
4. 我是不是讓工作流回頭呼叫 AI 做判斷?(是 → 改成我自己做)
|
||||||
4. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
|
5. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
|
||||||
5. 我有沒有假裝(假綠 / 假防偽 / 代替人類確認)?(有 → 停,誠實標明)
|
6. 我有沒有假裝(假綠/假防偽/代替人類確認)?(有 → 停,誠實標明)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: arcrun-mindset
|
||||||
|
description: >-
|
||||||
|
在 Arcrun 上做任何事時使用(用戶說「幫我用 Arcrun 做 X」「用 arcrun 寫一個工作流」「把 X 自動化」)。
|
||||||
|
Arcrun 是跑在 Cloudflare 上的工作流引擎——你用 `>>` 寫「意圖」,系統告訴你有哪些現成零件與 recipe,
|
||||||
|
你只填 payload,不必自己寫程式。**不要上網搜 Arcrun 文件**(網路上沒有),也不要自己猜 YAML 格式:
|
||||||
|
先讀本 skill,再用 `acr` 指令(或 MCP 工具)查現成零件。
|
||||||
|
涵蓋:意圖工作流語法、四份實跑過的範本、零件 vs recipe 的分別、缺件的兩條路、已知的坑。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Arcrun:怎麼寫意圖工作流
|
||||||
|
|
||||||
|
> **你已經配備 Arcrun**(此專案裝了 `acr` CLI,可能另有 `arcrun_*` MCP 工具)。
|
||||||
|
> **別上網找文件**——網路上沒有 Arcrun 的文件,找到的都是錯的。答案都在本 skill 與 `acr` 指令裡。
|
||||||
|
|
||||||
|
## 先做這三件(照順序)
|
||||||
|
|
||||||
|
1. `acr whoami` — 確認連到哪個帳號(**勿自行 curl 猜帳號 URL**)
|
||||||
|
2. 讀本 skill 下面的語法與範本 → 寫出 `>>` 意圖
|
||||||
|
3. `acr parts`/`acr recipe list`(或 `acr search <關鍵字>` 一次掃全部)— 確認零件與 recipe 真的存在
|
||||||
|
|
||||||
|
**卡住時**:`acr search <關鍵字>` 跨類搜尋;有 MCP 就 `arcrun_get_skill('INDEX')` 拿全館導航。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句話世界觀
|
||||||
|
|
||||||
|
**Arcrun 裡幾乎所有東西都是工作流(workflow)。** 工作流 = 一張紙,寫「用哪些零件、什麼順序、什麼條件」。
|
||||||
|
你大部分時間在**寫紙、改紙**,不是在造新零件、也不是自己寫腳本。
|
||||||
|
|
||||||
|
**Arcrun 只有三種東西,先分清楚就不會做歪:**
|
||||||
|
|
||||||
|
| 東西 | 是什麼 | 你能做的 |
|
||||||
|
|---|---|---|
|
||||||
|
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
||||||
|
| **recipe** | 打「一個固定外部 API」的設定(endpoint/header/body 模板) | 自由寫、**而且該投稿**(缺就自己補) |
|
||||||
|
| **零件(component)** | WASM 程式(流程控制/資料處理/`http_request`/auth),固定一小套 | **你不自製**,走 PR 由維護者管 |
|
||||||
|
|
||||||
|
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制/資料處理/通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 資源去哪取(不要自己重造 Arcrun 已有的)
|
||||||
|
|
||||||
|
| 你想知道 | 跑這個 |
|
||||||
|
|---|---|
|
||||||
|
| 有哪些零件可用 | `acr parts` |
|
||||||
|
| 某零件的設定範本 | `acr parts scaffold <name>` |
|
||||||
|
| 有哪些 recipe | `acr recipe list`/`acr recipe search <關鍵字>` |
|
||||||
|
| 支援哪些服務的認證 | `acr auth-recipe list` |
|
||||||
|
| 某服務認證要哪些 credential + 範例 | `acr auth-recipe scaffold <service>` |
|
||||||
|
| **一次掃全部**(零件/recipe/auth-recipe/workflow) | `acr search <關鍵字>` |
|
||||||
|
| 已部署的 workflow | `acr list` |
|
||||||
|
| 某次執行為什麼失敗 | `acr logs <workflow>` |
|
||||||
|
| 工作流語法、指令 | `acr --help` |
|
||||||
|
|
||||||
|
**先查再動手**——Arcrun 多半已經有你要的零件/recipe/認證,不要自刻。
|
||||||
|
|
||||||
|
## 10. 做出來以後:驗證 → 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
acr validate <workflow>.yaml # 先驗,別直接部署
|
||||||
|
acr push <workflow>.yaml # 部署(暴露動作,見 §12)
|
||||||
|
acr run <workflow> # 觸發一次,看實際結果
|
||||||
|
acr logs <workflow> # 看執行紀錄/失敗原因
|
||||||
|
```
|
||||||
|
|
||||||
|
需要 credential(API key/token)時:`acr auth-recipe scaffold <service>` 看要哪些,
|
||||||
|
明確告訴使用者去哪取得、怎麼 `acr creds push`。
|
||||||
|
🔑 **金鑰只拿名字**:workflow/recipe 裡只寫 `{{credential.<名字>}}`,
|
||||||
|
**真身絕不寫進定義檔**(執行前才由系統回填)。
|
||||||
|
|
||||||
|
## 11. Arcrun 是你(AI)用的工具,不是工具回頭呼叫 AI
|
||||||
|
|
||||||
|
需要智慧判斷/自然語言轉換時,**你自己做**,再呼叫工作流執行確定性的下一步。
|
||||||
|
**不要在工作流中間放零件回頭呼叫 LLM**——Arcrun 的大腦就是操盤的你。
|
||||||
|
(唯一例外:`ask_llm` 這種「內容生成本身就是流程的一步」,見範本 B。)
|
||||||
|
|
||||||
|
## 12. 把東西開放給別人用 = 要使用者明示同意
|
||||||
|
|
||||||
|
`acr push`(部署 workflow)與 `acr recipe push`(投稿 recipe)會讓資料/能力**可被外部呼叫**:
|
||||||
|
- 停下來,明確告訴使用者「這會讓 X 可被外部呼叫」,要他同意。**不替他決定公開。**
|
||||||
|
- 非互動環境(你直跑)遇到 → 停,把完整指令印給使用者自己貼上跑,絕不自己塞 confirm 假裝同意。
|
||||||
|
- Arcrun 可提供保護(要求呼叫者帶 key/限流)——提醒使用者。
|
||||||
|
|
||||||
|
## 13. Arcrun 不替你做授權判斷
|
||||||
|
|
||||||
|
API 打不打得通由發 key 的服務決定。401/403 是對方服務在行使授權,**不是 Arcrun 的 bug、不是你做錯**。
|
||||||
|
不要在 Arcrun 裡建「允許/禁止某 endpoint」的二次授權清單。
|
||||||
|
|
||||||
|
## 14. 誠實(最重要)
|
||||||
|
|
||||||
|
- **不假綠**:沒打通就誠實說。缺 credential 打不到 2xx → 標「未驗收:缺 X」,不 mock 充綠燈。
|
||||||
|
- **不假裝防偽/不代替人類確認**有風險的動作(暴露資料)。
|
||||||
|
- **完成 = 客觀證據**(HTTP 2xx + trace),不是口頭「做好了」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 動手前的自檢清單
|
||||||
|
|
||||||
|
1. 我把意圖寫成 `>>` 串了嗎?(還是直接跳去寫 YAML/寫程式)
|
||||||
|
2. 我查過 `acr search` / `acr parts` / `acr recipe list` 了嗎?
|
||||||
|
3. 查詢回 `not_found` 時,我走的是 recipe/零件 PR 兩條路,**還是偷偷改寫成 `code`**?(後者=腹語術)
|
||||||
|
4. 我是不是讓工作流回頭呼叫 AI 做判斷?(是 → 改成我自己做)
|
||||||
|
5. 這動作會把資料開放給別人嗎?(會 → 要使用者明示同意)
|
||||||
|
6. 我有沒有假裝(假綠/假防偽/代替人類確認)?(有 → 停,誠實標明)
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "arcrun",
|
"name": "arcrun",
|
||||||
"version": "1.3.13",
|
"version": "1.3.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "arcrun",
|
"name": "arcrun",
|
||||||
"version": "1.3.13",
|
"version": "1.3.14",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
|
|||||||
+4
-2
@@ -8,7 +8,9 @@
|
|||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "npm run build:harness && npm run check:harness && tsc",
|
||||||
|
"build:harness": "node scripts/build-harness-skill.mjs",
|
||||||
|
"check:harness": "node scripts/check-harness-generation.mjs",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"test": "node --test \"tests/**/*.test.ts\"",
|
"test": "node --test \"tests/**/*.test.ts\"",
|
||||||
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
||||||
@@ -42,6 +44,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/uncle6me-web/Arcrun.git"
|
"url": "git+https://github.com/youlinhsieh/Arcrun.git"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* build-harness-skill.mjs — 由 registry/skills/ 組出 harness 的 arcrun-mindset SKILL.md
|
||||||
|
*
|
||||||
|
* 【為什麼是「建置期複製」而不是人工維護兩份】
|
||||||
|
* `registry/skills/write_intent_workflow.md` 是意圖語法的**單一真相源**——它同時是
|
||||||
|
* MCP `arcrun_get_skill()` 回給雲端 AI 的內容。harness 的 skill 若人工再抄一份,
|
||||||
|
* 兩份必然漂移(2026-07-31 實錄:harness 那份停在上一代,grep「意圖」「>>」= 0 命中,
|
||||||
|
* 只講世界觀,害新裝的用戶 AI 學不到 `>>`)。
|
||||||
|
*
|
||||||
|
* 作法:harness skill = 三段拼接
|
||||||
|
* SKILL.md.head ← harness 專屬(frontmatter/CLI 入口/三種東西的分型)
|
||||||
|
* registry 的 write_intent_workflow.md 正文 ← 單一真相源,只此一份被維護
|
||||||
|
* SKILL.md.tail ← harness 專屬(acr 指令表/暴露同意/誠實鐵律)
|
||||||
|
*
|
||||||
|
* 為什麼不用 symlink / npm 打包直接引用:npm `files` 只收 `harness/`,
|
||||||
|
* registry/ 不進套件;symlink 在 npm pack 與 Windows 上不可靠。建置期複製最單純。
|
||||||
|
*
|
||||||
|
* 產物 `SKILL.md` **有 commit 進 repo**(npm 套件裝的是它,不會跑 build),
|
||||||
|
* 由 check-harness-generation.mjs 驗證它與 registry 沒有漂移。
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url)); // cli/scripts
|
||||||
|
const repoRoot = join(here, '..', '..'); // repo 根
|
||||||
|
const skillDir = join(here, '..', 'harness', 'skills', 'arcrun-mindset');
|
||||||
|
const registrySkill = join(repoRoot, 'registry', 'skills', 'write_intent_workflow.md');
|
||||||
|
|
||||||
|
const head = readFileSync(join(skillDir, 'SKILL.md.head'), 'utf8').trimEnd();
|
||||||
|
const tail = readFileSync(join(skillDir, 'SKILL.md.tail'), 'utf8').trimEnd();
|
||||||
|
const body = readFileSync(registrySkill, 'utf8');
|
||||||
|
|
||||||
|
// 取 registry skill 的正文:去掉它自己的 H1 標題與「何時用這個 skill」那段
|
||||||
|
// (harness 的 head 已用 CLI 語境寫過入口),從第一個 `## 1.` 章節起收。
|
||||||
|
const idx = body.indexOf('## 1. 意圖工作流的語法');
|
||||||
|
if (idx < 0) {
|
||||||
|
console.error('❌ registry/skills/write_intent_workflow.md 找不到「## 1. 意圖工作流的語法」章節;');
|
||||||
|
console.error(' registry skill 結構變了 → 請同步更新 cli/scripts/build-harness-skill.mjs 的取段規則。');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const middle = body
|
||||||
|
.slice(idx)
|
||||||
|
// registry 版把 MCP 工具當預設介面;harness 裝在有 acr CLI 的專案 → 補上 CLI 等價指令
|
||||||
|
.replace(/`arcrun_get_workflow\(<name>\)`/g, '`acr logs <name>`(有 MCP 則 `arcrun_get_workflow(<name>)`)')
|
||||||
|
.replace(/`arcrun_list_components` \/ `arcrun_search_components`/g, '`acr parts` / `acr search`')
|
||||||
|
.replace(/下一步該讀哪支 skill:`arcrun_list_skills\(\)`/g, '下一步該讀哪支 skill(需 MCP):`arcrun_list_skills()`')
|
||||||
|
.trimEnd();
|
||||||
|
|
||||||
|
const out = [
|
||||||
|
head,
|
||||||
|
'',
|
||||||
|
'<!-- 以下正文由 registry/skills/write_intent_workflow.md 於建置期複製而來(單一真相源)。',
|
||||||
|
' 不要直接編輯本段——改 registry 那份,然後跑 `npm run build:harness`。 -->',
|
||||||
|
'',
|
||||||
|
middle,
|
||||||
|
'',
|
||||||
|
tail,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
writeFileSync(join(skillDir, 'SKILL.md'), out, 'utf8');
|
||||||
|
console.log(`✓ harness skill 已由 registry 重建:${out.length} bytes`);
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* check-harness-generation.mjs — 世代閘:harness 內容脫節就讓 build/publish 失敗
|
||||||
|
*
|
||||||
|
* 【為什麼要這道閘】
|
||||||
|
* 2026-07-31 實錄:`acr install-harness` 的管道一直是好的,但它鋪出去的**內容停在上一代**——
|
||||||
|
* harness skill grep「意圖」「>>」= 0 命中,只講世界觀。管道綠燈、交付物過時,
|
||||||
|
* 沒有任何機械檢查會抱怨 ⇒ 世代脫節可以無聲存在好幾個月。
|
||||||
|
*
|
||||||
|
* 這道閘檢查四件交付物的「現世代指紋」。缺指紋 = exit 1,擋掉 build 與 npm publish。
|
||||||
|
* 指紋要挑「上一代絕不會有、現世代一定有」的字串,不是隨便的關鍵字。
|
||||||
|
*/
|
||||||
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const harness = join(here, '..', 'harness');
|
||||||
|
const repoRoot = join(here, '..', '..');
|
||||||
|
|
||||||
|
/** @type {{file: string, must: [string, string][], mustNot?: [string,string][]}[]} */
|
||||||
|
const CHECKS = [
|
||||||
|
{
|
||||||
|
file: 'skills/arcrun-mindset/SKILL.md',
|
||||||
|
must: [
|
||||||
|
['>>', '意圖語法(`A >> 邊 >> B`)——步驟 1 的核心教材'],
|
||||||
|
['ON_SUCCESS', '合法邊之一'],
|
||||||
|
['對每個', 'FOREACH 邊(十題裡有四題要用)'],
|
||||||
|
['input', '第一個節點固定是 input'],
|
||||||
|
['not_found', '現世代查詢狀態(舊版寫 missing/假 found)'],
|
||||||
|
['腹語術', '缺件不准改寫成 code 的紅線'],
|
||||||
|
['recipe', '零件 vs recipe 分型'],
|
||||||
|
// 條件邊自 2026-08-01 起引擎已支援(cypher-executor/src/graph-executor.ts
|
||||||
|
// case 'ON_TRUE'/'ON_FALSE'/'ON_BRANCH',31 個測試全過)。教材該教會怎麼用,
|
||||||
|
// 不是教「不存在」——這條 must 同時防「哪天又被改回舊世代說法」的回歸。
|
||||||
|
['ON_TRUE', '條件邊(配 if_control)自 2026-08-01 起引擎已支援,教材須教會用法'],
|
||||||
|
],
|
||||||
|
mustNot: [
|
||||||
|
// ON_FAILURE 才是真的不存在(VALID_EDGE_TYPES 只有 ON_FAIL,見
|
||||||
|
// cypher-executor/src/lib/constants.ts)。只准出現在「教它不存在」的脈絡。
|
||||||
|
// 2026-08-10 修正:這道閘原本擋的是 ON_TRUE——但 ON_TRUE/ON_FALSE/ON_BRANCH
|
||||||
|
// 已是引擎現世代能力,正確教材反而被這道閘擋下,是閘的判準過時了,不是教材寫錯。
|
||||||
|
['ON_FAILURE', '引擎沒有這種邊(只有 ON_FAIL);教材不該把它教成可用的邊', /不要寫|不存在|沒有這種|❌|非法/],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'CLAUDE.block.md',
|
||||||
|
must: [
|
||||||
|
['>>', '意圖語法要在 CLAUDE.md 就先亮相'],
|
||||||
|
['not_found', '缺件兩條路的觸發點'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'commands/arcrun.md',
|
||||||
|
must: [
|
||||||
|
['>>', '/arcrun 的第一步就該是寫意圖'],
|
||||||
|
['acr search', '現世代的跨類搜尋指令'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'hooks/arcrun-guard.sh',
|
||||||
|
must: [
|
||||||
|
['arcrun-mindset', 'hook 被擋下時要把 AI 導向 skill,而不是叫它去翻 repo 文件'],
|
||||||
|
['>>', 'hook 的正路提示要提到意圖語法'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let fail = 0;
|
||||||
|
const say = (s) => console.log(s);
|
||||||
|
|
||||||
|
say('\n 世代閘:檢查 harness 交付物是否為現世代內容\n');
|
||||||
|
|
||||||
|
for (const c of CHECKS) {
|
||||||
|
const p = join(harness, c.file);
|
||||||
|
if (!existsSync(p)) {
|
||||||
|
say(` ❌ ${c.file} — 檔案不存在`);
|
||||||
|
fail++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = readFileSync(p, 'utf8');
|
||||||
|
const missing = c.must.filter(([needle]) => !text.includes(needle));
|
||||||
|
const badNot = (c.mustNot ?? []).filter(([needle, , allowIfNear]) => {
|
||||||
|
if (!text.includes(needle)) return false;
|
||||||
|
if (!allowIfNear) return true;
|
||||||
|
// 允許「在教『不要用』的脈絡裡」出現:看該字串所在行是否有豁免詞
|
||||||
|
return !text
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.includes(needle))
|
||||||
|
.every((l) => allowIfNear.test(l));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missing.length === 0 && badNot.length === 0) {
|
||||||
|
say(` ✓ ${c.file}`);
|
||||||
|
} else {
|
||||||
|
fail++;
|
||||||
|
say(` ❌ ${c.file}`);
|
||||||
|
for (const [needle, why] of missing) say(` 缺指紋「${needle}」— ${why}`);
|
||||||
|
for (const [needle, why] of badNot) say(` 不該出現「${needle}」— ${why}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// harness skill 必須是由 registry 重建的最新版(防「改了 registry 忘了重跑 build」)
|
||||||
|
const skillPath = join(harness, 'skills', 'arcrun-mindset', 'SKILL.md');
|
||||||
|
const registrySkill = join(repoRoot, 'registry', 'skills', 'write_intent_workflow.md');
|
||||||
|
if (existsSync(skillPath) && existsSync(registrySkill)) {
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, [join(here, 'build-harness-skill.mjs')], { stdio: 'pipe' });
|
||||||
|
const rebuilt = readFileSync(skillPath, 'utf8');
|
||||||
|
const before = statSync(skillPath); // 重建後內容即為期望值
|
||||||
|
void before;
|
||||||
|
// 重建是冪等的:若重建後與 git 中的版本不同,git diff 會在 CI 顯示;
|
||||||
|
// 這裡直接比對「重建結果是否含 registry 當前的關鍵段落」
|
||||||
|
const reg = readFileSync(registrySkill, 'utf8');
|
||||||
|
const marker = reg.includes('## 7. 常犯的錯') ? '## 7. 常犯的錯' : null;
|
||||||
|
if (marker && !rebuilt.includes(marker)) {
|
||||||
|
say(` ❌ harness skill 與 registry 漂移:registry 有「${marker}」但重建產物沒有`);
|
||||||
|
fail++;
|
||||||
|
} else {
|
||||||
|
say(' ✓ harness skill 與 registry/skills/write_intent_workflow.md 同步');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
say(` ❌ 無法由 registry 重建 harness skill:${e.message}`);
|
||||||
|
fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
say('');
|
||||||
|
if (fail) {
|
||||||
|
say(` 🔴 世代閘擋下(${fail} 項)。harness 交付的內容落後於現世代。`);
|
||||||
|
say(' 修法:改 registry/skills/write_intent_workflow.md(單一真相源)或對應的');
|
||||||
|
say(' cli/harness/ 檔案,然後跑 `npm run build:harness` 重建,再跑本檢查。\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
say(' ✅ 世代閘通過:四件交付物都帶現世代指紋\n');
|
||||||
@@ -230,15 +230,16 @@ async function initSelfHosted(
|
|||||||
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
|
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3.5 語義查詢開關(issue #7 / T2.4):問用戶要不要開(預設關,free-tier 友善)。
|
// 3.5 語義查詢(issue #7 / T2.4):**預設開**(2026-08-09 翻轉,leo:「語義搜尋已經
|
||||||
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword,零花費。
|
// 確定是一安裝就提供的功能」——預設關會產出一批「看起來裝好了、其實少一條腿」的
|
||||||
// 之後想開:跟 CC 說「幫我開語義查詢」或設 kbdb_embed:true + acr update(不必重 init)。
|
// 實例,之後畫面上還被誤說成「沒開通」)。顯式回答 n 才關(極端省額度者自選)。
|
||||||
|
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword。
|
||||||
const embedAns = (await prompt(
|
const embedAns = (await prompt(
|
||||||
rl,
|
rl,
|
||||||
'要開語義查詢嗎?(KBDB 加 AI 向量搜尋;用 CF Vectorize,可能多花費;預設關,之後可隨時開) [y/N]',
|
'要開語義查詢嗎?(內建功能,建議保持開啟;用 CF Vectorize,有免費額度) [Y/n]',
|
||||||
)).trim().toLowerCase();
|
)).trim().toLowerCase();
|
||||||
const kbdbEmbed = embedAns === 'y' || embedAns === 'yes';
|
const kbdbEmbed = !(embedAns === 'n' || embedAns === 'no');
|
||||||
if (kbdbEmbed) console.log(chalk.gray(' → 已選開語義查詢:部署時會建 Vectorize index。'));
|
if (!kbdbEmbed) console.log(chalk.yellow(' → 已選關語義查詢:這台實例將只有關鍵字搜尋(之後可設 kbdb_embed:true + acr update 補開)。'));
|
||||||
|
|
||||||
// 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker
|
// 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker
|
||||||
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
|
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
|
||||||
|
|||||||
@@ -110,11 +110,18 @@ function mergeSettings(cwd: string, src: string): void {
|
|||||||
writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 遞迴複製目錄樹(覆蓋同名檔)。 */
|
/** 建置期產物的來源片段(`SKILL.md.head` / `.tail`),只給 build-harness-skill.mjs 用,
|
||||||
|
* 不該被鋪進使用者專案(使用者拿到的是拼接好的 `SKILL.md`)。 */
|
||||||
|
function isBuildSource(name: string): boolean {
|
||||||
|
return name.endsWith('.head') || name.endsWith('.tail');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 遞迴複製目錄樹(覆蓋同名檔;跳過建置期來源片段)。 */
|
||||||
function copyTree(srcDir: string, dstDir: string): void {
|
function copyTree(srcDir: string, dstDir: string): void {
|
||||||
if (!existsSync(srcDir)) return;
|
if (!existsSync(srcDir)) return;
|
||||||
mkdirSync(dstDir, { recursive: true });
|
mkdirSync(dstDir, { recursive: true });
|
||||||
for (const name of readdirSync(srcDir, { withFileTypes: true })) {
|
for (const name of readdirSync(srcDir, { withFileTypes: true })) {
|
||||||
|
if (isBuildSource(name.name)) continue;
|
||||||
const s = join(srcDir, name.name);
|
const s = join(srcDir, name.name);
|
||||||
const d = join(dstDir, name.name);
|
const d = join(dstDir, name.name);
|
||||||
if (name.isDirectory()) copyTree(s, d);
|
if (name.isDirectory()) copyTree(s, d);
|
||||||
|
|||||||
@@ -84,9 +84,13 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
|||||||
// self-hosted → 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
|
// 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 跑。
|
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
|
||||||
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
|
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
|
||||||
// 語義查詢開關(issue #7):config.kbdb_embed:true → 部署建 Vectorize index + 注入 binding。
|
// 語義查詢(issue #7):預設**開**,只有 config 顯式寫 kbdb_embed:false 才關。
|
||||||
// 這也是「CC 幫開」的落地路徑:CC 寫 kbdb_embed:true 進 config → acr update redeploy 即生效。
|
// 🔴 2026-08-09 翻轉預設(leo:「語義搜尋已經確定是一安裝就提供的功能」)。
|
||||||
kbdbEmbed: config.kbdb_embed === true,
|
// 舊判斷 `=== 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 });
|
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ export interface ArcrunConfig {
|
|||||||
mcp_url?: string;
|
mcp_url?: string;
|
||||||
multi_tenant?: boolean;
|
multi_tenant?: boolean;
|
||||||
// 語義查詢開關(issue #7 / SDD T2.4,self-hosted 從零做)。
|
// 語義查詢開關(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)。
|
// kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。
|
||||||
// 未設/false → base 維持 LIKE keyword(free-tier 友善,不建 index、不花費)。
|
// false → base 維持 LIKE keyword(顯式選擇才有這個狀態;缺欄位不再等於關——
|
||||||
// 開法:設 kbdb_embed:true → redeploy(acr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
|
// 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)。
|
||||||
kbdb_embed?: boolean;
|
kbdb_embed?: boolean;
|
||||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
|
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
|
||||||
// (讀到不報錯,不再寫入/檢查)。
|
// (讀到不報錯,不再寫入/檢查)。
|
||||||
|
|||||||
+65
-15
@@ -163,8 +163,27 @@ export interface DeployContext {
|
|||||||
kbdbEmbed?: boolean;
|
kbdbEmbed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */
|
/**
|
||||||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed';
|
* Vectorize index 名(kbdb embed 模組用)。**bge-m3 = 1024 維、cosine。**
|
||||||
|
*
|
||||||
|
* 🔴 2026-08-03 換代(leo 拍板;5 組中文測資實證:舊 `bge-base-en-v1.5` 排序 2/5、
|
||||||
|
* margin −0.0413=**中文根本不能用**;`bge-m3` 5/5、+0.1410、959ms)。
|
||||||
|
* leo 08-05:「換 embed model 當然要合併,當然要換 vectorize,原本的根本不能用」。
|
||||||
|
*
|
||||||
|
* **換模型必須換 index,且必須換「名字」**:
|
||||||
|
* ① 維度 768→1024,舊 index 收不進新向量
|
||||||
|
* ② 就算維度相同也不能沿用——不同模型的向量混在同一 index,比對出來是垃圾;
|
||||||
|
* 而 #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 {
|
export interface DeployResult {
|
||||||
implemented: boolean;
|
implemented: boolean;
|
||||||
@@ -317,20 +336,49 @@ export async function downloadAndDeploy(
|
|||||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql(${migPath})`);
|
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)。
|
// 3.6 credential template seed(D38 圍牆修復,總管交辦,2026-08-07):credential 目錄改走
|
||||||
// 現行 credential 規範見 .claude/rules/01-tech-stack.md「Credential 儲存規範」。
|
// KBDB template 機制(entries 表 entry_type='credential',比照 recipe_stat/execution_log
|
||||||
// 同一顆 D1(與 KBDB base 共用),冪等 IF NOT EXISTS,套用機制與 0001_base.sql 完全相同
|
// 慣例),取代舊的獨立 credentials 表(0002,已退役,見該檔頭部說明)。冪等,套用機制
|
||||||
// (同一個 applyD1Migration helper,同一支 CF D1 query API)。D19:這張表不含密文,
|
// 與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets(見
|
||||||
// 密文本體住在 Workers per-script Secrets(見 cypher-executor/src/routes/credentials.ts)。
|
// cypher-executor/src/routes/credentials.ts),D19「擁有目錄不擁有內容物」不變。
|
||||||
const credMigPath = join(root, 'kbdb', 'migrations', '0002_credentials.sql');
|
const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
|
||||||
if (existsSync(credMigPath)) {
|
if (existsSync(credTplMigPath)) {
|
||||||
try {
|
try {
|
||||||
await applyD1Migration(ctx, readFileSync(credMigPath, 'utf8'));
|
await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
|
||||||
} catch (e) {
|
} 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 {
|
} 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})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +436,9 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
|
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
|
||||||
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=768/metric=cosine,對齊 bge-base-en-v1.5)。
|
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=1024 / metric=cosine,對齊 bge-m3)。
|
||||||
|
* ⚠️ 這行別寫成 `**dimensions=1024**/metric`——`*` 緊接 `/` 會提早關掉 block comment(實撞 TS1127)。
|
||||||
|
* 維度必須與 `kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 一致——不一致時 upsert 直接被 CF 拒絕。
|
||||||
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
|
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
|
||||||
*/
|
*/
|
||||||
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||||
@@ -398,8 +448,8 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
|||||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: KBDB_VECTORIZE_INDEX,
|
name: KBDB_VECTORIZE_INDEX,
|
||||||
config: { dimensions: 768, metric: 'cosine' },
|
config: { dimensions: 1024, metric: 'cosine' },
|
||||||
description: 'arcrun KBDB optional embed module (issue #7)',
|
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,68 @@
|
|||||||
{
|
{
|
||||||
"_readme": [
|
"_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 介面』的真因)",
|
" · demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo 看到『Mira 介面』的真因)",
|
||||||
" · 兩站都漏 ARCRUN_API_BASE → apiBase 空字串 → 前端打自己回 405 → 登不進去",
|
" · 兩站都漏 ARCRUN_API_BASE → apiBase 空字串 → 前端打自己回 405 → 登不進去",
|
||||||
" · 兩個帳號有同名 arcrun-console-ui 專案,wrangler 又登入在 uncle6",
|
" · 兩個帳號有同名 arcrun-console-ui 專案,wrangler 又登入在 uncle6",
|
||||||
" → 不指定帳號直接 deploy 會部到 demo 站上(差點蓋掉)",
|
" → 不指定帳號直接 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 決定顯示哪些。",
|
"版本差異(leo 2026-07-22 定調):頁面都存在,由 profile 決定顯示哪些。",
|
||||||
" personal(full) 個人版:7 頁全開,落地駕駛艙",
|
" personal(full) 個人版:7 頁全開,落地駕駛艙",
|
||||||
" enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁",
|
" enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁",
|
||||||
" 未來擴充:個人版新用戶上限 1、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。",
|
" 未來擴充:個人版新用戶上限 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": {
|
"personal": {
|
||||||
"description": "leo 私人實例(原 Mira)。入口 mira.uncle6.me → leo21c worker。",
|
"description": "leo 私人實例(原 Mira)。入口 mira.uncle6.me → leo21c worker。",
|
||||||
"accountId": "51a01bfa2665bd7bc3fd080dc40cf3e1",
|
"accountId": "51a01bfa2665bd7bc3fd080dc40cf3e1",
|
||||||
@@ -24,6 +70,7 @@
|
|||||||
"profile": "full",
|
"profile": "full",
|
||||||
"brand": "Arcrun",
|
"brand": "Arcrun",
|
||||||
"apiBase": "https://arcrun-cypher-executor.leo21c.workers.dev",
|
"apiBase": "https://arcrun-cypher-executor.leo21c.workers.dev",
|
||||||
|
"verifyUrls": ["https://mira.uncle6.me", "https://arcrun-console-ui.pages.dev"],
|
||||||
"limits": {
|
"limits": {
|
||||||
"maxUsers": 1,
|
"maxUsers": 1,
|
||||||
"libraryPermissions": false
|
"libraryPermissions": false
|
||||||
@@ -31,12 +78,14 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"enterprise": {
|
"enterprise": {
|
||||||
"description": "企業版 demo 站。rag-demo.arcrun.dev → uncle6 帳號 cypher。",
|
"frozen": "leo 2026-08-08:「要看範例只在 youlin 網站,不要去碰 uncle6」——這站是 uncle6 帳號的資源,已廢。不更新、不下架、不探測。要動它是 leo 的閘。",
|
||||||
|
"description": "【已凍結・沿革】企業版 demo 站(uncle6 帳號)。保留紀錄用,不是現行部署對象。",
|
||||||
"accountId": "58309bb90fd93ad6d0fe0aae99170e9d",
|
"accountId": "58309bb90fd93ad6d0fe0aae99170e9d",
|
||||||
"projectName": "arcrun-console-ui",
|
"projectName": "arcrun-console-ui",
|
||||||
"profile": "rag",
|
"profile": "rag",
|
||||||
"brand": "Arcrun",
|
"brand": "Arcrun",
|
||||||
"apiBase": "https://cypher.arcrun.dev",
|
"apiBase": "https://cypher.arcrun.dev",
|
||||||
|
"verifyUrls": ["https://rag-demo.arcrun.dev"],
|
||||||
"limits": {
|
"limits": {
|
||||||
"maxUsers": null,
|
"maxUsers": null,
|
||||||
"libraryPermissions": true
|
"libraryPermissions": true
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
"name": "arcrun-console-ui",
|
"name": "arcrun-console-ui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"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": {
|
"scripts": {
|
||||||
"deploy": "node scripts/deploy.mjs",
|
"deploy": "node scripts/deploy.mjs",
|
||||||
"deploy:personal": "node scripts/deploy.mjs personal",
|
"deploy:personal": "node scripts/deploy.mjs personal",
|
||||||
"deploy:enterprise": "node scripts/deploy.mjs enterprise",
|
"verify": "node scripts/verify-live.mjs",
|
||||||
"preview": "npx serve public"
|
"preview": "node scripts/deploy.mjs personal --dry-run && npx serve .staging/personal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1,2 +0,0 @@
|
|||||||
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
|
||||||
window.ARCRUN_CONFIG = { apiBase: "https://cypher.arcrun.dev" };
|
|
||||||
@@ -92,7 +92,12 @@
|
|||||||
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
|
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
|
||||||
</style>
|
</style>
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
<script>
|
||||||
|
// 2026-08-01(arcrun-rag#10 同族):拔掉寫死中央位址的 fallback。
|
||||||
|
// apiBase 由 worker 動態產生的 /config.js 注入;缺它就讓它明顯壞掉,
|
||||||
|
// **不要靜默把請求(可能含金鑰)送去中央實例**。
|
||||||
|
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -220,7 +220,12 @@
|
|||||||
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
||||||
</style>
|
</style>
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
<script>
|
||||||
|
// 2026-08-01(arcrun-rag#10 同族):拔掉寫死中央位址的 fallback。
|
||||||
|
// apiBase 由 worker 動態產生的 /config.js 注入;缺它就讓它明顯壞掉,
|
||||||
|
// **不要靜默把請求(可能含金鑰)送去中央實例**。
|
||||||
|
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -440,6 +445,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 class="panel">
|
||||||
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
|
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
|
||||||
<div id="st-info"><div class="muted">載入中…</div></div>
|
<div id="st-info"><div class="muted">載入中…</div></div>
|
||||||
@@ -857,7 +871,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
}
|
}
|
||||||
var libs = x.d.libraries || [];
|
var libs = x.d.libraries || [];
|
||||||
if (!libs.length) {
|
if (!libs.length) {
|
||||||
lmHonest('還沒有藏書地圖', '還沒有任何庫跑過重算——對 KBDB 呼 <code style="font-size:12.5px">POST /map/recompute?library=庫名</code> backfill 後,這裡會出現全館導覽。<br>不影響下方搜尋,可直接搜全庫。');
|
lmHonest('還沒有藏書地圖', '這個租戶目前沒有任何三元組資料(地圖是查詢時即時核對重算的,不是要人手動 backfill——資料一進來下次載入就會出現)。<br>不影響下方搜尋,可直接搜全庫。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LM.libs = libs; LM.details = {};
|
LM.libs = libs; LM.details = {};
|
||||||
@@ -965,7 +979,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + '</span>'; return; }
|
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + '</span>'; return; }
|
||||||
var d = x.d;
|
var d = x.d;
|
||||||
if (S.semantic && d.mode === 'keyword') {
|
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 || [];
|
var entries = d.entries || [];
|
||||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +
|
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +
|
||||||
@@ -1451,17 +1466,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
// t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示——
|
// t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示——
|
||||||
// 沒有東西要用戶操作;未啟用才給一句人話與下一步。
|
// 沒有東西要用戶操作;未啟用才給一句人話與下一步。
|
||||||
|
// 2026-08-09 leo:語意搜尋是安裝即提供的功能——探測到降級=這台實例壞了,
|
||||||
|
// 照實標「故障」,不說「尚未啟用」(那會把 bug 說成沒提供的功能)。
|
||||||
var on = d.mode === 'semantic';
|
var on = d.mode === 'semantic';
|
||||||
$('st-vec').textContent = on
|
$('st-vec').textContent = on
|
||||||
? '● 已啟用——搜尋頁切到「語意」就能用意思找資料。'
|
? '● 正常——搜尋頁切到「語意」就能用意思找資料。'
|
||||||
: '○ 尚未啟用——目前用關鍵字搜尋,不會假裝有語意結果。';
|
: '○ 故障——語意搜尋是內建功能,這台實例現在少了它(系統端問題,不是操作問題)。';
|
||||||
var hint = $('st-vec-hint');
|
var hint = $('st-vec-hint');
|
||||||
if (on) {
|
if (on) {
|
||||||
hint.style.display = 'none';
|
hint.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
hint.style.display = '';
|
hint.style.display = '';
|
||||||
hint.innerHTML = '一鍵安裝的實例會在安裝時自動開通語意索引。'
|
hint.innerHTML = '修復方式:重新跑一次安裝流程(用原本的 Cloudflare 帳號),會把缺的語意索引設定補回來;已建好的資料不會重來。'
|
||||||
+ '如果你這個實例是較早裝的、或安裝當下開通沒成功,重新跑一次安裝流程即可補上(已建好的資料不會重來)。';
|
+ (d.admin_hint ? '<br>維運資訊:' + esc(d.admin_hint) : '');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
@@ -1518,6 +1535,27 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
})
|
})
|
||||||
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
|
.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 指示)。開關已移除,
|
// t36:原本這裡綁在那顆假開關上(點了只會 toast 一段 CLI 指示)。開關已移除,
|
||||||
// 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener
|
// 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener
|
||||||
// 當場拋錯,把後面所有綁定(含登出)一起打斷。
|
// 當場拋錯,把後面所有綁定(含登出)一起打斷。
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="arcrun icon"><title>arcrun icon</title><rect width="1024" height="1024" fill="#17181A"/><path fill="#FDFCFB" fill-rule="nonzero" d="M463.01,612.91 L436.06,612.91 L436.06,485.41 L435.86,477.78 L435.27,470.46 L434.28,463.44 L432.89,456.73 L431.11,450.31 L428.93,444.20 L426.36,438.39 L423.39,432.88 L420.02,427.68 L416.26,422.78 L412.10,418.18 L407.55,413.88 L402.62,409.91 L397.31,406.28 L391.65,402.99 L385.61,400.06 L379.21,397.47 L372.44,395.22 L365.30,393.32 L357.79,391.76 L349.92,390.55 L341.68,389.69 L333.08,389.17 L324.10,389.00 L317.39,389.10 L310.90,389.40 L304.63,389.89 L298.59,390.58 L292.77,391.47 L287.17,392.56 L281.80,393.85 L276.65,395.33 L271.72,397.02 L267.02,398.90 L262.53,400.98 L258.28,403.25 L254.20,405.69 L250.26,408.26 L246.45,410.95 L242.78,413.76 L239.25,416.71 L235.86,419.77 L232.60,422.97 L229.48,426.29 L226.50,429.74 L223.65,433.31 L220.94,437.01 L218.37,440.83 L257.76,476.08 L259.43,473.77 L261.16,471.52 L262.96,469.32 L264.81,467.18 L266.73,465.09 L268.71,463.05 L270.75,461.07 L272.85,459.15 L275.01,457.27 L277.23,455.45 L279.51,453.69 L281.86,451.98 L284.30,450.36 L286.86,448.89 L289.55,447.55 L292.37,446.36 L295.31,445.31 L298.38,444.39 L301.58,443.62 L304.90,442.99 L308.34,442.50 L311.91,442.15 L315.61,441.94 L319.44,441.87 L323.74,441.95 L327.85,442.21 L331.75,442.65 L335.45,443.25 L338.95,444.03 L342.24,444.98 L345.34,446.10 L348.23,447.40 L350.93,448.87 L353.42,450.51 L355.71,452.32 L357.79,454.31 L359.70,456.45 L361.44,458.74 L363.01,461.18 L364.42,463.75 L365.66,466.47 L366.73,469.34 L367.64,472.35 L368.39,475.50 L368.97,478.80 L369.38,482.24 L369.63,485.82 L369.71,489.55 L369.71,509.25 L323.58,509.25 L314.52,509.39 L305.80,509.82 L297.44,510.53 L289.43,511.52 L281.78,512.80 L274.47,514.37 L267.52,516.22 L260.93,518.35 L254.68,520.77 L248.79,523.47 L243.25,526.45 L238.06,529.72 L233.26,533.28 L228.88,537.14 L224.91,541.30 L221.36,545.76 L218.23,550.52 L215.52,555.57 L213.22,560.93 L211.34,566.58 L209.88,572.53 L208.84,578.78 L208.21,585.33 L208.00,592.18 L208.15,598.13 L208.62,603.87 L209.39,609.41 L210.48,614.75 L211.87,619.89 L213.57,624.83 L215.58,629.57 L217.91,634.11 L220.54,638.44 L223.48,642.57 L226.73,646.50 L230.29,650.23 L234.14,653.71 L238.25,656.88 L242.63,659.75 L247.28,662.32 L252.19,664.59 L257.37,666.56 L262.82,668.22 L268.53,669.58 L274.51,670.64 L280.75,671.40 L287.26,671.85 L294.04,672.00 L299.07,671.91 L303.96,671.63 L308.72,671.17 L313.33,670.53 L317.81,669.71 L322.16,668.70 L326.37,667.50 L330.44,666.13 L334.37,664.57 L338.17,662.82 L341.83,660.89 L345.35,658.78 L348.71,656.49 L351.88,654.01 L354.85,651.35 L357.62,648.50 L360.20,645.47 L362.59,642.26 L364.78,638.87 L366.78,635.29 L368.58,631.52 L370.19,627.58 L371.60,623.45 L372.82,619.13 L375.93,619.13 L376.52,622.61 L377.24,625.98 L378.09,629.22 L379.07,632.35 L380.19,635.36 L381.44,638.24 L382.83,641.01 L384.34,643.67 L385.99,646.20 L387.78,648.61 L389.69,650.91 L391.74,653.08 L393.92,655.11 L396.23,656.96 L398.66,658.64 L401.22,660.14 L403.90,661.46 L406.71,662.61 L409.64,663.58 L412.71,664.37 L415.89,664.99 L419.21,665.43 L422.65,665.69 L426.21,665.78 L463.01,665.78 L463.01,612.91 Z M475.77,630.42 L546.23,713.58 L762.31,530.50 L546.23,347.42 L475.77,430.58 L593.69,530.50 L475.77,630.42 Z M667.77,630.42 L738.23,713.58 L954.31,530.50 L738.23,347.42 L667.77,430.58 L785.69,530.50 L667.77,630.42 Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -7,8 +7,11 @@
|
|||||||
根目錄直接導向搜尋 Portal。
|
根目錄直接導向搜尋 Portal。
|
||||||
|
|
||||||
為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到):
|
為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到):
|
||||||
這個網域(rag-demo.arcrun.dev)是給**客戶測試**用的入口,
|
這份 UI 部署出去的網址是給**使用者**的入口(個人站 mira.uncle6.me,
|
||||||
客戶測試指南寫的就是「一個網址、一組帳密」——多一層選擇=多一個困惑點,
|
以及自架用戶自己的網址),進站就是要能用——多一層選擇=多一個困惑點,
|
||||||
|
(2026-08-08 更正:原註解寫「這個網域=rag-demo.arcrun.dev 是客戶測試入口」,
|
||||||
|
那是 uncle6 帳號那個已廢的 demo 站,leo 已定案不再拿它當範例;
|
||||||
|
註解留著會把下一個人導向錯的環境,故改寫。理由本身仍然成立。)
|
||||||
而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。
|
而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。
|
||||||
|
|
||||||
維運者要進 console 直接打 /console/ 即可。
|
維運者要進 console 直接打 /console/ 即可。
|
||||||
|
|||||||
+660
-183
File diff suppressed because one or more lines are too long
@@ -1,10 +1,15 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
|
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
|
||||||
// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)
|
// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)
|
||||||
|
//
|
||||||
|
// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語
|
||||||
|
// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。
|
||||||
|
// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。
|
||||||
const start = html.indexOf('var DAEMON_BASE_DEFAULT');
|
const start = html.indexOf('var DAEMON_BASE_DEFAULT');
|
||||||
const endMark = "return '(封測版未簽章,第一次請右鍵→打開)';\n }";
|
const hintAt = html.indexOf('function daemonHint', start);
|
||||||
const end = html.indexOf(endMark) + endMark.length;
|
const endMark = '\n }';
|
||||||
if (start < 0 || end < start) throw new Error('抽不到函式區塊');
|
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 src = html.slice(start, end);
|
||||||
|
|
||||||
const cases = [
|
const cases = [
|
||||||
@@ -26,11 +31,13 @@ for (const [name, ua] of cases) {
|
|||||||
console.log(` url: ${url}`);
|
console.log(` url: ${url}`);
|
||||||
if (name==='Windows') {
|
if (name==='Windows') {
|
||||||
chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);
|
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('仍要執行'));
|
chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));
|
||||||
}
|
}
|
||||||
if (name==='Mac') {
|
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 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));
|
||||||
chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));
|
chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,108 @@
|
|||||||
/**
|
/**
|
||||||
* deploy.mjs — 依具名目標部署 console-ui 到 Cloudflare Pages
|
* 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 立,實際踩到才補):
|
* 為什麼不直接用 `wrangler pages deploy`(2026-07-22 leo 立,實際踩到才補):
|
||||||
* **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案**
|
* **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案**
|
||||||
* · leo21c → arcrun-console-ui.pages.dev(個人版 console)
|
* wrangler 若 OAuth 登入在別的帳號,`--project-name arcrun-console-ui` 會部到別人的站上。
|
||||||
* · uncle6 → 綁 rag-demo.arcrun.dev(企業版 demo 站)
|
|
||||||
* wrangler 若 OAuth 登入在 uncle6,`--project-name arcrun-console-ui` 會部到 demo 站上。
|
|
||||||
* 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。
|
* 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。
|
||||||
*
|
*
|
||||||
* 同時把 profile/apiBase 綁進目標(deploy.targets.json),不再靠部署者記得帶環境變數——
|
* 同時把 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 { spawnSync } from 'node:child_process';
|
||||||
import { dirname, join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 args = process.argv.slice(2);
|
||||||
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
|
const dryRun = args.includes('--dry-run');
|
||||||
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
|
const name = args.find((a) => !a.startsWith('--'));
|
||||||
|
|
||||||
const name = process.argv[2];
|
let t;
|
||||||
if (!name || !targets[name]) {
|
try {
|
||||||
console.error(`用法:npm run deploy:<target>\n可用目標:${names.join(' / ')}`);
|
if (!name) throw Object.assign(new Error('沒有指定部署目標'), { usage: true });
|
||||||
if (name) console.error(`(收到未知目標:"${name}")`);
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const t = targets[name];
|
|
||||||
|
|
||||||
console.log(`\n部署目標:${name}`);
|
console.log(`\n部署目標:${name}`);
|
||||||
console.log(` 說明 :${t.description}`);
|
console.log(` 說明 :${t.description}`);
|
||||||
console.log(` 帳號 :${t.accountId}`);
|
console.log(` 帳號 :${t.accountId}`);
|
||||||
console.log(` 專案 :${t.projectName}`);
|
console.log(` 專案 :${t.projectName}`);
|
||||||
console.log(` profile :${t.profile}`);
|
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 };
|
// ── ①② 產出 + 驗產物 ────────────────────────────────────────────────
|
||||||
|
const outDir = join(ROOT, '.staging', name);
|
||||||
// t160(leo 07-31:「如果你會搞不清楚,就把錯的東西刪掉」):build 步驟已隨舊世代
|
try {
|
||||||
// src/ 一起 git rm——public/ 是唯一世代真身(手改演進),deploy=直接託管它。
|
buildArtifact(t, outDir);
|
||||||
// 病史:src/(舊代 renderer 快照)與 public/(新代真身)並存,deploy 自動跑 build
|
} catch (e) {
|
||||||
// 從舊 src 重產 public ⇒ 任何一次部署都可能把 UI 打回舊世代(07-27 記帳、07-31 引爆:
|
console.error(`\n✘ 產出失敗:${e.message}`);
|
||||||
// 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。');
|
|
||||||
process.exit(1);
|
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:本地部署常有未提交變更,不因此中斷
|
// --commit-dirty:本地部署常有未提交變更,不因此中斷
|
||||||
const deploy = spawnSync(
|
const deploy = spawnSync(
|
||||||
'npx',
|
'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 },
|
{ 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 當代。');
|
||||||
|
}
|
||||||
@@ -21,98 +21,73 @@ import type { Bindings } from '../types';
|
|||||||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||||
import { createArcrunHostFunctions } from '../lib/wasi-shim';
|
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 寫入)。解密發生在獨立的
|
// 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的
|
||||||
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
|
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
|
||||||
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref],T4)取明文
|
// 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref)→ 用 secret_get(ref)
|
||||||
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
|
// (即 env[ref],T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
|
||||||
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
// 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
|
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
|
||||||
// 內,解密/注入邏輯仍全在 WASM 零件。
|
// 內,解密/注入邏輯仍全在 WASM 零件。
|
||||||
|
|
||||||
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
|
|
||||||
interface CredentialRefRow {
|
|
||||||
name: string;
|
|
||||||
secret_ref: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
|
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
|
||||||
*
|
*
|
||||||
* 流程:查 D1 `credentials`(api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
|
* 流程:查 KBDB credential 目錄(api_key + name,快取命中零網路呼叫)拿 `secret_ref`
|
||||||
* (host function,實作 = env[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 缺席**(不是放空字串!),
|
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
|
||||||
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
|
* 讓 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——遷移過渡期(雙讀)本就允許「新家還沒資料」。
|
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
|
||||||
*/
|
*/
|
||||||
|
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
|
||||||
|
type ResolvedSecretMap = Record<string, string>;
|
||||||
|
|
||||||
export async function resolveSecretsFromNewHome(
|
export async function resolveSecretsFromNewHome(
|
||||||
env: Bindings,
|
env: Bindings,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
names: string[],
|
names: string[],
|
||||||
): Promise<Record<string, string>> {
|
): Promise<ResolvedSecretMap> {
|
||||||
const resolved: Record<string, string> = {};
|
const resolved: ResolvedSecretMap = {};
|
||||||
if (names.length === 0) return resolved;
|
if (names.length === 0) return resolved;
|
||||||
|
|
||||||
const db = env.CREDENTIALS_DB;
|
// 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts)
|
||||||
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
|
const refs = await getCredentialSecretRefs(env, apiKey);
|
||||||
|
if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 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;
|
|
||||||
|
|
||||||
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref])
|
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref])
|
||||||
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
|
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
|
||||||
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
|
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
|
||||||
|
|
||||||
const resolvedNames: string[] = [];
|
const resolvedNames: string[] = [];
|
||||||
for (const row of rows) {
|
for (const name of names) {
|
||||||
const value = await secretGet(row.secret_ref);
|
const ref = refs[name];
|
||||||
|
if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback
|
||||||
|
const value = await secretGet(ref);
|
||||||
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
|
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
|
||||||
if (value === null) continue;
|
if (value === null) continue;
|
||||||
resolved[row.name] = value;
|
resolved[name] = value;
|
||||||
resolvedNames.push(row.name);
|
resolvedNames.push(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
|
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name;fire-and-forget,非關鍵路徑)
|
||||||
if (resolvedNames.length > 0) {
|
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
|
||||||
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 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* Execution Logger — 執行結果寫入 ANALYTICS_KV(fire-and-forget)
|
* Execution Logger — 執行結果寫入 KBDB(fire-and-forget)
|
||||||
*
|
*
|
||||||
* 設計:每次 workflow 執行後,將統計數據寫入 ANALYTICS_KV(key = stats:{workflowId})。
|
* KV 額度事故修復(總管交辦,2026-08-07):舊版寫 ANALYTICS_KV(Workers KV),
|
||||||
* Phase 7 可升級為 POST 至 registry.arcrun.dev/analytics/record。
|
* 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 type { Bindings, GraphNode } from '../types';
|
||||||
|
import { kbdbBase } from '../routes/kbdb-proxy';
|
||||||
|
|
||||||
export interface ExecutionVerdict {
|
export interface ExecutionVerdict {
|
||||||
workflow_id: string;
|
workflow_id: string;
|
||||||
component_ids: string[];
|
|
||||||
verdict: 'success' | 'failed';
|
verdict: 'success' | 'failed';
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
message: string;
|
message: string;
|
||||||
recorded_at: string;
|
target?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 寫入執行結果至 ANALYTICS_KV(fire-and-forget,不阻擋主流程)
|
* 從觸發時的 trigger context 擷取這次處理的目標(page_name / path),供「哪些檔沒進去」
|
||||||
* 由 c.executionCtx.waitUntil() 包裹呼叫
|
* 這種問題答得出來。只認這兩個 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(
|
export async function writeExecutionVerdict(
|
||||||
env: Bindings,
|
env: Bindings,
|
||||||
@@ -27,27 +59,25 @@ export async function writeExecutionVerdict(
|
|||||||
verdict: 'success' | 'failed',
|
verdict: 'success' | 'failed',
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
message: string,
|
message: string,
|
||||||
|
input?: Record<string, unknown>,
|
||||||
|
apiKey?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
void nodes; // 少記:不再從節點算 component_ids,保留參數只為呼叫端相容
|
||||||
try {
|
try {
|
||||||
const componentIds = nodes
|
const { base, headers } = kbdbBase(env);
|
||||||
.filter(n => n.type === 'Component' && n.componentId)
|
await fetch(`${base}/execution-log/record`, {
|
||||||
.map(n => n.componentId!);
|
method: 'POST',
|
||||||
|
headers,
|
||||||
const record: ExecutionVerdict = {
|
body: JSON.stringify({
|
||||||
workflow_id: workflowId,
|
workflow_id: workflowId,
|
||||||
component_ids: componentIds,
|
owner_id: apiKey ?? null,
|
||||||
verdict,
|
verdict,
|
||||||
duration_ms: durationMs,
|
duration_ms: Math.max(0, Math.round(durationMs)),
|
||||||
message,
|
message: message ?? '',
|
||||||
recorded_at: new Date().toISOString(),
|
target: extractTarget(input) ?? null,
|
||||||
};
|
}),
|
||||||
|
|
||||||
// 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 天
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// fire-and-forget:不拋錯,不影響主流程
|
// fire-and-forget:任何錯誤(含 KBDB 端額度打滿、網路失敗)都吞掉、不影響主流程
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,28 @@ export function buildExecutionGraph(
|
|||||||
iterator = foreachMatch[1];
|
iterator = foreachMatch[1];
|
||||||
label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP
|
label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP
|
||||||
}
|
}
|
||||||
const edge: { from: string; to: string; type: ReturnType<typeof toEdgeType>; iterator?: string } = {
|
|
||||||
|
// 「ON_BRANCH(標籤)」抽 branch:意圖語法表達具名分支(SDD workflow-discovery 3.11)
|
||||||
|
// 例:'my_switch >> ON_BRANCH(branch_active) >> 處理啟用' → type=ON_BRANCH, branch='branch_active'
|
||||||
|
// 沒有這段的話,帶括號的 label 會落到 toEdgeType 的預設值 PIPE ⇒ 分支靜默失效
|
||||||
|
// (即「教了語法但引擎不收」——比沒做更糟,故與 skill 文件同批補上)
|
||||||
|
let branch: string | undefined;
|
||||||
|
const branchMatch = label.match(/^(?:ON_BRANCH|分支)\s*[((]\s*([\w-]+)\s*[))]$/i);
|
||||||
|
if (branchMatch) {
|
||||||
|
branch = branchMatch[1];
|
||||||
|
label = 'ON_BRANCH';
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge: {
|
||||||
|
from: string; to: string; type: ReturnType<typeof toEdgeType>;
|
||||||
|
iterator?: string; branch?: string;
|
||||||
|
} = {
|
||||||
from: e.from.toLowerCase().replace(/\s+/g, '-'),
|
from: e.from.toLowerCase().replace(/\s+/g, '-'),
|
||||||
to: e.to.toLowerCase().replace(/\s+/g, '-'),
|
to: e.to.toLowerCase().replace(/\s+/g, '-'),
|
||||||
type: toEdgeType(label),
|
type: toEdgeType(label),
|
||||||
};
|
};
|
||||||
if (iterator) edge.iterator = iterator;
|
if (iterator) edge.iterator = iterator;
|
||||||
|
if (branch) edge.branch = branch;
|
||||||
return edge;
|
return edge;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||||||
import { resolveNodeRole, isVirtualIoName } 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 { resolveRecipe } from '../routes/recipes';
|
||||||
import type { RecipeDefinition } from '../routes/recipes';
|
import type { RecipeDefinition } from '../routes/recipes';
|
||||||
|
import { branchHintFor } from '../lib/branch-hints';
|
||||||
|
import type { BranchHint } from '../lib/branch-hints';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `not_found` 而非 `missing`:欄位契約以頂層機械考
|
* `not_found` 而非 `missing`:欄位契約以頂層機械考
|
||||||
@@ -42,8 +44,12 @@ export type NodeInfo = {
|
|||||||
status: NodeStatus;
|
status: NodeStatus;
|
||||||
componentId?: string;
|
componentId?: string;
|
||||||
type: NodeRole;
|
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)。 */
|
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
|
||||||
input_schema?: unknown;
|
input_schema?: unknown;
|
||||||
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
|
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
|
||||||
@@ -52,6 +58,18 @@ export type NodeInfo = {
|
|||||||
/** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API)。 */
|
/** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API)。 */
|
||||||
description?: string;
|
description?: string;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
|
/**
|
||||||
|
* recipe 的 payload/回應用法自我說明(3.12,同 branch_hint 的動機):
|
||||||
|
* 逐顆查 recipe 時光看 endpoint 不知道「payload 怎麼填、回應怎麼取值」⇒ 會退回寫 code。
|
||||||
|
*/
|
||||||
|
payload_hint?: {
|
||||||
|
/** 這個 recipe 期望的 body 形狀(body_template 的欄位骨架,值是 {{var}} 佔位) */
|
||||||
|
body_template?: unknown;
|
||||||
|
/** 回應正規化規則存在時,說明取值路徑等 */
|
||||||
|
response_map?: unknown;
|
||||||
|
/** 一行說明:怎麼用這個 recipe */
|
||||||
|
usage: string;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* not_found 時的分型指路(task 3.7):兩庫(零件 registry+recipe 庫)都查過才點名,
|
* not_found 時的分型指路(task 3.7):兩庫(零件 registry+recipe 庫)都查過才點名,
|
||||||
* 並告訴 AI 該走哪條補件路+去哪裡看做法。欄位名 `suggestion`(單數字串)=verify.sh 03 組契約。
|
* 並告訴 AI 該走哪條補件路+去哪裡看做法。欄位名 `suggestion`(單數字串)=verify.sh 03 組契約。
|
||||||
@@ -63,6 +81,13 @@ export type NodeInfo = {
|
|||||||
similar_recipes?: string[];
|
similar_recipes?: string[];
|
||||||
/** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */
|
/** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */
|
||||||
substitution?: NodeSubstitution;
|
substitution?: NodeSubstitution;
|
||||||
|
/**
|
||||||
|
* 分支用法自我說明(3.11):只有「本身會分岔」的零件才有
|
||||||
|
* (if_control/switch/try_catch)。
|
||||||
|
* 存在的理由=走 n8n 式「逐顆查、自己組圖」的 AI,光看 input_schema 不知道
|
||||||
|
* 「判斷完之後兩條路怎麼接」⇒ 會回頭寫 code。判準:只看這一顆的回應就知道怎麼接下一步。
|
||||||
|
*/
|
||||||
|
branch_hint?: BranchHint;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchResult = {
|
export type SearchResult = {
|
||||||
@@ -191,6 +216,25 @@ export async function searchNodes(
|
|||||||
continue;
|
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。
|
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
|
||||||
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
||||||
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
|
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
|
||||||
@@ -216,6 +260,7 @@ export async function searchNodes(
|
|||||||
input_schema: hit.input_schema,
|
input_schema: hit.input_schema,
|
||||||
success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined,
|
success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined,
|
||||||
stability: typeof hit.stability === 'string' ? hit.stability : undefined,
|
stability: typeof hit.stability === 'string' ? hit.stability : undefined,
|
||||||
|
branch_hint: branchHintFor(componentId),
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -230,6 +275,7 @@ export async function searchNodes(
|
|||||||
source: 'recipe',
|
source: 'recipe',
|
||||||
description: recipe.description,
|
description: recipe.description,
|
||||||
endpoint: recipe.endpoint,
|
endpoint: recipe.endpoint,
|
||||||
|
payload_hint: buildPayloadHint(recipe),
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -355,6 +401,7 @@ async function legacyPerNodeLookup(
|
|||||||
info: {
|
info: {
|
||||||
status: 'found', componentId, type: role, source: 'component',
|
status: 'found', componentId, type: role, source: 'component',
|
||||||
input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability,
|
input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability,
|
||||||
|
branch_hint: branchHintFor(componentId),
|
||||||
},
|
},
|
||||||
missing: false,
|
missing: false,
|
||||||
};
|
};
|
||||||
@@ -366,6 +413,7 @@ async function legacyPerNodeLookup(
|
|||||||
info: {
|
info: {
|
||||||
status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe',
|
status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe',
|
||||||
description: recipe.description, endpoint: recipe.endpoint,
|
description: recipe.description, endpoint: recipe.endpoint,
|
||||||
|
payload_hint: buildPayloadHint(recipe),
|
||||||
},
|
},
|
||||||
missing: false,
|
missing: false,
|
||||||
};
|
};
|
||||||
@@ -407,7 +455,7 @@ async function legacyPerNodeLookup(
|
|||||||
type SubstitutionHit = Pick<
|
type SubstitutionHit = Pick<
|
||||||
NodeInfo,
|
NodeInfo,
|
||||||
'status' | 'componentId' | 'source' | 'substitution' |
|
'status' | 'componentId' | 'source' | 'substitution' |
|
||||||
'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint'
|
'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' | 'branch_hint'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function trySubstitution(
|
function trySubstitution(
|
||||||
@@ -472,6 +520,9 @@ function trySubstitution(
|
|||||||
input_schema: top.entry.input_schema,
|
input_schema: top.entry.input_schema,
|
||||||
success_rate: typeof top.entry.success_rate === 'number' ? top.entry.success_rate : undefined,
|
success_rate: typeof top.entry.success_rate === 'number' ? top.entry.success_rate : undefined,
|
||||||
stability: typeof top.entry.stability === 'string' ? top.entry.stability : undefined,
|
stability: typeof top.entry.stability === 'string' ? top.entry.stability : undefined,
|
||||||
|
// 替換成分岔零件時(例「判斷有沒有新資料」→ if_control)一併附分支用法,
|
||||||
|
// 否則 AI 換到零件卻不知道怎麼接兩條路,仍會退回寫 code。
|
||||||
|
branch_hint: branchHintFor(top.entry.canonical_id),
|
||||||
substitution: {
|
substitution: {
|
||||||
from: nodeName,
|
from: nodeName,
|
||||||
componentId: top.entry.canonical_id,
|
componentId: top.entry.canonical_id,
|
||||||
@@ -539,6 +590,41 @@ function buildSuggestion(componentId: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recipe 的 payload/回應用法自我說明(3.12)。
|
||||||
|
* 動機同 branch_hint:逐顆查 recipe(n8n 式)時,光看 endpoint 不知道 payload 怎麼填、
|
||||||
|
* 回應怎麼取值 ⇒ AI 會退回把整包寫進 workflow code。
|
||||||
|
*/
|
||||||
|
export function buildPayloadHint(recipe: RecipeDefinition): NodeInfo['payload_hint'] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (recipe.body_template) {
|
||||||
|
parts.push('payload 已收在 recipe 的 body_template 裡,你只要把 {{變數}} 對應的值放進節點 context');
|
||||||
|
} else if (recipe.body) {
|
||||||
|
parts.push('payload 形狀見 body 欄位({{變數}} 由節點 context 填)');
|
||||||
|
} else {
|
||||||
|
parts.push('未定義 body_template:節點 context 會整包當 body 送出(_ 開頭的內部欄位會被剔除)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe.response_map) {
|
||||||
|
parts.push('回應已正規化:執行結果除了原始 data,另附 text(取值路徑等規則寫在 recipe 裡,換源不必改 workflow)');
|
||||||
|
} else {
|
||||||
|
parts.push('未定義 response_map:回應原樣放在 data,取值要自己指路徑');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe.auth === 'binding') {
|
||||||
|
parts.push(`認證=binding(免金鑰,用平台內建 ${recipe.binding_name ?? 'AI'})`);
|
||||||
|
} else if (recipe.auth_service) {
|
||||||
|
parts.push(`認證走 auth recipe「${recipe.auth_service}」(金鑰由系統在執行前注入,你不必也不該填)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
body_template: recipe.body_template,
|
||||||
|
response_map: recipe.response_map,
|
||||||
|
usage: parts.join(';') + '。',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── registry 查詢 ─────────────────────────────────────────────────────────────
|
// ── registry 查詢 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type CatalogEntry = {
|
type CatalogEntry = {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||||
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
|
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
|
||||||
import { listAllRecipes, type SearchNodesEnv } from './search-nodes';
|
import { listAllRecipes, buildPayloadHint, type SearchNodesEnv } from './search-nodes';
|
||||||
|
import { branchHintFor } from '../lib/branch-hints';
|
||||||
|
|
||||||
export type TargetQueryEnv = SearchNodesEnv & {
|
export type TargetQueryEnv = SearchNodesEnv & {
|
||||||
KBDB_BASE_URL?: string;
|
KBDB_BASE_URL?: string;
|
||||||
@@ -44,12 +45,21 @@ export async function searchByTarget(
|
|||||||
);
|
);
|
||||||
if (!res.ok) return { ok: false, status: 502, error: `registry 搜尋失敗(HTTP ${res.status})` };
|
if (!res.ok) return { ok: false, status: 502, error: `registry 搜尋失敗(HTTP ${res.status})` };
|
||||||
const body = (await res.json()) as { data?: { results?: unknown[]; count?: number } };
|
const body = (await res.json()) as { data?: { results?: unknown[]; count?: number } };
|
||||||
|
// 3.11:逐顆查零件(n8n 式「自己一顆一顆填」)時,會分岔的零件要自我說明分支用法。
|
||||||
|
// leo 08-01:「它可以一一查詢自己手工填寫每個零件,就像在 n8n 那樣」——
|
||||||
|
// 這條路徑若只回 input_schema,AI 拿到 if_control/switch 仍不知道兩條路怎麼接 ⇒ 回頭寫 code。
|
||||||
|
const results = (body.data?.results ?? []).map(r => {
|
||||||
|
if (!r || typeof r !== 'object') return r;
|
||||||
|
const rec = r as Record<string, unknown>;
|
||||||
|
const hint = branchHintFor(typeof rec.canonical_id === 'string' ? rec.canonical_id : undefined);
|
||||||
|
return hint ? { ...rec, branch_hint: hint } : rec;
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
body: {
|
body: {
|
||||||
target,
|
target,
|
||||||
query,
|
query,
|
||||||
results: body.data?.results ?? [],
|
results,
|
||||||
count: body.data?.count ?? 0,
|
count: body.data?.count ?? 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -64,7 +74,10 @@ export async function searchByTarget(
|
|||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
// 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重
|
// 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const results: Array<{ canonical_id: string; display_name?: string; description?: string; endpoint: string }> = [];
|
const results: Array<{
|
||||||
|
canonical_id: string; display_name?: string; description?: string; endpoint: string;
|
||||||
|
payload_hint?: unknown;
|
||||||
|
}> = [];
|
||||||
for (const r of all) {
|
for (const r of all) {
|
||||||
if (seen.has(r.canonical_id)) continue;
|
if (seen.has(r.canonical_id)) continue;
|
||||||
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
|
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
|
||||||
@@ -75,6 +88,8 @@ export async function searchByTarget(
|
|||||||
display_name: r.display_name,
|
display_name: r.display_name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
endpoint: r.endpoint,
|
endpoint: r.endpoint,
|
||||||
|
// 3.12:逐顆查 recipe 時也要說得出「payload 怎麼填、回應怎麼取值」
|
||||||
|
payload_hint: buildPayloadHint(r),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -348,7 +348,15 @@ export class GraphExecutor {
|
|||||||
|
|
||||||
// BUILD-006:將節點 output 存入 KV(key = {run_id}:node:{node_id})
|
// BUILD-006:將節點 output 存入 KV(key = {run_id}:node:{node_id})
|
||||||
// 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突
|
// 這讓下游節點可以透過 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);
|
await kvSetNodeOutput(kvStore, node.id, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +486,37 @@ export class GraphExecutor {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 條件邊(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5 缺口①)──
|
||||||
|
// 為什麼要有:`if_control` 回 {result, branch} 卻沒有邊讀得懂它,
|
||||||
|
// AI 照規矩用了零件仍得寫 code 判斷走哪條 ⇒「全變成 code」的根(Arcrun#5)。
|
||||||
|
// 讀法對齊零件 output_schema:優先 data.branch(if_control/switch 的正式形狀),
|
||||||
|
// 相容 top-level branch / result 布林。讀不出分支=不走(誠實,不亂挑一條)。
|
||||||
|
case 'ON_TRUE': {
|
||||||
|
if (readBranch(result) === 'true') {
|
||||||
|
const mergedCtx = propagateCtx(context, result, node.id);
|
||||||
|
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ON_FALSE': {
|
||||||
|
if (readBranch(result) === 'false') {
|
||||||
|
const mergedCtx = propagateCtx(context, result, node.id);
|
||||||
|
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ON_BRANCH': {
|
||||||
|
// switch 具名分支:邊上的 branch 要跟上游 output 的 branch 字面相等才走
|
||||||
|
const actual = readBranch(result);
|
||||||
|
if (edge.branch !== undefined && actual !== undefined && actual === edge.branch) {
|
||||||
|
const mergedCtx = propagateCtx(context, result, node.id);
|
||||||
|
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'FOREACH': {
|
case 'FOREACH': {
|
||||||
const iteratorKey = edge.iterator ?? 'item';
|
const iteratorKey = edge.iterator ?? 'item';
|
||||||
// 找 iterable 順序:先看上游 output (result),沒有再看完整 context (含上游 chain 累積的 fields)
|
// 找 iterable 順序:先看上游 output (result),沒有再看完整 context (含上游 chain 累積的 fields)
|
||||||
@@ -500,6 +539,26 @@ export class GraphExecutor {
|
|||||||
iterResults.push(itemResult);
|
iterResults.push(itemResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤
|
||||||
|
if (iterResults.length > 0) {
|
||||||
|
const failures = iterResults.filter(
|
||||||
|
r => r !== null && typeof r === 'object' && (r as Record<string, unknown>).success === false
|
||||||
|
);
|
||||||
|
if (failures.length === iterResults.length) {
|
||||||
|
const first = failures[0] as Record<string, unknown>;
|
||||||
|
const errParts: string[] = [];
|
||||||
|
if (first.error) errParts.push(String(first.error));
|
||||||
|
if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`);
|
||||||
|
const bodyData = first.data as { body?: string } | null | undefined;
|
||||||
|
if (bodyData && typeof bodyData.body === 'string' && bodyData.body) {
|
||||||
|
errParts.push(bodyData.body.slice(0, 200));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join(';') || '未知錯誤'})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result = { ...(result as Record<string, unknown>), results: iterResults };
|
result = { ...(result as Record<string, unknown>), results: iterResults };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -631,6 +690,30 @@ function getNestedValue(ctx: unknown, path: string): unknown {
|
|||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從節點 output 讀出「走哪條分支」(SDD workflow-discovery 3.11)
|
||||||
|
*
|
||||||
|
* 讀取順序(對齊零件 contract 的 output_schema,由正式到相容):
|
||||||
|
* 1. `data.branch` —— if_control / switch 的正式輸出形狀 {success, data:{result, branch}}
|
||||||
|
* 2. `branch` —— 已被 propagateCtx spread 到 top-level 的情況
|
||||||
|
* 3. `data.result` —— 只有布林沒有 branch 的零件
|
||||||
|
* 4. `result` —— top-level 布林
|
||||||
|
* 讀不出來回 undefined ⇒ 呼叫端一律不走該邊(誠實:寧可不走,不亂挑一條)。
|
||||||
|
*/
|
||||||
|
function readBranch(result: unknown): string | undefined {
|
||||||
|
if (!result || typeof result !== 'object') return undefined;
|
||||||
|
const r = result as Record<string, unknown>;
|
||||||
|
const data = (r.data && typeof r.data === 'object') ? r.data as Record<string, unknown> : undefined;
|
||||||
|
|
||||||
|
const named = data?.branch ?? r.branch;
|
||||||
|
if (typeof named === 'string') return named;
|
||||||
|
|
||||||
|
const bool = data?.result ?? r.result;
|
||||||
|
if (typeof bool === 'boolean') return bool ? 'true' : 'false';
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** 判斷節點執行結果是否為失敗:success === false 或含有 error key */
|
/** 判斷節點執行結果是否為失敗:success === false 或含有 error key */
|
||||||
function isFailure(result: unknown): boolean {
|
function isFailure(result: unknown): boolean {
|
||||||
if (!result || typeof result !== 'object') return false;
|
if (!result || typeof result !== 'object') return false;
|
||||||
|
|||||||
@@ -48,7 +48,28 @@ app.use('*', cors({
|
|||||||
extra = String((c.env as Record<string, unknown>).UI_ORIGINS || '')
|
extra = String((c.env as Record<string, unknown>).UI_ORIGINS || '')
|
||||||
.split(',').map((s: string) => s.trim()).filter(Boolean);
|
.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||||
} catch { /* UI_ORIGINS 未設定=只用靜態白名單 */ }
|
} 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'],
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
|
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
|
||||||
|
|||||||
@@ -22,13 +22,21 @@
|
|||||||
* KBDB 改網址後同步更新此處。seed 先照現況進。
|
* KBDB 改網址後同步更新此處。seed 先照現況進。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ResponseMap } from './recipe-payload';
|
||||||
|
|
||||||
export interface ApiRecipeSeed {
|
export interface ApiRecipeSeed {
|
||||||
canonical_id: string;
|
canonical_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** HTTP recipe=要打的網址;`auth: 'binding'` 型=要呼叫的資源名(如 Workers AI 的模型 id)。 */
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
method: string;
|
method: string;
|
||||||
auth_service?: string;
|
auth_service?: string;
|
||||||
|
// ── payload/回應/binding 三層(3.12):全選填,既有種子不帶=行為完全不變 ──
|
||||||
|
body_template?: Record<string, unknown>;
|
||||||
|
response_map?: ResponseMap;
|
||||||
|
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
|
||||||
|
binding_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
|
export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
|
||||||
@@ -120,4 +128,47 @@ export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
auth_service: 'line_notify',
|
auth_service: 'line_notify',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── LLM 對話(binding=免金鑰,3.12 第四型認證的第一個真實案例)──
|
||||||
|
//
|
||||||
|
// 為什麼進種子(而非寫在某個產品的安裝器裡):「裝好之後預設有哪些 recipe」是平台能力,
|
||||||
|
// 與本檔其餘種子同理由(見檔頭)。裝完 /init/seed 就有 ⇒ **用戶不填任何金鑰就能問答**。
|
||||||
|
//
|
||||||
|
// 換模型/換供應商=**改這一筆 recipe**(endpoint + body_template + response_map),
|
||||||
|
// workflow 的 ask_llm 節點不動——這正是「換源=換 recipe 不是換引擎」。
|
||||||
|
//
|
||||||
|
// 選型實測(2026-08-03,在 1.4.4 實例上跑真實長度的 RAG prompt,每個模型連跑 2 次):
|
||||||
|
// @cf/meta/llama-4-scout-17b-16e-instruct 2373/2173 ms ✅ 答案最完整、引用正確
|
||||||
|
// @cf/meta/llama-3.3-70b-instruct-fp8-fast 3261/2147 ms ✅ 可用但波動較大
|
||||||
|
// @cf/mistralai/mistral-small-3.1-24b-instruct 3560/3631 ms
|
||||||
|
// @cf/qwen/qwen2.5-coder-32b-instruct 3572/3353 ms
|
||||||
|
// @cf/openai/gpt-oss-120b 1971/2295 ms ❌ 回應形狀不同,response 取不到文字
|
||||||
|
// @cf/google/gemma-3-12b-it ❌ 5018 This account is not allowed to access this model
|
||||||
|
// 對照舊路徑(Gemini `gemma-4-31b-it`):同型提問 **16.87 s**,且吐整段英文思考草稿
|
||||||
|
// ⇒ 選 llama-4-scout:**快 7 倍以上,且不需要淨化思考草稿**。
|
||||||
|
{
|
||||||
|
canonical_id: 'workers_ai_chat',
|
||||||
|
display_name: 'Workers AI 對話(免金鑰)',
|
||||||
|
description:
|
||||||
|
'Cloudflare Workers AI 文字生成,走 env.AI binding ⇒ 不需要任何 API 金鑰。'
|
||||||
|
+ 'ctx 帶 prompt,回應正規化成 text(含【答】標記與前綴淨化)。'
|
||||||
|
+ '換模型=改本 recipe 的 endpoint,workflow 不動。',
|
||||||
|
endpoint: '@cf/meta/llama-4-scout-17b-16e-instruct',
|
||||||
|
method: 'POST',
|
||||||
|
auth: 'binding',
|
||||||
|
binding_name: 'AI',
|
||||||
|
body_template: {
|
||||||
|
messages: [{ role: 'user', content: '{{prompt}}' }],
|
||||||
|
max_tokens: 1024,
|
||||||
|
temperature: 0.2,
|
||||||
|
},
|
||||||
|
response_map: {
|
||||||
|
// Workers AI chat 回應:{ response: "…" }(另有 OpenAI 相容的 choices,取 response 最穩)
|
||||||
|
text_path: 'response',
|
||||||
|
// 提示詞要求答案以【答】開頭;模型偶爾會在前面多帶一行 ⇒ 取最後一個標記之後
|
||||||
|
answer_marker: '【答】',
|
||||||
|
// 前綴組合順序不定,循環剝殼(規則見 recipe-payload.ts sanitize)
|
||||||
|
strip_prefixes: ['*', '-', '•', '>', '#', '"', '「', '【答】', 'Answer:', 'Draft:'],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* 分支用法自我說明(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5)
|
||||||
|
*
|
||||||
|
* 為什麼需要這一層(leo 08-01 逼出的洞,別刪):
|
||||||
|
* leo:「它也可以不要送整個意圖工作流去查詢,它可以**一一查詢自己手工填寫每個零件,
|
||||||
|
* 就像在 n8n 那樣**,這時它不會每個都寫 code?」
|
||||||
|
* 取證:逐顆查 `if_control`,回應只有 {status, componentId, input_schema, success_rate…},
|
||||||
|
* `input_schema` 只說得出 {condition, input}——**沒有任何欄位告訴 AI「判斷完之後兩條路怎麼分岔」**
|
||||||
|
* ⇒ 走 n8n 式逐顆查、自己組圖的 AI 拿到 if_control 後必然卡在「然後呢」,回頭寫 code。
|
||||||
|
*
|
||||||
|
* 判準(leo 一貫要求:資訊出現在需要它的那一刻):
|
||||||
|
* **AI 只看這一顆的查詢回應,就知道怎麼接下一步**,不必回頭讀 skill。
|
||||||
|
*
|
||||||
|
* 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string`
|
||||||
|
* ⇒ 引擎只有「依標籤選邊」一個機制(ON_BRANCH),ON_TRUE/ON_FALSE 是布林路的語法糖。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BranchHint = {
|
||||||
|
/** 這顆零件會輸出哪個欄位當分支標籤 */
|
||||||
|
branch_field: string;
|
||||||
|
/** 可能的分支標籤(switch 是動態的,故標明由 cases 決定) */
|
||||||
|
branches: string[] | string;
|
||||||
|
/** 接下游要用哪些邊型 */
|
||||||
|
edge_types: string[];
|
||||||
|
/** 一行說明:這顆零件之後怎麼分岔 */
|
||||||
|
usage: string;
|
||||||
|
/** 可直接照抄的最小範例(意圖語法+對應的邊) */
|
||||||
|
example: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 零件 → 分支用法。key = canonical_id。
|
||||||
|
* 只收「本身會分岔」的零件;不分岔的零件不該有 branch_hint(避免噪音)。
|
||||||
|
*/
|
||||||
|
const BRANCH_HINTS: Record<string, BranchHint> = {
|
||||||
|
if_control: {
|
||||||
|
branch_field: 'data.branch',
|
||||||
|
branches: ['true', 'false'],
|
||||||
|
edge_types: ['ON_TRUE', 'ON_FALSE'],
|
||||||
|
usage:
|
||||||
|
'這顆算完會輸出 data.branch("true"/"false")。下游接兩條邊:ON_TRUE 接條件成立要做的事,' +
|
||||||
|
'ON_FALSE 接不成立要做的事。**不需要自己寫 code 判斷走哪條**——引擎依 branch 自動選路。',
|
||||||
|
example:
|
||||||
|
'判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram\n' +
|
||||||
|
'判斷有沒有新資料 >> ON_FALSE >> 結束\n' +
|
||||||
|
'(中文語意詞亦可:「成立時」=ON_TRUE、「否則」=ON_FALSE)',
|
||||||
|
},
|
||||||
|
switch: {
|
||||||
|
branch_field: 'data.branch',
|
||||||
|
branches: '由 input_schema.cases[].branch 與 default_branch 決定(N 路,非固定清單)',
|
||||||
|
edge_types: ['ON_BRANCH'],
|
||||||
|
usage:
|
||||||
|
'這顆依 value 比對 cases,輸出 data.branch=命中那個 case 的 branch 名(都沒中則是 default_branch)。' +
|
||||||
|
'下游**每條路各接一條 ON_BRANCH 邊,並在邊上標 branch 等於你在 cases 裡取的名字**。' +
|
||||||
|
'default_branch 不需要特別的邊型,照樣用 ON_BRANCH 標它的名字即可。',
|
||||||
|
example:
|
||||||
|
'{"cases":[{"match":"active","branch":"branch_active"}],"default_branch":"branch_default"}\n' +
|
||||||
|
'edges: [\n' +
|
||||||
|
' {"from":"my_switch","to":"處理啟用","type":"ON_BRANCH","branch":"branch_active"},\n' +
|
||||||
|
' {"from":"my_switch","to":"處理其他","type":"ON_BRANCH","branch":"branch_default"}\n' +
|
||||||
|
']',
|
||||||
|
},
|
||||||
|
try_catch: {
|
||||||
|
branch_field: 'data.branch',
|
||||||
|
branches: ['try', 'catch'],
|
||||||
|
edge_types: ['ON_BRANCH'],
|
||||||
|
usage:
|
||||||
|
'這顆看上游 error 是否非空,輸出 data.branch("try"=沒錯/"catch"=有錯)。' +
|
||||||
|
'下游接兩條 ON_BRANCH 邊,branch 分別標 "try" 與 "catch"。' +
|
||||||
|
'**錯誤處理不需要寫 code**——把要補救的節點接在 catch 那條邊後面即可。',
|
||||||
|
example:
|
||||||
|
'edges: [\n' +
|
||||||
|
' {"from":"my_try_catch","to":"正常流程","type":"ON_BRANCH","branch":"try"},\n' +
|
||||||
|
' {"from":"my_try_catch","to":"補救流程","type":"ON_BRANCH","branch":"catch"}\n' +
|
||||||
|
']',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 取某零件的分支用法說明;不分岔的零件回 undefined(回應不加噪音)。 */
|
||||||
|
export function branchHintFor(componentId: string | undefined): BranchHint | undefined {
|
||||||
|
if (!componentId) return undefined;
|
||||||
|
return BRANCH_HINTS[componentId.toLowerCase()];
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { isComponentHash, isRecipeHash } from './hash';
|
|||||||
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
|
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
|
||||||
import type { AuthRecipeDefinition } from '../routes/recipes';
|
import type { AuthRecipeDefinition } from '../routes/recipes';
|
||||||
import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
|
import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
|
||||||
|
import { renderBodyTemplate, applyResponseMap } from './recipe-payload';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WASM HTTP runner:canonical_id → 對應獨立 Worker URL。
|
* WASM HTTP runner:canonical_id → 對應獨立 Worker URL。
|
||||||
@@ -87,6 +88,33 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
|||||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
// 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) {
|
export function createComponentLoader(env: Bindings) {
|
||||||
return async (componentId: string): Promise<ComponentRunner> => {
|
return async (componentId: string): Promise<ComponentRunner> => {
|
||||||
|
|
||||||
@@ -120,7 +148,7 @@ export function createComponentLoader(env: Bindings) {
|
|||||||
// 4. rec_hash → 查 RECIPES KV idx → recipe 執行
|
// 4. rec_hash → 查 RECIPES KV idx → recipe 執行
|
||||||
if (isRecipeHash(componentId)) {
|
if (isRecipeHash(componentId)) {
|
||||||
const recipe = await resolveRecipe(componentId, env.RECIPES);
|
const recipe = await resolveRecipe(componentId, env.RECIPES);
|
||||||
if (recipe) return makeRecipeRunner(recipe);
|
if (recipe) return pickRecipeRunner(recipe, env);
|
||||||
throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`);
|
throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +162,7 @@ export function createComponentLoader(env: Bindings) {
|
|||||||
|
|
||||||
// 6. KV recipe(動態,用戶 push 的)
|
// 6. KV recipe(動態,用戶 push 的)
|
||||||
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
|
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
|
||||||
if (kvRecipe) return makeRecipeRunner(kvRecipe);
|
if (kvRecipe) return pickRecipeRunner(kvRecipe, env);
|
||||||
|
|
||||||
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
|
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
|
||||||
// 白名單見 WASM_HTTP_RUNNER_IDS(http_request、5 個待降級 API 零件、4 個 auth primitive)。
|
// 白名單見 WASM_HTTP_RUNNER_IDS(http_request、5 個待降級 API 零件、4 個 auth primitive)。
|
||||||
@@ -271,6 +299,73 @@ function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner |
|
|||||||
return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN));
|
return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recipe → runner 的分派(3.12):auth='binding' 走平台 binding(免金鑰),
|
||||||
|
* 其餘一律走既有 HTTP 路徑(沒宣告 auth 的舊 recipe 完全不受影響)。
|
||||||
|
*/
|
||||||
|
function pickRecipeRunner(
|
||||||
|
recipe: import('../routes/recipes').RecipeDefinition,
|
||||||
|
env: Bindings,
|
||||||
|
): ComponentRunner {
|
||||||
|
return recipe.auth === 'binding'
|
||||||
|
? makeBindingRecipeRunner(recipe, env)
|
||||||
|
: makeRecipeRunner(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* auth='binding' 的 recipe runner(3.12 第四型認證):不打外部 HTTP、不需要任何金鑰,
|
||||||
|
* 直接用平台 binding(env.AI/VECTORIZE/…)⇒ leo 要的「開機就可用」。
|
||||||
|
*
|
||||||
|
* 為什麼要開這型:recipe 的舊抽象=「打一個外部 HTTP API」(endpoint+method+auth_service),
|
||||||
|
* 而 Cloudflare 的 binding 呼叫不是 HTTP ⇒ **整類能力被排除在 recipe 之外**。
|
||||||
|
* 開這一型不是為 Workers AI 開特例,是一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排。
|
||||||
|
*/
|
||||||
|
function makeBindingRecipeRunner(
|
||||||
|
recipe: import('../routes/recipes').RecipeDefinition,
|
||||||
|
env: Bindings,
|
||||||
|
): ComponentRunner {
|
||||||
|
return async (ctx: unknown) => {
|
||||||
|
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||||
|
const name = recipe.binding_name ?? 'AI';
|
||||||
|
const binding = (env as unknown as Record<string, unknown>)[name];
|
||||||
|
|
||||||
|
if (!binding) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`recipe "${recipe.canonical_id}" 宣告 auth: binding、binding_name: "${name}",` +
|
||||||
|
`但這個部署沒有綁定 ${name}。請在 wrangler.toml 補上該 binding 後重新部署。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpoint 在 binding 型當作「要呼叫的資源名」(例 Workers AI 的模型 id)
|
||||||
|
const target = recipe.endpoint;
|
||||||
|
const payload = renderBodyTemplate(recipe.body_template ?? recipe.body, ctxObj)
|
||||||
|
?? Object.fromEntries(Object.entries(ctxObj).filter(([k]) => !k.startsWith('_')));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runner = binding as { run?: (model: string, input: unknown) => Promise<unknown> };
|
||||||
|
if (typeof runner.run !== 'function') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `binding "${name}" 沒有 run() 方法,目前 binding 型只支援 run(model, input) 形狀(如 env.AI)。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = await runner.run(target, payload);
|
||||||
|
if (recipe.response_map) {
|
||||||
|
const normalized = applyResponseMap(data, recipe.response_map);
|
||||||
|
return { success: true, data, text: normalized.text };
|
||||||
|
}
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `binding "${name}" 呼叫失敗(${target}):${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner {
|
function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner {
|
||||||
return async (ctx: unknown) => {
|
return async (ctx: unknown) => {
|
||||||
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||||
@@ -293,9 +388,12 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
|
|||||||
headers[k] = interpolate(v);
|
headers[k] = interpolate(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
// body:把 recipe.body 裡的 {{key}} 都換掉
|
// body:優先 body_template(③ payload 層,3.12——支援巢狀/dot path/保留型別),
|
||||||
|
// 其次既有 recipe.body(淺層 {{key}},舊 recipe 照舊),最後才拿 ctx 當 body。
|
||||||
let bodyStr: string | undefined;
|
let bodyStr: string | undefined;
|
||||||
if (recipe.body) {
|
if (recipe.body_template) {
|
||||||
|
bodyStr = JSON.stringify(renderBodyTemplate(recipe.body_template, ctxObj));
|
||||||
|
} else if (recipe.body) {
|
||||||
bodyStr = interpolate(JSON.stringify(recipe.body));
|
bodyStr = interpolate(JSON.stringify(recipe.body));
|
||||||
} else if (method !== 'GET') {
|
} else if (method !== 'GET') {
|
||||||
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
|
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
|
||||||
@@ -313,6 +411,13 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data = await readBodyOnce(res);
|
const data = await readBodyOnce(res);
|
||||||
|
|
||||||
|
// ③ 回應正規化(3.12):未設 response_map ⇒ 原樣回傳(既有 recipe 零行為變化)。
|
||||||
|
// 設了 ⇒ 額外附 `text`(各家形狀差異收在 recipe 裡,換源不必改 workflow)。
|
||||||
|
if (recipe.response_map) {
|
||||||
|
const normalized = applyResponseMap(data, recipe.response_map);
|
||||||
|
return { success: res.ok, status: res.status, data, text: normalized.text };
|
||||||
|
}
|
||||||
return { success: res.ok, status: res.status, data };
|
return { success: res.ok, status: res.status, data };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const VALID_EDGE_TYPES = new Set([
|
|||||||
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
|
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
|
||||||
// 新增:執行語意
|
// 新增:執行語意
|
||||||
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
|
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
|
||||||
|
// 新增:條件語意(SDD workflow-discovery 3.11)—— 讀上游 if_control/switch 的 branch
|
||||||
|
'ON_TRUE', 'ON_FALSE', 'ON_BRANCH',
|
||||||
// 新增:觸發語意
|
// 新增:觸發語意
|
||||||
'ON_CLICK', 'CALLS_SUBFLOW',
|
'ON_CLICK', 'CALLS_SUBFLOW',
|
||||||
// 新增:結構語意(記錄圖結構,不執行)
|
// 新增:結構語意(記錄圖結構,不執行)
|
||||||
@@ -28,9 +30,19 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
|||||||
'失敗時': 'ON_FAIL',
|
'失敗時': 'ON_FAIL',
|
||||||
'對每個': 'FOREACH',
|
'對每個': 'FOREACH',
|
||||||
'條件滿足時': 'IF',
|
'條件滿足時': 'IF',
|
||||||
|
// 條件分支語意(SDD workflow-discovery 3.11):讓意圖工作流寫得出兩條路
|
||||||
|
'成立時': 'ON_TRUE',
|
||||||
|
'為真時': 'ON_TRUE',
|
||||||
|
'不成立時': 'ON_FALSE',
|
||||||
|
'為假時': 'ON_FALSE',
|
||||||
|
'否則': 'ON_FALSE',
|
||||||
// 英文別名
|
// 英文別名
|
||||||
'SUCCESS': 'ON_SUCCESS',
|
'SUCCESS': 'ON_SUCCESS',
|
||||||
'FAIL': 'ON_FAIL',
|
'FAIL': 'ON_FAIL',
|
||||||
|
'TRUE': 'ON_TRUE',
|
||||||
|
'FALSE': 'ON_FALSE',
|
||||||
|
'ELSE': 'ON_FALSE',
|
||||||
|
'BRANCH': 'ON_BRANCH',
|
||||||
'CLICK': 'ON_CLICK',
|
'CLICK': 'ON_CLICK',
|
||||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* 認證儲存(D61:認證與資料分離)— 門鎖不住在知識資料庫裡
|
||||||
|
*
|
||||||
|
* leo 2026-08-10 下令(ADR D61 / Leo/arcrun-rag#55):
|
||||||
|
* 「登入認證資料要分離⋯⋯**就算只有我一個人存在單獨的 json 檔也好**,
|
||||||
|
* 它不能被改資料庫的連結導致無法登入。」
|
||||||
|
*
|
||||||
|
* 不變量(整份檔案只為這一句存在):
|
||||||
|
* **登入所需要的一切,不得存放在任何「會被安裝/遷移重新指向」的地方。**
|
||||||
|
*
|
||||||
|
* 為什麼家選在 CF Workers per-script Secrets(判斷過程留著,方便日後推翻):
|
||||||
|
* - D1 / KV / R2 / Vectorize 全靠 **binding** 指過去,安裝器每次都會重新指一次
|
||||||
|
* ⇒ 換家=換鎖。所以「搬到另一顆資料庫」根本不解問題。
|
||||||
|
* - Workers Secret **掛在 script 本身**,與 bindings 是兩套資源:
|
||||||
|
* `wrangler deploy` 帶新 bindings 重部不會洗掉它(journeys/gemini-key-lost-on-reinstall.md
|
||||||
|
* 在 stage 完整重裝 24/24 顆 worker 後 secret 仍在;installer worker.js:1148 亦有同款實證)。
|
||||||
|
* - 它是**自足**的:讀出來就是完整的一份 JSON,裡面沒有任何「再去某顆 D1/KV 查一次」的指標。
|
||||||
|
* 自足是重點——只要還要回頭查一次,就又被綁回去了。
|
||||||
|
* - 不開新 D1(P9:leo 2026-08-07「你建一顆新的 D1,以後就會偷偷溜去那裡建表」)。
|
||||||
|
* - 不牴觸 D38「KBDB 三張核心表永不加新的」:本檔是把東西**搬出去**,KBDB 表數不增不減。
|
||||||
|
*
|
||||||
|
* 容量(2026-08-10 查官方 developers.cloudflare.com/workers/platform/limits/,不是憑記憶):
|
||||||
|
* - 每個變數(secret + text 合計)上限 **5 KB**
|
||||||
|
* - 每顆 worker 變數數量上限 **64(Free)/ 128(Paid)**,與 CRED_* 共用同一份額度
|
||||||
|
* ⇒ 故採「單一 store + 溢位分片」:`ARCRUN_AUTH_STORE`、`ARCRUN_AUTH_STORE_1`、`_2`…
|
||||||
|
* 一份 ~4.5 KB 大約裝得下 12–15 個帳號;超過就自動長出下一片。
|
||||||
|
* 這是刻意的取捨:**不**做「一個帳號一顆 secret」,因為那會用同一份 64 格的額度去跟
|
||||||
|
* workflow credential 搶位子,且沒有任何實例接近這個量級。
|
||||||
|
*
|
||||||
|
* 寫入路徑:CF Workers Scripts secrets 管理 API(唯寫,讀不回值)。
|
||||||
|
* 與 routes/credentials.ts 走**同一支** putWorkerSecret/deleteWorkerSecret,不另造第二套
|
||||||
|
* (D36 教訓:AI 天生偏向新增一種做法而非沿用既有的,兩套並存必然漂移)。
|
||||||
|
*
|
||||||
|
* 讀取路徑:`env` 直接讀——**零網路呼叫**。這正是它比 KBDB 可靠的原因:
|
||||||
|
* 登入不再依賴任何外部系統活著。
|
||||||
|
*
|
||||||
|
* ⚠️ 傳播延遲(誠實限制,mindset §7):更新 secret 會產生 worker 的新版本,
|
||||||
|
* **既有 isolate 讀到的仍是舊 env**,要等新版本鋪開。故本檔帶一層 per-isolate 的
|
||||||
|
* write-through overlay(AUTH_OVERLAY_TTL_MS),讓「剛改完密碼立刻登入」在同一顆 isolate 上
|
||||||
|
* 立即生效;跨 isolate 仍可能有數十秒的落差,這是平台特性,不假裝沒有。
|
||||||
|
*/
|
||||||
|
import type { Bindings } from '../types';
|
||||||
|
import { putWorkerSecret, deleteWorkerSecret } from '../routes/credentials';
|
||||||
|
|
||||||
|
/** 主分片名;溢位分片為 `${AUTH_STORE_PREFIX}_1`、`_2`… */
|
||||||
|
export const AUTH_STORE_PREFIX = 'ARCRUN_AUTH_STORE';
|
||||||
|
/** 單片安全上限(官方 5 KB,留 ~10% 給 JSON 結構與 UTF-8 膨脹)。 */
|
||||||
|
const SHARD_MAX_BYTES = 4600;
|
||||||
|
/** 剛寫完的資料在本 isolate 內優先採信多久(跨 isolate 傳播用)。 */
|
||||||
|
const AUTH_OVERLAY_TTL_MS = 180_000;
|
||||||
|
/**
|
||||||
|
* 「剛寫完」加速器的 KV key 與存活時間。
|
||||||
|
*
|
||||||
|
* 🔴 為什麼需要它(2026-08-10 stage 演練**實測撞到**,不是預防性設計):
|
||||||
|
* 更新 secret 會產生 worker 新版本,**既有 isolate 讀到的還是舊 env**。實測「建好帳號 →
|
||||||
|
* 立刻登入」有 **15 秒以上**登不進去,而且那幾次失敗**會被算進 5 次鎖定**
|
||||||
|
* ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。**這正是本案要根治的病的變種。**
|
||||||
|
*
|
||||||
|
* 🔑 它**不是**認證的家,只是「新版本還沒鋪開時的臨時快遞」:
|
||||||
|
* - 讀取順序永遠是 **secret 優先**;secret 裡查不到/密碼對不上,才回頭問加速器一次
|
||||||
|
* - KV 被重裝指到新的空的 → 加速器空 → 退回 secret ⇒ **D61 的不變量不受影響**
|
||||||
|
* - 短 TTL:密碼雜湊不長期躺在 KV 裡(舊設計是永久躺著,這比舊的嚴格)
|
||||||
|
*/
|
||||||
|
const ACCEL_KEY = 'auth_store_recent';
|
||||||
|
const ACCEL_TTL_SECONDS = 600;
|
||||||
|
/** store 內 user id 前綴——呼叫端據此分辨「這筆住新家還是舊家(KBDB)」。 */
|
||||||
|
export const AUTH_ID_PREFIX = 'auth:';
|
||||||
|
|
||||||
|
export interface AuthUserRecord {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
status: string;
|
||||||
|
role: string;
|
||||||
|
libraries: string[];
|
||||||
|
password_hash: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** console 管理員那一組(原本住 SESSIONS_KV `console:credentials`,重裝就跟著蒸發)。 */
|
||||||
|
export interface AuthConsoleRecord {
|
||||||
|
email: string;
|
||||||
|
salt: string;
|
||||||
|
hash: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStoreData {
|
||||||
|
version: number;
|
||||||
|
console: AuthConsoleRecord | null;
|
||||||
|
users: AuthUserRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShardPayload {
|
||||||
|
v: number;
|
||||||
|
console?: AuthConsoleRecord | null;
|
||||||
|
users?: AuthUserRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID,或 CF API 回錯)。 */
|
||||||
|
export class AuthStoreWriteError extends Error {}
|
||||||
|
|
||||||
|
// ── per-isolate overlay(見檔頭「傳播延遲」)─────────────────────────────────────
|
||||||
|
let overlay: AuthStoreData | null = null;
|
||||||
|
let overlayAt = 0;
|
||||||
|
|
||||||
|
function emptyStore(): AuthStoreData {
|
||||||
|
return { version: 1, console: null, users: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shardNames(env: Bindings): string[] {
|
||||||
|
const bag = env as unknown as Record<string, unknown>;
|
||||||
|
return Object.keys(bag)
|
||||||
|
.filter((k) => k === AUTH_STORE_PREFIX || /^ARCRUN_AUTH_STORE_\d+$/.test(k))
|
||||||
|
.filter((k) => typeof bag[k] === 'string' && (bag[k] as string).length > 0)
|
||||||
|
.sort((a, b) => shardIndex(a) - shardIndex(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shardIndex(name: string): number {
|
||||||
|
if (name === AUTH_STORE_PREFIX) return 0;
|
||||||
|
return Number.parseInt(name.slice(AUTH_STORE_PREFIX.length + 1), 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shardNameOf(index: number): string {
|
||||||
|
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 這台實例的 env 裡有沒有認證儲存(不論裡面有沒有帳號)。 */
|
||||||
|
export function authStorePresent(env: Bindings): boolean {
|
||||||
|
return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 */
|
||||||
|
export function authStoreWritable(env: Bindings): boolean {
|
||||||
|
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀出完整認證資料。**同步、零網路呼叫**——這就是分離的意義:
|
||||||
|
* 登入不依賴 KBDB / D1 / KV 任何一個活著。
|
||||||
|
* 壞掉的分片(JSON parse 失敗)誠實跳過,不讓一片損毀鎖死整台實例。
|
||||||
|
*/
|
||||||
|
export function readAuthStore(env: Bindings): AuthStoreData {
|
||||||
|
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
||||||
|
return readAuthStoreFromEnv(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只讀 `env` 那一版(**跳過 overlay**)。
|
||||||
|
*
|
||||||
|
* 為什麼要分出這一支(#66 修補的一半):read-modify-write 時,overlay 與 env 兩份都可能
|
||||||
|
* 各自「有對方沒有的帳號」——overlay 可能來自加速器(別台 isolate 剛寫的),
|
||||||
|
* env 可能是**比加速器更新**的一版(加速器過期、或這顆 isolate 已經吃到新版本)。
|
||||||
|
* 只採信其中一份就會把另一份獨有的帳號寫掉,而 secret 是唯一真相源 ⇒ **永久消失**。
|
||||||
|
*/
|
||||||
|
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
|
||||||
|
const bag = env as unknown as Record<string, unknown>;
|
||||||
|
const out = emptyStore();
|
||||||
|
for (const name of shardNames(env)) {
|
||||||
|
let parsed: ShardPayload | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(bag[name] as string) as ShardPayload;
|
||||||
|
} catch {
|
||||||
|
continue; // 損毀的分片跳過(其餘帳號仍登得進去)
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object') continue;
|
||||||
|
if (parsed.console && !out.console) out.console = parsed.console;
|
||||||
|
if (Array.isArray(parsed.users)) {
|
||||||
|
for (const u of parsed.users) {
|
||||||
|
if (u && typeof u.email === 'string' && typeof u.id === 'string') out.users.push(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找一筆帳號(email 比對,大小寫不敏感)。 */
|
||||||
|
export function findAuthUserByEmail(env: Bindings, email: string): AuthUserRecord | null {
|
||||||
|
const needle = email.trim().toLowerCase();
|
||||||
|
return readAuthStore(env).users.find((u) => u.email.toLowerCase() === needle) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAuthUserById(env: Bindings, id: string): AuthUserRecord | null {
|
||||||
|
return readAuthStore(env).users.find((u) => u.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判斷一個 record_id 是不是住新家(呼叫端據此決定打 store 還是打 KBDB)。 */
|
||||||
|
export function isAuthStoreId(recordId: string): boolean {
|
||||||
|
return recordId.startsWith(AUTH_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newAuthUserId(): string {
|
||||||
|
const arr = new Uint8Array(12);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把整份認證資料切片後寫回 Workers Secrets。
|
||||||
|
* 分片規則:console 一定放第 0 片;users 依序塞,塞不下就開下一片。
|
||||||
|
* 多出來的舊分片會被刪掉(避免「刪了帳號卻還留在舊分片裡復活」)。
|
||||||
|
*/
|
||||||
|
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
|
||||||
|
if (!authStoreWritable(env)) {
|
||||||
|
throw new AuthStoreWriteError(
|
||||||
|
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
|
||||||
|
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shards: string[] = [];
|
||||||
|
let current: ShardPayload = { v: 1, console: data.console ?? null, users: [] };
|
||||||
|
for (const u of data.users) {
|
||||||
|
const trial: ShardPayload = { ...current, users: [...(current.users ?? []), u] };
|
||||||
|
const size = new TextEncoder().encode(JSON.stringify(trial)).length;
|
||||||
|
if (size > SHARD_MAX_BYTES && (current.users ?? []).length > 0) {
|
||||||
|
shards.push(JSON.stringify(current));
|
||||||
|
current = { v: 1, users: [u] };
|
||||||
|
} else {
|
||||||
|
current = trial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shards.push(JSON.stringify(current));
|
||||||
|
|
||||||
|
// 單筆帳號本身就超過一片=真的塞不下,誠實擋下(不靜默丟資料)
|
||||||
|
for (const s of shards) {
|
||||||
|
if (new TextEncoder().encode(s).length > 5000) {
|
||||||
|
throw new AuthStoreWriteError('單筆認證資料超過 Cloudflare 變數 5 KB 上限,無法寫入。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = shardNames(env);
|
||||||
|
for (let i = 0; i < shards.length; i++) {
|
||||||
|
await putWorkerSecret(env, shardNameOf(i), shards[i]);
|
||||||
|
}
|
||||||
|
for (const name of existing) {
|
||||||
|
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
|
||||||
|
overlayAt = Date.now();
|
||||||
|
|
||||||
|
// 加速器(非真相源,見 ACCEL_KEY 註解):讓別的 isolate 在新版本鋪開前也讀得到剛寫的東西。
|
||||||
|
// 寫失敗完全不影響正確性——最多就是回到「等 secret 傳播」的狀態,故吞掉例外。
|
||||||
|
try {
|
||||||
|
await env.SESSIONS_KV.put(
|
||||||
|
ACCEL_KEY,
|
||||||
|
JSON.stringify({ written_at: Date.now(), data: overlay }),
|
||||||
|
{ expirationTtl: ACCEL_TTL_SECONDS },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* 加速器是加分項,不是必要條件 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「secret 裡查不到/密碼對不上」時再問一次加速器(見 ACCEL_KEY)。
|
||||||
|
* 命中就把它放進本 isolate 的 overlay,呼叫端重跑一次同樣的查找即可。
|
||||||
|
* 回傳是否真的拿到比較新的資料(沒有就不必重跑)。
|
||||||
|
*/
|
||||||
|
export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
|
||||||
|
let raw: string | null = null;
|
||||||
|
try {
|
||||||
|
raw = await env.SESSIONS_KV.get(ACCEL_KEY);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!raw) return false;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { written_at?: number; data?: AuthStoreData };
|
||||||
|
if (!parsed?.data || !Array.isArray(parsed.data.users)) return false;
|
||||||
|
if (overlay && overlayAt >= (parsed.written_at ?? 0)) return false; // 本地的更新
|
||||||
|
overlay = { version: 1, console: parsed.data.console ?? null, users: parsed.data.users };
|
||||||
|
overlayAt = parsed.written_at ?? Date.now();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
|
||||||
|
*
|
||||||
|
* 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事:
|
||||||
|
* - 「查不到這個帳號」= 帳號真的被刪了 → 該擋(401)
|
||||||
|
* - 「查不到這個帳號」= secret 新版本還沒鋪到這顆 isolate → **不該擋,更不該刪 session**
|
||||||
|
* 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`,它存在就代表「最近有人動過認證儲存」。
|
||||||
|
* 讀不到(KV 掛了/沒設)⇒ 回 false,退回舊行為,不會比現在更糟。
|
||||||
|
*/
|
||||||
|
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兩份 store 取聯集:同一個 id 以 `updated_at` 新者為準;只在一邊出現的一律保留。 */
|
||||||
|
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
|
||||||
|
const byId = new Map<string, AuthUserRecord>();
|
||||||
|
for (const u of [...a.users, ...b.users]) {
|
||||||
|
const prev = byId.get(u.id);
|
||||||
|
if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
|
||||||
|
}
|
||||||
|
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。
|
||||||
|
*
|
||||||
|
* 🔴 #66:**改之前先把手上這份補齊**。舊版直接 `readAuthStore(env)` 當底稿,而 `writeAuthStore`
|
||||||
|
* 會把整份重切分片並刪掉多出來的舊分片 ⇒ 若底稿是「某個帳號被建立之前」的版本,
|
||||||
|
* 那個帳號會在這次寫入中**被抹掉,且再也回不來**(secret 是唯一真相源,沒有第二份可還原)。
|
||||||
|
* 這正是「改一次密碼=有人被鎖在門外」的另一半病因。
|
||||||
|
*
|
||||||
|
* 補法:先問一次加速器,再把 env 版與 overlay 版**取聯集**當底稿——
|
||||||
|
* 兩邊獨有的帳號都留下來;刪除仍然有效,因為 `fn()` 是在聯集**之後**才跑。
|
||||||
|
*/
|
||||||
|
export async function mutateAuthStore(
|
||||||
|
env: Bindings,
|
||||||
|
fn: (data: AuthStoreData) => void | Promise<void>,
|
||||||
|
): Promise<AuthStoreData> {
|
||||||
|
await hydrateFromAccelerator(env);
|
||||||
|
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||||
|
await fn(next);
|
||||||
|
await writeAuthStore(env, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 診斷用(/health、/console/auth-status、daemon diagnostics 共用同一份判讀)。 */
|
||||||
|
export function authStoreStatus(env: Bindings): {
|
||||||
|
present: boolean;
|
||||||
|
writable: boolean;
|
||||||
|
users: number;
|
||||||
|
console_configured: boolean;
|
||||||
|
shards: number;
|
||||||
|
} {
|
||||||
|
const data = readAuthStore(env);
|
||||||
|
return {
|
||||||
|
present: authStorePresent(env),
|
||||||
|
writable: authStoreWritable(env),
|
||||||
|
users: data.users.length,
|
||||||
|
console_configured: Boolean(data.console),
|
||||||
|
shards: shardNames(env).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -98,6 +98,20 @@ export function randomHex(bytes: number): string {
|
|||||||
.join('');
|
.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 / 建帳號未給密碼時用)。
|
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
|
||||||
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
||||||
|
|||||||
@@ -37,4 +37,21 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
|
|||||||
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
|
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
|
||||||
created_by: 'system',
|
created_by: 'system',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// t130:rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。
|
||||||
|
// 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。
|
||||||
|
// slots 來源:kbdb_list_templates 核實(2026-07-19,library-map.test.ts PROD_TRIPLET_SLOTS)
|
||||||
|
// + library(library-map.ts M1 預案:recompute 歸庫用,ensurePortalTemplates 若缺則 PATCH 補入)。
|
||||||
|
name: 'triplet',
|
||||||
|
description: 'KBDB 知識圖譜三元組(kbdb-graph-plugin 寫入;portal 讀此 template 建鄰接圖)',
|
||||||
|
slots: [
|
||||||
|
'subject', 'predicate', 'object',
|
||||||
|
'source_block_id', 'confidence', 'clusters_json',
|
||||||
|
'bridge_score', 'subject_entity_type', 'object_entity_type',
|
||||||
|
'status', 'superseded_by',
|
||||||
|
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
|
||||||
|
'library',
|
||||||
|
],
|
||||||
|
created_by: 'system',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* recipe 的 payload 與回應處理層(SDD workflow-discovery 3.12 / CP arcrun-usable 步驟 5 缺口②)
|
||||||
|
*
|
||||||
|
* 為什麼存在(leo 的三層模型,第③層過去是空的):
|
||||||
|
* ① 零件(http_request) ② auth recipe(auth_service) ③ **payload recipe** ← 這層
|
||||||
|
* 舊 schema 存不住 body 與「回應怎麼取值」⇒ 帶 body 的 API 只能把整包寫進 workflow code,
|
||||||
|
* 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式 ⇒ 換源必壞。
|
||||||
|
* 有了這層:**換 LLM 供應商=換 recipe,不必動 workflow**。
|
||||||
|
*
|
||||||
|
* 相容鐵律:三個欄位全為選填。既有 recipe(沒有這些欄位)行為**完全不變**——
|
||||||
|
* renderBodyTemplate(undefined,…) 回 undefined、applyResponseMap(body, undefined) 原樣回傳。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 回應正規化規則(隨 recipe 走,故換源=換 recipe) */
|
||||||
|
export type ResponseMap = {
|
||||||
|
/**
|
||||||
|
* 取值路徑(dot path,支援陣列索引)。
|
||||||
|
* 例:Gemini `candidates.0.content.parts.0.text`/Claude `content.0.text`/
|
||||||
|
* Workers AI `response`。
|
||||||
|
* 搭配 thinking_model 時可指向 parts 陣列本身。
|
||||||
|
*/
|
||||||
|
text_path?: string;
|
||||||
|
/**
|
||||||
|
* 思考型模型(如 gemma):parts 內會混入 `thought: true` 的思考過程,
|
||||||
|
* 要剔除後取最後一個非 thought 的 part。
|
||||||
|
*/
|
||||||
|
thinking_model?: boolean;
|
||||||
|
/** 淨化:要剝掉的前綴(實撞過「Draft:」「*」「Answer:」,且組合順序不定) */
|
||||||
|
strip_prefixes?: string[];
|
||||||
|
/** 答案標記:出現時只取其後的內容(實撞:模型會把草稿吐在標記前) */
|
||||||
|
answer_marker?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 從物件用 dot path 取值:'a.0.b' → obj.a[0].b */
|
||||||
|
function getPath(obj: unknown, path: string): unknown {
|
||||||
|
let cur: unknown = obj;
|
||||||
|
for (const part of path.split('.')) {
|
||||||
|
if (cur === null || cur === undefined) return undefined;
|
||||||
|
if (typeof cur !== 'object') return undefined;
|
||||||
|
cur = (cur as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ③-a body_template:payload 收回 recipe ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 body_template 內所有 `{{var}}` 用 ctx 填掉(遞迴進巢狀 object / array)。
|
||||||
|
*
|
||||||
|
* 與 graph-executor 的 interpolateData 同一套語義(刻意一致,避免兩種插值行為):
|
||||||
|
* - 整個字串就是單一 `{{x}}` → 回**原型別**(陣列/物件/數字不被 stringify)
|
||||||
|
* - 混合文字 → 拼成字串
|
||||||
|
* - 取不到 → **保留原樣** `{{x}}`(看得見才好 debug,不靜默吞掉)
|
||||||
|
*/
|
||||||
|
export function renderBodyTemplate(
|
||||||
|
template: unknown,
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
): unknown {
|
||||||
|
if (template === undefined || template === null) return undefined;
|
||||||
|
return renderValue(template, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderValue(v: unknown, ctx: Record<string, unknown>): unknown {
|
||||||
|
if (typeof v === 'string') return renderString(v, ctx);
|
||||||
|
if (Array.isArray(v)) return v.map(item => renderValue(item, ctx));
|
||||||
|
if (v !== null && typeof v === 'object') {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||||
|
out[k] = renderValue(val, ctx);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderString(s: string, ctx: Record<string, unknown>): unknown {
|
||||||
|
const single = s.match(/^\s*\{\{([\w.]+)\}\}\s*$/);
|
||||||
|
if (single) {
|
||||||
|
const val = getPath(ctx, single[1]);
|
||||||
|
return val === undefined ? s : val;
|
||||||
|
}
|
||||||
|
return s.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => {
|
||||||
|
const val = getPath(ctx, key);
|
||||||
|
if (val === undefined) return `{{${key}}}`;
|
||||||
|
return typeof val === 'string' ? val : JSON.stringify(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ③-b response_map:回應正規化 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type NormalizedResponse = {
|
||||||
|
/** 正規化後的純文字(沒有 response_map 或取不到時 undefined——誠實,不編造) */
|
||||||
|
text?: string;
|
||||||
|
/** 原始回應永遠保留(除錯與向後相容都靠它) */
|
||||||
|
raw: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依 response_map 把各家 API 的回應正規化成 `{ text }`。
|
||||||
|
* 沒給 map ⇒ 原樣回傳(既有 recipe 零行為變化)。
|
||||||
|
*/
|
||||||
|
export function applyResponseMap(body: unknown, map?: ResponseMap): NormalizedResponse {
|
||||||
|
if (!map) return { raw: body };
|
||||||
|
|
||||||
|
let picked: unknown = map.text_path ? getPath(body, map.text_path) : body;
|
||||||
|
|
||||||
|
// 思考型模型:picked 是 parts 陣列 → 剔除 thought=true,取最後一個
|
||||||
|
if (map.thinking_model && Array.isArray(picked)) {
|
||||||
|
const real = picked.filter(
|
||||||
|
p => !(p && typeof p === 'object' && (p as Record<string, unknown>).thought === true),
|
||||||
|
);
|
||||||
|
const last = real[real.length - 1];
|
||||||
|
picked = (last && typeof last === 'object')
|
||||||
|
? (last as Record<string, unknown>).text
|
||||||
|
: last;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof picked !== 'string') return { text: undefined, raw: body };
|
||||||
|
|
||||||
|
return { text: sanitize(picked, map), raw: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 淨化(知識是實撞出來的,非預想):
|
||||||
|
* 1. 有 answer_marker → 只取標記**最後一次**出現之後的內容
|
||||||
|
* (實撞:模型的自檢清單內文也會提到標記,用 lastIndexOf 才撈得到真的那個)
|
||||||
|
* 2. 前綴組合順序不定(「* 【答】」「Draft: 【答】」「Answer: * 【答】」三型都撞過)
|
||||||
|
* ⇒ **循環**剝殼,單趟剝不乾淨
|
||||||
|
*/
|
||||||
|
function sanitize(input: string, map: ResponseMap): string {
|
||||||
|
let s = input.trim();
|
||||||
|
|
||||||
|
if (map.answer_marker) {
|
||||||
|
const idx = s.lastIndexOf(map.answer_marker);
|
||||||
|
if (idx >= 0) s = s.slice(idx + map.answer_marker.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixes = map.strip_prefixes ?? [];
|
||||||
|
if (prefixes.length > 0) {
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
s = s.trimStart();
|
||||||
|
for (const p of prefixes) {
|
||||||
|
if (p && s.startsWith(p)) {
|
||||||
|
s = s.slice(p.length);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.trim();
|
||||||
|
}
|
||||||
@@ -14,9 +14,10 @@ export const graphSchema = z.object({
|
|||||||
edges: z.array(z.object({
|
edges: z.array(z.object({
|
||||||
from: z.string(),
|
from: z.string(),
|
||||||
to: z.string(),
|
to: z.string(),
|
||||||
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']),
|
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_TRUE', 'ON_FALSE', 'ON_BRANCH', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']),
|
||||||
condition: z.string().optional(),
|
condition: z.string().optional(),
|
||||||
iterator: z.string().optional(),
|
iterator: z.string().optional(),
|
||||||
|
branch: z.string().optional(), // ON_BRANCH 的具名分支(SDD workflow-discovery 3.11)
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
|||||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||||
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
|
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
|
||||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
||||||
} catch {
|
} catch (e) {
|
||||||
return 1;
|
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
|
||||||
|
// 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。
|
||||||
|
// writeOut 失敗(memory 壞)才 fallback return 1。
|
||||||
|
const errDetail = e instanceof Error ? e.message : String(e);
|
||||||
|
const errEnv = new TextEncoder().encode(
|
||||||
|
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
|
||||||
|
);
|
||||||
|
return writeOut(memory.buffer, outPtr, outLenPtr, errEnv);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: () => 1,
|
: () => 1,
|
||||||
|
|||||||
@@ -22,6 +22,19 @@
|
|||||||
*/
|
*/
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
|
// D61(ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV(`console:credentials`,
|
||||||
|
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
|
||||||
|
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
|
||||||
|
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
|
||||||
|
// 讀到就順手搬過去(見 loadCredentials)。
|
||||||
|
import {
|
||||||
|
AuthStoreWriteError,
|
||||||
|
authStoreStatus,
|
||||||
|
hydrateFromAccelerator,
|
||||||
|
mutateAuthStore,
|
||||||
|
readAuthStore,
|
||||||
|
type AuthConsoleRecord,
|
||||||
|
} from '../lib/portal-auth-store';
|
||||||
|
|
||||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -70,16 +83,72 @@ function tenantOf(c: { env: Bindings }): string {
|
|||||||
return c.env.CONSOLE_TENANT || 'leo';
|
return c.env.CONSOLE_TENANT || 'leo';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(KV),
|
||||||
|
* 且一旦從舊家讀到就順手搬過去(best-effort,搬不動不影響本次登入)。
|
||||||
|
*/
|
||||||
|
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
|
||||||
|
let fromStore = readAuthStore(env).console;
|
||||||
|
if (!fromStore && (await hydrateFromAccelerator(env))) {
|
||||||
|
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
|
||||||
|
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
|
||||||
|
fromStore = readAuthStore(env).console;
|
||||||
|
}
|
||||||
|
if (fromStore) return { creds: fromStore, source: 'secrets' };
|
||||||
|
|
||||||
|
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||||
|
if (!raw) return { creds: null, source: 'none' };
|
||||||
|
let legacy: StoredCredentials | null = null;
|
||||||
|
try {
|
||||||
|
legacy = JSON.parse(raw) as StoredCredentials;
|
||||||
|
} catch {
|
||||||
|
return { creds: null, source: 'none' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await mutateAuthStore(env, (data) => {
|
||||||
|
if (!data.console) data.console = legacy as AuthConsoleRecord;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store) */
|
||||||
|
}
|
||||||
|
return { creds: legacy, source: 'legacy-kv' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
|
||||||
|
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||||
|
await mutateAuthStore(env, (data) => {
|
||||||
|
data.console = record;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
const { creds, source } = await loadCredentials(c.env);
|
||||||
return c.json({ configured: !!existing });
|
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||||
|
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||||
|
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||||||
consoleAuthRouter.post('/console/setup', async (c) => {
|
consoleAuthRouter.post('/console/setup', async (c) => {
|
||||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
const { creds: existing } = await loadCredentials(c.env);
|
||||||
if (existing) return c.json({ error: '已設定過帳密,請改用登入;要換帳密請用 /console/setup/reset(需舊密碼)' }, 409);
|
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 body = await c.req.json().catch(() => null);
|
||||||
const email = (body?.email ?? '').trim();
|
const email = (body?.email ?? '').trim();
|
||||||
@@ -90,7 +159,13 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
|||||||
const salt = randomHex(16);
|
const salt = randomHex(16);
|
||||||
const hash = await hashPassword(password, salt);
|
const hash = await hashPassword(password, salt);
|
||||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
||||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
try {
|
||||||
|
await saveCredentials(c.env, record);
|
||||||
|
} catch (e) {
|
||||||
|
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
|
||||||
|
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||||
|
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||||
|
}
|
||||||
|
|
||||||
const token = randomHex(32);
|
const token = randomHex(32);
|
||||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||||
@@ -101,9 +176,8 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
|||||||
|
|
||||||
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
|
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
|
||||||
consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
const { creds: existing } = await loadCredentials(c.env);
|
||||||
if (!raw) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
if (!existing) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||||
const existing = JSON.parse(raw) as StoredCredentials;
|
|
||||||
|
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const currentPassword = body?.current_password ?? '';
|
const currentPassword = body?.current_password ?? '';
|
||||||
@@ -118,23 +192,48 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
|||||||
const salt = randomHex(16);
|
const salt = randomHex(16);
|
||||||
const hash = await hashPassword(password, salt);
|
const hash = await hashPassword(password, salt);
|
||||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
|
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
|
||||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
try {
|
||||||
|
await saveCredentials(c.env, record);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||||
|
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||||
|
}
|
||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。
|
// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。
|
||||||
consoleAuthRouter.post('/console/login', async (c) => {
|
consoleAuthRouter.post('/console/login', async (c) => {
|
||||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
const { creds: existing } = await loadCredentials(c.env);
|
||||||
if (!raw) return c.json({ error: '尚未設定帳密,請先完成首次設定' }, 400);
|
if (!existing) {
|
||||||
const existing = JSON.parse(raw) as StoredCredentials;
|
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||||||
|
code: 'auth_store_empty',
|
||||||
|
auth_store: authStoreStatus(c.env),
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const email = (body?.email ?? '').trim().toLowerCase();
|
const email = (body?.email ?? '').trim().toLowerCase();
|
||||||
const password = body?.password ?? '';
|
const password = body?.password ?? '';
|
||||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||||
|
|
||||||
const hash = await hashPassword(password, existing.salt);
|
let creds = existing;
|
||||||
if (email !== existing.email || hash !== existing.hash) {
|
let hash = await hashPassword(password, creds.salt);
|
||||||
|
if (email !== creds.email || hash !== creds.hash) {
|
||||||
|
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
|
||||||
|
if (await hydrateFromAccelerator(c.env)) {
|
||||||
|
const again = (await loadCredentials(c.env)).creds;
|
||||||
|
if (again) {
|
||||||
|
creds = again;
|
||||||
|
hash = await hashPassword(password, creds.salt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email !== creds.email || hash !== creds.hash) {
|
||||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,24 +7,41 @@
|
|||||||
* 寫入(POST 建立 / PUT 覆寫):
|
* 寫入(POST 建立 / PUT 覆寫):
|
||||||
* 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫,
|
* 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫,
|
||||||
* arcrun 自己也讀不回值——D19「不持有內容物」)。
|
* arcrun 自己也讀不回值——D19「不持有內容物」)。
|
||||||
* 2. D1 `credentials` 表只寫「目錄」(api_key/name/service/sensitivity/secret_ref/
|
* 2. 目錄(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,
|
||||||
* created_at),**不含密文**。
|
* **不含密文**)走 KBDB HTTP API 寫,不再直連任何 D1。
|
||||||
* 不再寫 KV / 不再寫明文密文到 D1。
|
* 不再寫 KV / 不再寫明文密文到 D1。
|
||||||
*
|
*
|
||||||
* 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體
|
* 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體
|
||||||
* 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03
|
* 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03
|
||||||
* 定案並已落地的做法,取代更早的 `{name, encrypted, iv}` 格式;rule 01 已同步。)
|
* 定案並已落地的做法,取代更早的 `{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,同時保留
|
* - `GET /credentials`:改讀 KBDB entries(與 `/credentials/catalog` 共用同一份查詢,同時
|
||||||
* `/catalog` 別名,Console 既有呼叫不受影響)。
|
* 保留 `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||||
* - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row;
|
* - `DELETE /credentials/:name`:先查 KBDB 拿 secret_ref → 有則刪 Workers Secret + entries
|
||||||
* 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。
|
* row;沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的
|
||||||
|
* 孤兒資料。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { sha256Prefix } from '../lib/hash';
|
import { sha256Prefix } from '../lib/hash';
|
||||||
|
import { kbdbBase } from './kbdb-proxy';
|
||||||
|
|
||||||
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
|
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -61,7 +78,7 @@ export async function storeCredential(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const secretRef = await deriveSecretRef(apiKey, name);
|
const secretRef = await deriveSecretRef(apiKey, name);
|
||||||
await putWorkerSecret(env, secretRef, value);
|
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 {
|
function validateName(name: unknown): name is string {
|
||||||
@@ -76,7 +93,7 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
|
|||||||
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
|
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
|
||||||
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
|
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
|
||||||
*/
|
*/
|
||||||
async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
|
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
|
||||||
@@ -105,7 +122,7 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
|
|||||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||||
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
||||||
*/
|
*/
|
||||||
async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||||
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
||||||
}
|
}
|
||||||
@@ -124,32 +141,197 @@ async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<voi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── KBDB 目錄存取(D38:零 SQL,一律走 entries HTTP API)──────────────────────────
|
||||||
|
|
||||||
|
const CREDENTIAL_ENTRY_TYPE = 'credential';
|
||||||
|
|
||||||
|
/** entries 表回來的一列(本檔只取用得到的欄位,避免耦合 KBDB 內部型別)。 */
|
||||||
|
interface KbdbEntryRow {
|
||||||
|
id: string;
|
||||||
|
page_name: string | null;
|
||||||
|
owner_id: string | null;
|
||||||
|
metadata_json: string | null;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialMeta {
|
||||||
|
service: string | null;
|
||||||
|
sensitivity: 'standard' | 'high';
|
||||||
|
secret_ref: string;
|
||||||
|
last_used_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** name → secret_ref 對照(給熱路徑用;獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
|
||||||
|
type CredentialRefMap = Record<string, string>;
|
||||||
|
|
||||||
|
function parseMeta(row: KbdbEntryRow): CredentialMeta {
|
||||||
|
try {
|
||||||
|
const m = row.metadata_json ? (JSON.parse(row.metadata_json) as Record<string, unknown>) : {};
|
||||||
|
return {
|
||||||
|
service: typeof m.service === 'string' ? m.service : null,
|
||||||
|
sensitivity: m.sensitivity === 'high' ? 'high' : 'standard',
|
||||||
|
secret_ref: typeof m.secret_ref === 'string' ? m.secret_ref : '',
|
||||||
|
last_used_at: typeof m.last_used_at === 'number' ? m.last_used_at : null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// 壞資料誠實視為空目錄列,不讓損毀的 metadata_json 炸整條路徑
|
||||||
|
return { service: null, sensitivity: 'standard', secret_ref: '', last_used_at: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 對 KBDB base 發 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(不含密文)。
|
* 給熱路徑(auth-dispatcher.ts)用:回這個租戶所有 credential 的 name→secret_ref 對照。
|
||||||
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新
|
* 快取命中=零網路呼叫;未命中打一次 KBDB list(見 getCredentialDirectory)。
|
||||||
* service/sensitivity/secret_ref(secret_ref 是純函式衍生自 api_key+name,理論上覆寫時
|
|
||||||
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
|
|
||||||
*/
|
*/
|
||||||
async function upsertCredentialRow(
|
export async function getCredentialSecretRefs(env: Bindings, apiKey: string): Promise<CredentialRefMap> {
|
||||||
db: D1Database,
|
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,
|
apiKey: string,
|
||||||
name: string,
|
name: string,
|
||||||
service: string | null,
|
service: string | null,
|
||||||
sensitivity: 'standard' | 'high',
|
sensitivity: 'standard' | 'high',
|
||||||
secretRef: string,
|
secretRef: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const existing = await findCredentialEntry(env, apiKey, name);
|
||||||
await db
|
const meta: CredentialMeta = {
|
||||||
.prepare(
|
service, sensitivity, secret_ref: secretRef,
|
||||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
last_used_at: existing ? parseMeta(existing).last_used_at : null,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
};
|
||||||
ON CONFLICT(api_key, name) DO UPDATE SET
|
if (existing) {
|
||||||
service = excluded.service,
|
const res = await kbdbCredFetch(env, `/entries/${encodeURIComponent(existing.id)}`, {
|
||||||
sensitivity = excluded.sensitivity,
|
method: 'PATCH',
|
||||||
secret_ref = excluded.secret_ref`,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
)
|
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
|
||||||
.bind(apiKey, name, service, sensitivity, secretRef, now)
|
});
|
||||||
.run();
|
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 {
|
interface CredentialRow {
|
||||||
@@ -160,25 +342,26 @@ interface CredentialRow {
|
|||||||
last_used_at: number | null;
|
last_used_at: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
/** KBDB 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||||
async function listCredentialRows(db: D1Database, apiKey: string): Promise<CredentialRow[]> {
|
async function listCredentialRows(env: Bindings, apiKey: string): Promise<CredentialRow[]> {
|
||||||
const rows = await db
|
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
|
||||||
.prepare(
|
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||||
`SELECT name, service, sensitivity, created_at, last_used_at
|
if (!res.ok) throw new Error(`credential 目錄查詢失敗:HTTP ${res.status}`);
|
||||||
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
|
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||||
)
|
const rows = (body?.entries ?? [])
|
||||||
.bind(apiKey)
|
.filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
|
||||||
.all<CredentialRow>();
|
.map((e) => {
|
||||||
return rows.results ?? [];
|
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 本身,只內部使用)。 */
|
/** 給 `GET /portal/admin/ai` 之類「只要知道有沒有存過、不要值」的呼叫端用。 */
|
||||||
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> {
|
export async function hasCredential(env: Bindings, apiKey: string, name: string): Promise<boolean> {
|
||||||
const row = await db
|
const entry = await findCredentialEntry(env, apiKey, name);
|
||||||
.prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`)
|
return entry !== null;
|
||||||
.bind(apiKey, name)
|
|
||||||
.first<{ secret_ref: string }>();
|
|
||||||
return row?.secret_ref ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CredentialWriteBody {
|
interface CredentialWriteBody {
|
||||||
@@ -203,13 +386,13 @@ async function writeCredential(
|
|||||||
// 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回)
|
// 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回)
|
||||||
await putWorkerSecret(env, secretRef, value);
|
await putWorkerSecret(env, secretRef, value);
|
||||||
|
|
||||||
// 2. D1 目錄(不含密文)
|
// 2. KBDB 目錄(不含密文)
|
||||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef);
|
await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||||
|
|
||||||
return { secretRef, sensitivity };
|
return { secretRef, sensitivity };
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄)
|
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + KBDB entries 目錄)
|
||||||
credentialsRouter.post('/credentials', async (c) => {
|
credentialsRouter.post('/credentials', async (c) => {
|
||||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -272,17 +455,17 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
|||||||
|
|
||||||
const name = c.req.param('name');
|
const name = c.req.param('name');
|
||||||
try {
|
try {
|
||||||
const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
|
const entry = await findCredentialEntry(c.env, apiKey, name);
|
||||||
if (secretRef) {
|
if (entry) {
|
||||||
await deleteWorkerSecret(c.env, secretRef);
|
const meta = parseMeta(entry);
|
||||||
await c.env.CREDENTIALS_DB
|
if (meta.secret_ref) await deleteWorkerSecret(c.env, meta.secret_ref);
|
||||||
.prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`)
|
const res = await kbdbCredFetch(c.env, `/entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' });
|
||||||
.bind(apiKey, name)
|
if (!res.ok) throw new Error(`credential 目錄刪除失敗:HTTP ${res.status}`);
|
||||||
.run();
|
invalidateCredentialCache(apiKey);
|
||||||
return c.json({ success: true, name, source: 'workers-secrets' });
|
return c.json({ success: true, name, source: 'workers-secrets' });
|
||||||
}
|
}
|
||||||
// D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
// KBDB 沒有這筆 entry:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||||
// 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。
|
// 避免「GET 改讀新家看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||||
await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`);
|
await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`);
|
||||||
return c.json({ success: true, name, source: 'legacy-kv' });
|
return c.json({ success: true, name, source: 'legacy-kv' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -290,8 +473,8 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
// GET /credentials/catalog — 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||||
// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑;
|
// 與 GET /credentials(下方,改讀同一份 KBDB 查詢)是同一份資料的兩個路徑;
|
||||||
// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。
|
// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。
|
||||||
credentialsRouter.get('/credentials/catalog', async (c) => {
|
credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||||
@@ -299,22 +482,22 @@ credentialsRouter.get('/credentials/catalog', async (c) => {
|
|||||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||||
}
|
}
|
||||||
try {
|
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 });
|
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
|
// 誠實回報:KBDB 不可達 / 回錯(不假綠回空陣列裝沒事)
|
||||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
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) => {
|
credentialsRouter.get('/credentials', async (c) => {
|
||||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||||
}
|
}
|
||||||
try {
|
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 });
|
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ executeRouter.post('/execute', async (c) => {
|
|||||||
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
|
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
|
||||||
const duration_ms = Date.now() - start;
|
const duration_ms = Date.now() - start;
|
||||||
c.executionCtx.waitUntil(
|
c.executionCtx.waitUntil(
|
||||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
|
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成', context, apiKey)
|
||||||
);
|
);
|
||||||
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
|
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const duration_ms = Date.now() - start;
|
const duration_ms = Date.now() - start;
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
c.executionCtx.waitUntil(
|
c.executionCtx.waitUntil(
|
||||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
|
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100), context, apiKey)
|
||||||
);
|
);
|
||||||
if (err instanceof ExecutionError) {
|
if (err instanceof ExecutionError) {
|
||||||
const traceFormatted = err.trace.map(s => ({
|
const traceFormatted = err.trace.map(s => ({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { listPausedRunsByApiKey } from '../lib/paused-runs';
|
import { listPausedRunsByApiKey } from '../lib/paused-runs';
|
||||||
|
import { kbdbBase } from './kbdb-proxy';
|
||||||
|
|
||||||
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
|
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -132,11 +133,13 @@ executionsRouter.get('/executions/:task_id', async (c) => {
|
|||||||
/**
|
/**
|
||||||
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
|
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
|
||||||
*
|
*
|
||||||
* 走 ANALYTICS_KV `stats:{workflowId}:*` prefix scan。
|
* KV 額度事故修復(2026-08-07):改打 KBDB `GET /execution-log`(原走 ANALYTICS_KV
|
||||||
|
* `stats:{workflowId}:*` prefix scan,免費層 list 也是 1,000/日,裝十幾支 workflow
|
||||||
|
* 的實例刷 90 次 portal 就見底)。KBDB=API-as-Wall(leo 2026-06-14):本路由**不直連
|
||||||
|
* 任何 D1**,一律走 HTTP,連法比照既有 kbdbBase() 慣例(kbdb-proxy.ts)。
|
||||||
*
|
*
|
||||||
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name)。
|
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name,與舊 KV
|
||||||
*
|
* key 同語意,沿用既有限制不在這次修復裡處理)。
|
||||||
* 限制:ANALYTICS_KV list 沒辦法依 timestamp 排序,只能拿 key 後段 timestamp 排。
|
|
||||||
*/
|
*/
|
||||||
executionsRouter.get('/workflows/:name/executions', async (c) => {
|
executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||||
@@ -164,30 +167,21 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
|
|||||||
}, 404);
|
}, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 撈 stats:{name}:* 全 list(每個 key 含 timestamp 後綴)
|
const { base, headers } = kbdbBase(c.env);
|
||||||
const list = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
const params = new URLSearchParams({ workflow_id: name, owner_id: apiKey, limit: String(limit) });
|
||||||
|
const kbdbRes = await fetch(`${base}/execution-log?${params.toString()}`, { headers });
|
||||||
|
const kbdbBody = await kbdbRes.json().catch(() => null) as { success?: boolean; executions?: Array<{
|
||||||
|
verdict: string; duration_ms: number; message: string; target?: string; recorded_at: number;
|
||||||
|
}> } | null;
|
||||||
|
|
||||||
// 按 timestamp 降序(key suffix 是 unix ms)
|
const executions = (kbdbRes.ok && kbdbBody?.success ? kbdbBody.executions ?? [] : []).map((r) => ({
|
||||||
const sorted = [...list.keys].sort((a, b) => {
|
timestamp: String(r.recorded_at),
|
||||||
const ta = parseInt(a.name.split(':').pop() ?? '0', 10);
|
workflow_id: name,
|
||||||
const tb = parseInt(b.name.split(':').pop() ?? '0', 10);
|
verdict: r.verdict,
|
||||||
return tb - ta;
|
duration_ms: r.duration_ms,
|
||||||
}).slice(0, limit);
|
message: r.message ?? '',
|
||||||
|
...(r.target ? { target: r.target } : {}),
|
||||||
const executions = [];
|
}));
|
||||||
for (const key of sorted) {
|
|
||||||
const raw = await c.env.ANALYTICS_KV.get(key.name);
|
|
||||||
if (!raw) continue;
|
|
||||||
try {
|
|
||||||
const record = JSON.parse(raw);
|
|
||||||
executions.push({
|
|
||||||
timestamp: key.name.split(':').pop(),
|
|
||||||
...record,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -197,7 +191,7 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
|
|||||||
executions,
|
executions,
|
||||||
},
|
},
|
||||||
hints: executions.length === 0
|
hints: executions.length === 0
|
||||||
? ['尚未有任何執行紀錄(或都過了 90d TTL)。先 call /webhooks/named/:name/trigger 跑一次']
|
? ['尚未有任何執行紀錄。先 call /webhooks/named/:name/trigger 跑一次']
|
||||||
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
|
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
|
import { authStoreStatus } from '../lib/portal-auth-store';
|
||||||
|
|
||||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
healthRouter.get('/health', (c) =>
|
// t162(leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
|
||||||
c.json({ ok: true })
|
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
|
||||||
);
|
// 但本端點過去只回 {ok:true},**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
|
||||||
|
// ⇒ 恆判 stale ⇒ 假警報永遠不消失(安裝器其實一直有注入 ARCRUN_BUNDLE_VERSION var,
|
||||||
|
// 只是沒有人把它吐出來)。修=誠實回報本實例的 bundle 版本。
|
||||||
|
// 未注入(本地 dev/很舊的實例)就省略該欄——daemon 對空字串仍判 stale,
|
||||||
|
// 那是**正確的**(真的是老實例,該更新)。
|
||||||
|
// D61(ADR D61 / Leo/arcrun-rag#55):多吐一個 `auth_store`——「認證住哪、寫不寫得進去」
|
||||||
|
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
|
||||||
|
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
|
||||||
|
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
|
||||||
|
healthRouter.get('/health', (c) => {
|
||||||
|
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||||
|
auth_store: authStoreStatus(c.env),
|
||||||
|
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||||
|
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||||
|
// 版本無關(同一個 cypher 版本,有的實例有這個 var、有的沒有)。純比版本號的話,
|
||||||
|
// 已經在最新版的實例(如 leo 自己那台)永遠不會因為「按更新」而重推,這個 var
|
||||||
|
// 就永遠補不進去。只回布林(有沒有設,不回值本身)——不洩漏郵差網址。
|
||||||
|
mail_relay_configured: Boolean(String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
healthRouter.get('/', (c) =>
|
healthRouter.get('/', (c) =>
|
||||||
c.json({
|
c.json({
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ initSeedRouter.post('/init/seed', async (c) => {
|
|||||||
endpoint: seed.endpoint,
|
endpoint: seed.endpoint,
|
||||||
method: (seed.method ?? 'POST').toUpperCase(),
|
method: (seed.method ?? 'POST').toUpperCase(),
|
||||||
auth_service: seed.auth_service,
|
auth_service: seed.auth_service,
|
||||||
|
// ③ payload/回應/binding 三層(3.12):不列進來的欄位會被**靜默吃掉**——
|
||||||
|
// 種子帶了 body_template/response_map/auth 卻沒進 KV,症狀是 recipe 存在但跑起來
|
||||||
|
// 「像沒設定過」,且哪裡都不會紅(08-02 manifest.daemon 欄被列舉式重建吃掉的同型)。
|
||||||
|
body_template: seed.body_template,
|
||||||
|
response_map: seed.response_map,
|
||||||
|
auth: seed.auth,
|
||||||
|
binding_name: seed.binding_name,
|
||||||
created_at: existing?.created_at ?? now,
|
created_at: existing?.created_at ?? now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -119,6 +119,27 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
|
|||||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH /kbdb/records/:recordId — 翻某筆 record 的 slot 值({ values:{slot:content} })。
|
||||||
|
// 補上基本盤既有能力(kbdb/src/routes/records.ts 的 PATCH /records/:recordId,mira-dissolve T2.1)
|
||||||
|
// 缺的對外通道——2026-08-11 leo 三元組 library 補標核實:base 早有這個端點,但這條 proxy
|
||||||
|
// 之前只轉發 GET/POST,插件/工作流打不到,補標三元組只能繞去改表(違 D38)。單純轉發,無業務邏輯。
|
||||||
|
// by-id 沿用既有慣例(require-key,不額外做 owner 比對——與本檔 GET .../:recordId、
|
||||||
|
// PATCH /kbdb/entries/:id 同款)。
|
||||||
|
kbdbProxyRouter.patch('/kbdb/records/:recordId', async (c) => {
|
||||||
|
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (!body || typeof body.values !== 'object' || body.values === null) {
|
||||||
|
return c.json({ error: 'values 必填({slot名: 內容})' }, 400);
|
||||||
|
}
|
||||||
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ values: body.values }),
|
||||||
|
});
|
||||||
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
});
|
||||||
|
|
||||||
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
|
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
|
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
|
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||||
import { graphBase } from './kbdb-proxy';
|
import { graphBase } from './kbdb-proxy';
|
||||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||||
|
|
||||||
@@ -73,6 +73,32 @@ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; e
|
|||||||
return { neighbors, edges, count: neighbors.length };
|
return { neighbors, edges, count: neighbors.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 出處清單按 page_name 去重(t129):
|
||||||
|
* rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。
|
||||||
|
* 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。
|
||||||
|
* page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。
|
||||||
|
* 純函式,單測用 export。
|
||||||
|
*/
|
||||||
|
export function dedupeSourcesByPage(sources: unknown[]): unknown[] {
|
||||||
|
const seen = new Map<string, { item: Record<string, unknown>; count: number }>();
|
||||||
|
for (const s of sources) {
|
||||||
|
if (!s || typeof s !== 'object') continue;
|
||||||
|
const item = s as Record<string, unknown>;
|
||||||
|
const page = typeof item.page_name === 'string' ? item.page_name :
|
||||||
|
typeof item.page === 'string' ? item.page : '';
|
||||||
|
const existing = seen.get(page);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
} else {
|
||||||
|
seen.set(page, { item, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()].map(({ item, count }) =>
|
||||||
|
count > 1 ? { ...item, hit_count: count } : item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
|
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
|
||||||
function notFound(c: Context<{ Bindings: Bindings }>): Response {
|
function notFound(c: Context<{ Bindings: Bindings }>): Response {
|
||||||
return c.json({ error: '找不到這筆資料' }, 404);
|
return c.json({ error: '找不到這筆資料' }, 404);
|
||||||
@@ -103,7 +129,10 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
|
|||||||
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
|
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
|
||||||
* 純函式(單測用 export)。
|
* 純函式(單測用 export)。
|
||||||
*/
|
*/
|
||||||
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);
|
// execution_log/execution_log_usage(KV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
|
||||||
|
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
|
||||||
|
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
|
||||||
|
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
|
||||||
|
|
||||||
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
|
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
|
||||||
entries: T[],
|
entries: T[],
|
||||||
@@ -121,6 +150,62 @@ export function filterDeprecatedEntries<T extends { metadata_json?: string | nul
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CJK/ASCII 邊界插空白正規化(t95):
|
||||||
|
* 「AI協作」→「AI 協作」;「協作AI」→「協作 AI」;已有空白不重複插。
|
||||||
|
* 只動查詢端,不動索引端。純函式,單測用 export。
|
||||||
|
*/
|
||||||
|
export function normalizeCjkQuery(q: string): string {
|
||||||
|
// U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat.
|
||||||
|
const isCjk = (c: string) => /[-鿿豈-]/.test(c);
|
||||||
|
const isAsciiAlnum = (c: string) => /[-鿿豈-]/.test(c);
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < q.length; i++) {
|
||||||
|
const ch = q[i];
|
||||||
|
if (result.length > 0) {
|
||||||
|
const prev = result[result.length - 1];
|
||||||
|
if (prev !== ' ' && ch !== ' ' &&
|
||||||
|
((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
|
||||||
|
result += ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result += ch;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用):
|
||||||
|
* 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。
|
||||||
|
*/
|
||||||
|
export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
|
||||||
|
const term = normalizeCjkQuery(searchTerm).toLowerCase();
|
||||||
|
if (!term) return null;
|
||||||
|
const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
|
||||||
|
if (hits.length === 0) return null;
|
||||||
|
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||||
|
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
||||||
|
if (!body || !Array.isArray(body.records)) return null;
|
||||||
|
const nodeNames = new Set<string>();
|
||||||
|
for (const r of body.records) {
|
||||||
|
const v = r?.values;
|
||||||
|
if (!v || typeof v !== 'object') continue;
|
||||||
|
if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
|
||||||
|
if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
|
||||||
|
}
|
||||||
|
return findBestNodeMatch(searchTerm, [...nodeNames]);
|
||||||
|
} catch {
|
||||||
|
return null; // fallback 失敗靜默略過,原本 0 結果直接回
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
|
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
|
||||||
//(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形
|
//(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形
|
||||||
//(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
|
//(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
|
||||||
@@ -129,8 +214,9 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
|||||||
run(c, async () => {
|
run(c, async () => {
|
||||||
const auth = await requirePortalUser(c);
|
const auth = await requirePortalUser(c);
|
||||||
if (!auth.ok) return auth.res;
|
if (!auth.ok) return auth.res;
|
||||||
const q = c.req.query('q');
|
const qRaw = c.req.query('q');
|
||||||
if (!q) return c.json({ error: 'q 必填' }, 400);
|
if (!qRaw) return c.json({ error: 'q 必填' }, 400);
|
||||||
|
const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端)
|
||||||
|
|
||||||
const libraries = parseLibraries(auth.user.values.libraries);
|
const libraries = parseLibraries(auth.user.values.libraries);
|
||||||
if (libraries.length === 0) {
|
if (libraries.length === 0) {
|
||||||
@@ -142,7 +228,32 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
|||||||
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||||||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||||||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
||||||
if (c.req.query('mode') === 'semantic') params.set('mode', 'semantic');
|
if (c.req.query('mode') === 'semantic') {
|
||||||
|
params.set('mode', 'semantic');
|
||||||
|
// 🔴 t183(leo 08-04 實撞:「語義搜尋搜到一大堆不相關的內容」
|
||||||
|
// ——搜「火星座標」卻跑出 n8n 版本比較表、Leo 填答):
|
||||||
|
// Vectorize 會**硬湊滿 topK 筆**,湊不到就把低分的塞進來 ⇒ 尾巴全是無關內容。
|
||||||
|
// kbdb 早就支援 min_score(`kbdb/src/embed.ts:225`,issue #67),
|
||||||
|
// 但 portal **從來沒傳** ⇒ 等同沒有閾值,低分尾全端到用戶面前。
|
||||||
|
//
|
||||||
|
// 0.75 怎麼來的(**實測分數分布,不是猜的**;youlin 實例搜「火星座標 奧林帕斯山」):
|
||||||
|
// 0.908 / 0.881 / 0.881 / 0.880 / 0.870 / 0.815 / 0.798 / 0.787 ← 全是火星座標,真相關
|
||||||
|
// ─────────────────────── 斷崖 ───────────────────────
|
||||||
|
// 0.742 姨媽說故事 / 0.740 ax-academy / 0.739×8 n8n 版本比較表 ← 全是雜訊
|
||||||
|
// 斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75:相關的全留、雜訊全砍。
|
||||||
|
//
|
||||||
|
// 允許前端覆寫(想放寬看更多可傳 min_score),但**不接受 0/負數**
|
||||||
|
// ——那等於關掉閾值,正是 t183 要修的病本身。
|
||||||
|
//
|
||||||
|
// 🔴 2026-08-05 修正(leo 實撞「語義搜尋 0 命中」):**這裡不再硬寫預設值**。
|
||||||
|
// 上面 0.75 是照**舊模型 bge-base-en-v1.5** 的分數分布定的;08-05 換 bge-m3 後
|
||||||
|
// 分數尺度整體下移,0.75 砍掉的變成正解 ⇒ 新上傳的檔一律 0 命中。
|
||||||
|
// 根因=**閾值是模型的性質,卻被複製到呼叫端**,換模型時沒人想到要回來改這行。
|
||||||
|
// ⇒ 預設值移到 `kbdb/src/embed.ts` 的 `DEFAULT_MIN_SCORE`(緊鄰 DEFAULT_EMBED_MODEL),
|
||||||
|
// portal 只在**使用者顯式指定**時才傳。**不要把數字搬回來。**
|
||||||
|
const msRaw = Number(c.req.query('min_score'));
|
||||||
|
if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw));
|
||||||
|
}
|
||||||
const entryType = c.req.query('entry_type');
|
const entryType = c.req.query('entry_type');
|
||||||
if (entryType) params.set('entry_type', entryType);
|
if (entryType) params.set('entry_type', entryType);
|
||||||
const limit = c.req.query('limit');
|
const limit = c.req.query('limit');
|
||||||
@@ -205,6 +316,9 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
|||||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
|
||||||
|
const nodeName = normalizeCjkQuery(c.req.param('name'));
|
||||||
|
|
||||||
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
||||||
const tenant = portalTenant(c.env);
|
const tenant = portalTenant(c.env);
|
||||||
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
||||||
@@ -214,7 +328,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
|||||||
const result = await executeWebhookGraph(
|
const result = await executeWebhookGraph(
|
||||||
c.env,
|
c.env,
|
||||||
wfGraph,
|
wfGraph,
|
||||||
{ node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
|
// t116: 補傳 kbdb_base;t128: 補傳 template(workflow fetch_triplets.url 用 {{input.template}})
|
||||||
|
{ node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' },
|
||||||
'graph_neighbors',
|
'graph_neighbors',
|
||||||
tenant,
|
tenant,
|
||||||
c.executionCtx,
|
c.executionCtx,
|
||||||
@@ -231,8 +346,23 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
|||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
|
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
if (!res.ok) {
|
||||||
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
// t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」)
|
||||||
|
const resText = await res.text().catch(() => '');
|
||||||
|
let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
|
||||||
|
try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
|
||||||
|
if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
|
||||||
|
Array.isArray(data.edges) && data.edges.length === 0) {
|
||||||
|
const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
|
||||||
|
if (fallbackName && fallbackName !== nodeName) {
|
||||||
|
const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
|
||||||
|
return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
|
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
|
||||||
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
|
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||||
@@ -315,10 +445,12 @@ portalDataRouter.get('/portal/data/chat', (c) =>
|
|||||||
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
|
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
|
||||||
}
|
}
|
||||||
// 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
|
// 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
|
||||||
|
// t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。
|
||||||
const inner = unwrapWorkflowData(result.data, 'answer');
|
const inner = unwrapWorkflowData(result.data, 'answer');
|
||||||
|
const rawSources = Array.isArray(inner.sources) ? inner.sources : [];
|
||||||
return c.json({
|
return c.json({
|
||||||
answer: typeof inner.answer === 'string' ? inner.answer : '',
|
answer: typeof inner.answer === 'string' ? inner.answer : '',
|
||||||
sources: Array.isArray(inner.sources) ? inner.sources : [],
|
sources: dedupeSourcesByPage(rawSources),
|
||||||
graph_facts: inner.graph_facts ?? null,
|
graph_facts: inner.graph_facts ?? null,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -431,23 +563,20 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
|||||||
/* 壞 record 誠實留空 */
|
/* 壞 record 誠實留空 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp,
|
// 最近一次執行:KV 額度事故修復(2026-08-07)改打 KBDB GET /execution-log/latest
|
||||||
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
|
// (原走 ANALYTICS_KV stats:{name}:* list,免費層 list 也是 1,000/日)。KBDB=
|
||||||
|
// API-as-Wall:不直連 D1,走既有 kbdbFetch(本檔已在用,見上方 import)。
|
||||||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||||||
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
const execRes = await kbdbFetch(
|
||||||
if (stats.keys.length > 0) {
|
c.env,
|
||||||
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
|
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
|
||||||
const ts = latest.name.split(':').pop() ?? '';
|
);
|
||||||
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
|
const execBody = await execRes.json().catch(() => null) as {
|
||||||
let verdict: string | undefined;
|
success?: boolean;
|
||||||
if (rawStat) {
|
execution?: { verdict: string; recorded_at: number } | null;
|
||||||
try {
|
} | null;
|
||||||
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
|
if (execRes.ok && execBody?.success && execBody.execution) {
|
||||||
} catch {
|
last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict };
|
||||||
/* 壞 record 誠實留空 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
last_execution = { timestamp: ts, verdict };
|
|
||||||
}
|
}
|
||||||
return { name, description, created_at, cron_expr, last_execution };
|
return { name, description, created_at, cron_expr, last_execution };
|
||||||
}),
|
}),
|
||||||
@@ -455,3 +584,36 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
|||||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||||||
|
//
|
||||||
|
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||||||
|
//
|
||||||
|
// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。
|
||||||
|
//
|
||||||
|
// 🔴 t213(2026-08-08,InkStoneCo 總管交辦):leo 實測拿真檔驗四個真實問題,只答得出一題
|
||||||
|
// (雲端這半的 bundle_version)——其餘三題(本機檔案總量、失敗分類統計、daemon 版本/
|
||||||
|
// 自我更新狀態)需要本機資料,雲端這支端點天生構不到(封測者的瀏覽器與他電腦上的
|
||||||
|
// daemon 是兩個獨立行程)。核准方案:本機那半改由 arcrun-app(daemon 桌面殼)匯出時
|
||||||
|
// 直接讀本機檔案,並改打**新增的** `GET /portal/daemon/diagnostics`(X-Arcrun-API-Key
|
||||||
|
// 認證,免帳密)取雲端這半,兩者合併成一份完整診斷檔——arcrun-app 那半見
|
||||||
|
// products/arcrun-rag repo t213 phase 2。本端點(portal 網頁版)保留當退路(daemon
|
||||||
|
// 完全掛掉時仍按得到),文案需誠實講清楚自己只有一半,完整診斷請去 daemon 匯出
|
||||||
|
// (portal 前端文案改動不在本次 matrix/arcrun 範圍內,由 arcrun-rag 那邊處理)。
|
||||||
|
//
|
||||||
|
// 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變,核心邏輯已抽成 buildDiagnostics()
|
||||||
|
// (portal.ts)——與新的 daemon 版共用同一份查詢邏輯(薄殼原則)。
|
||||||
|
portalDataRouter.get('/portal/data/diagnostics', (c) =>
|
||||||
|
run(c, async () => {
|
||||||
|
const auth = await requirePortalUser(c);
|
||||||
|
if (!auth.ok) return auth.res;
|
||||||
|
const tenant = portalTenant(c.env);
|
||||||
|
const core = await buildDiagnostics(c.env, tenant);
|
||||||
|
return c.json({
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
instance_url: new URL(c.req.url).origin,
|
||||||
|
bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
|
||||||
|
...core,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { deriveRecipeHash } from '../lib/hash';
|
import { deriveRecipeHash } from '../lib/hash';
|
||||||
|
import type { ResponseMap } from '../lib/recipe-payload';
|
||||||
|
|
||||||
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
|
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -34,6 +35,26 @@ export interface RecipeDefinition {
|
|||||||
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
|
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: Record<string, unknown>;
|
body?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* ③ payload 層(SDD workflow-discovery 3.12):帶 body 的 API 把 payload 收回 recipe,
|
||||||
|
* 不必寫進 workflow code。與 `body` 的差別=支援巢狀 {{var}} 與 dot path、
|
||||||
|
* 單一引用保留原型別。兩者並存時 body_template 優先(新欄位贏,舊 recipe 不受影響)。
|
||||||
|
*/
|
||||||
|
body_template?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* ③ 回應正規化層:各家 API 回應形狀不同(Gemini/Claude/Workers AI),
|
||||||
|
* 取值路徑・思考型模型旗標・淨化規則**隨 recipe 走** ⇒ 換源=換 recipe,不必改 workflow。
|
||||||
|
* 未設=原樣回傳(既有 recipe 行為零變化)。
|
||||||
|
*/
|
||||||
|
response_map?: ResponseMap;
|
||||||
|
/**
|
||||||
|
* 認證型別。未設=沿用既有 auth_service 判斷(向後相容)。
|
||||||
|
* `binding`=**免金鑰**,用平台內建能力(env.AI/VECTORIZE/BROWSER/QUEUE),
|
||||||
|
* 不是為 Workers AI 開特例——Cloudflare 這一整類都被舊抽象(只認 HTTP+金鑰)排除在外。
|
||||||
|
*/
|
||||||
|
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
|
||||||
|
/** auth='binding' 時指定用哪個 binding(例 'AI'/'VECTORIZE')。 */
|
||||||
|
binding_name?: string;
|
||||||
/**
|
/**
|
||||||
* 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。
|
* 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。
|
||||||
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
|
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
|
||||||
@@ -116,6 +137,11 @@ recipesRouter.post('/recipes', async (c) => {
|
|||||||
method: (body.method ?? 'POST').toUpperCase(),
|
method: (body.method ?? 'POST').toUpperCase(),
|
||||||
headers: body.headers,
|
headers: body.headers,
|
||||||
body: body.body,
|
body: body.body,
|
||||||
|
// ③ payload/回應/binding 三層(3.12):全選填,沒給就是 undefined=既有行為
|
||||||
|
body_template: body.body_template,
|
||||||
|
response_map: body.response_map,
|
||||||
|
auth: body.auth,
|
||||||
|
binding_name: body.binding_name,
|
||||||
auth_service: body.auth_service,
|
auth_service: body.auth_service,
|
||||||
credentials_required: body.credentials_required,
|
credentials_required: body.credentials_required,
|
||||||
created_at: existing?.created_at ?? now,
|
created_at: existing?.created_at ?? now,
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ async function triggerNamed(
|
|||||||
c.executionCtx.waitUntil(
|
c.executionCtx.waitUntil(
|
||||||
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
|
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
|
||||||
.then(result =>
|
.then(result =>
|
||||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return c.json({ accepted: true }, 202);
|
return c.json({ accepted: true }, 202);
|
||||||
@@ -329,7 +329,7 @@ async function triggerNamed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
c.executionCtx.waitUntil(
|
c.executionCtx.waitUntil(
|
||||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json(result, result.success ? 200 : 500);
|
return c.json(result, result.success ? 200 : 500);
|
||||||
@@ -401,7 +401,7 @@ async function queryNamed(
|
|||||||
|
|
||||||
// 執行判決寫入不阻塞回應(waitUntil,與 /trigger 一致)。
|
// 執行判決寫入不阻塞回應(waitUntil,與 /trigger 一致)。
|
||||||
c.executionCtx.waitUntil(
|
c.executionCtx.waitUntil(
|
||||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
|
|||||||
const workflowId = graph.id ?? token;
|
const workflowId = graph.id ?? token;
|
||||||
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
|
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
|
||||||
c.executionCtx.waitUntil(
|
c.executionCtx.waitUntil(
|
||||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json(result, result.success ? 200 : 500);
|
return c.json(result, result.success ? 200 : 500);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度)
|
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度)
|
||||||
* 3. 匹配才去讀完整 workflow record({apiKey}:wf:{name})
|
* 3. 匹配才去讀完整 workflow record({apiKey}:wf:{name})
|
||||||
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
|
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
|
||||||
|
* 5. 每天固定一分鐘(UTC 02:30)順便叫 KBDB 清一批過期執行紀錄(P7 保留期,見下方 §5)
|
||||||
*
|
*
|
||||||
* 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限,
|
* 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限,
|
||||||
* 改成單一固定 key 只 get 一次 → list 歸零。
|
* 改成單一固定 key 只 get 一次 → list 歸零。
|
||||||
@@ -18,6 +19,7 @@ import type { Bindings } from './types';
|
|||||||
import { cronMatch } from './lib/cron-match';
|
import { cronMatch } from './lib/cron-match';
|
||||||
import { readCronIndex, parseCronEntryKey } from './lib/cron-index';
|
import { readCronIndex, parseCronEntryKey } from './lib/cron-index';
|
||||||
import { executeWebhookGraph } from './actions/webhook-handlers';
|
import { executeWebhookGraph } from './actions/webhook-handlers';
|
||||||
|
import { kbdbBase } from './routes/kbdb-proxy';
|
||||||
|
|
||||||
type StoredWorkflowRecord = {
|
type StoredWorkflowRecord = {
|
||||||
graph: Record<string, unknown>;
|
graph: Record<string, unknown>;
|
||||||
@@ -73,4 +75,22 @@ export async function handleScheduled(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`);
|
console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`);
|
||||||
|
|
||||||
|
// §5 P7 保留期清理(2026-08-09):不新增排程基礎設施(wrangler.toml [triggers] 是受保護
|
||||||
|
// 檔案,AI 不可編輯——見 InkStoneCo 頂層 pending-changes.md P9 段 L1 權限閘),改「搭便車」:
|
||||||
|
// 這支 handler 本來就每分鐘醒一次(給上面的 cron workflow 用),挑固定一分鐘(UTC 02:30,
|
||||||
|
// 避開整點/半點常見的 cron 表達式擁擠時段)順手打一次 fire-and-forget 給 KBDB 的
|
||||||
|
// POST /execution-log/cleanup。頻率仍是「一天一次」,不是輪詢外部系統要狀態,是既有 tick
|
||||||
|
// 順手打理自己的表。呼叫失敗不影響上面的 cron workflow 觸發(各自 try/catch,互不拖累)。
|
||||||
|
if (now.getUTCHours() === 2 && now.getUTCMinutes() === 30) {
|
||||||
|
const { base, headers } = kbdbBase(env);
|
||||||
|
ctx.waitUntil(
|
||||||
|
fetch(`${base}/execution-log/cleanup`, { method: 'POST', headers })
|
||||||
|
.then(async (r) => {
|
||||||
|
const body = await r.json().catch(() => null);
|
||||||
|
console.log('[scheduled] execution-log cleanup', r.status, JSON.stringify(body));
|
||||||
|
})
|
||||||
|
.catch((e) => console.error('[scheduled] execution-log cleanup failed', e)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export type Bindings = {
|
|||||||
// Credential Store:AES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7
|
// Credential Store:AES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7
|
||||||
// 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡)
|
// 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡)
|
||||||
CREDENTIALS_KV: KVNamespace;
|
CREDENTIALS_KV: KVNamespace;
|
||||||
// credential-store-migration T2/T5(D19「擁有目錄,不擁有內容物」):credential 目錄表
|
// ⚠️ D38 圍牆修復(2026-08-07)後零讀寫點:credential 目錄已改走 KBDB entries HTTP API
|
||||||
// (api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,不含密文)。
|
// (見 cypher-executor/src/routes/credentials.ts),不再對這顆 D1 下任何 SQL。binding
|
||||||
// 與 KBDB base 共用同一顆 arcrun-kbdb D1(self-hosted 由 deploy.ts 注入用戶自己的
|
// 因 wrangler.toml 被權限鎖住(D38 決策所述)暫留宣告,比照 ANALYTICS_KV 同一模式
|
||||||
// database_id,比照 kbdb/wrangler.toml 同一套 database_id 注入機制)。密文本體不在這裡,
|
// (commit 60688c3:binding 留在 toml,程式碼零讀寫點)。舊表資料遷移路徑見
|
||||||
// 住在 Workers per-script Secrets(見 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。
|
// kbdb/migrations/0006_drop_credentials_table.sql。
|
||||||
CREDENTIALS_DB: D1Database;
|
CREDENTIALS_DB: D1Database;
|
||||||
// Analytics:執行統計(fire-and-forget,key = stats:{workflowId}:{timestamp})
|
// Analytics:執行統計(fire-and-forget,key = stats:{workflowId}:{timestamp})
|
||||||
ANALYTICS_KV: KVNamespace;
|
ANALYTICS_KV: KVNamespace;
|
||||||
@@ -68,6 +68,13 @@ export type Bindings = {
|
|||||||
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
|
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
|
||||||
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
|
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
|
||||||
WORKER_SUBDOMAIN: string;
|
WORKER_SUBDOMAIN: string;
|
||||||
|
/**
|
||||||
|
* t162:本實例安裝時的 bundle 版本(格式 `YYYY-MM-DD+<commit7>`)。
|
||||||
|
* 由安裝器 deployBundledWorker 注入(worker.js:805),**給 daemon 比對用**——
|
||||||
|
* daemon `/health` 讀不到就恆判「需要更新」(假警報迴圈,leo 07-31 實撞)。
|
||||||
|
* 未注入(本地 dev/舊實例)= undefined,/health 省略該欄。
|
||||||
|
*/
|
||||||
|
ARCRUN_BUNDLE_VERSION?: string;
|
||||||
// Platform telemetry api_key(可選,wrangler secret)
|
// Platform telemetry api_key(可選,wrangler secret)
|
||||||
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
||||||
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
||||||
@@ -96,6 +103,9 @@ export type Bindings = {
|
|||||||
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||||
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||||
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||||
|
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
|
||||||
|
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
|
||||||
|
ARCRUN_BUNDLE_VERSION?: string;
|
||||||
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
||||||
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
||||||
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||||
@@ -105,6 +115,19 @@ export type Bindings = {
|
|||||||
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
|
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
|
||||||
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
|
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
|
||||||
PORTAL_SESSION_TTL?: string;
|
PORTAL_SESSION_TTL?: string;
|
||||||
|
// Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它;
|
||||||
|
// D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。
|
||||||
|
UI_ORIGINS?: string;
|
||||||
|
// ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)───────────────────────────
|
||||||
|
// 中央代寄服務的 base URL(landing worker)。**用戶自己的實例沒有寄信能力**——安裝器
|
||||||
|
// 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize,
|
||||||
|
// **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。
|
||||||
|
// 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。
|
||||||
|
// ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。
|
||||||
|
PORTAL_MAIL_RELAY_BASE?: string;
|
||||||
|
// 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端
|
||||||
|
// 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。
|
||||||
|
PORTAL_MAIL_RELAY_KEY?: string;
|
||||||
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
|
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
|
||||||
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
|
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
|
||||||
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
|
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
|
||||||
@@ -148,6 +171,7 @@ export type GraphNode = {
|
|||||||
export type EdgeType =
|
export type EdgeType =
|
||||||
| 'PIPE' | 'IF' | 'FOREACH' | 'CONTINUE' // 現有
|
| 'PIPE' | 'IF' | 'FOREACH' | 'CONTINUE' // 現有
|
||||||
| 'IS_A' | 'ON_SUCCESS' | 'ON_FAIL' // 執行語意
|
| 'IS_A' | 'ON_SUCCESS' | 'ON_FAIL' // 執行語意
|
||||||
|
| 'ON_TRUE' | 'ON_FALSE' | 'ON_BRANCH' // 條件語意(SDD workflow-discovery 3.11)
|
||||||
| 'ON_CLICK' | 'CALLS_SUBFLOW' // 觸發語意
|
| 'ON_CLICK' | 'CALLS_SUBFLOW' // 觸發語意
|
||||||
| 'CONTAINS' | 'HAS_STYLE' | 'HAS_BEHAVIOR'; // 結構語意(記錄圖結構,不執行)
|
| 'CONTAINS' | 'HAS_STYLE' | 'HAS_BEHAVIOR'; // 結構語意(記錄圖結構,不執行)
|
||||||
|
|
||||||
@@ -157,6 +181,8 @@ export type GraphEdge = {
|
|||||||
type: EdgeType;
|
type: EdgeType;
|
||||||
condition?: string; // IF 的條件表達式
|
condition?: string; // IF 的條件表達式
|
||||||
iterator?: string; // FOREACH 的迭代變數名
|
iterator?: string; // FOREACH 的迭代變數名
|
||||||
|
/** ON_BRANCH 的具名分支(對應 switch 零件 output 的 data.branch) */
|
||||||
|
branch?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExecutionGraph = {
|
export type ExecutionGraph = {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 逐顆查詢的回應要自我說明分支用法(SDD workflow-discovery 3.11;總管 08-01 抽驗第 3 點)
|
||||||
|
*
|
||||||
|
* 判準(leo/總管一致):**AI 只看那一顆的回應,就知道怎麼接下一步**——
|
||||||
|
* 不必回頭讀 skill、不必猜。看得到分支說明才算數。
|
||||||
|
*
|
||||||
|
* 取證背景(08-01 prod):逐顆查 if_control 只回
|
||||||
|
* status/componentId/type/source/input_schema{condition,input}/success_rate/stability
|
||||||
|
* ⇒ **沒有任何欄位說明分支怎麼接** ⇒ 走 n8n 式逐顆查的 AI 只好寫 code。
|
||||||
|
*
|
||||||
|
* 本檔直接驗 `branchHintFor()`(回應裡那個欄位的來源),並把 AI 實際會看到的內容印出來。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { branchHintFor } from '../src/lib/branch-hints';
|
||||||
|
|
||||||
|
describe('三顆分支零件的查詢回應自帶用法(AI 看一眼就知道怎麼接)', () => {
|
||||||
|
for (const id of ['if_control', 'switch', 'try_catch']) {
|
||||||
|
it(`${id}:回應含 branch_field/branches/edge_types/usage/example`, () => {
|
||||||
|
const hint = branchHintFor(id);
|
||||||
|
expect(hint).toBeDefined();
|
||||||
|
|
||||||
|
// 這一顆會輸出哪個欄位當分支標籤
|
||||||
|
expect(hint!.branch_field).toBe('data.branch');
|
||||||
|
// 接下游要用哪些邊型
|
||||||
|
expect(hint!.edge_types.length).toBeGreaterThan(0);
|
||||||
|
// 一行說明 + 可照抄範例(缺任一個,AI 都得自己猜)
|
||||||
|
expect(hint!.usage.length).toBeGreaterThan(0);
|
||||||
|
expect(hint!.example.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`\n──────── 逐顆查 ${id} 時,AI 會看到的 branch_hint ────────\n` +
|
||||||
|
JSON.stringify(hint, null, 2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('if_control 明說 ON_TRUE/ON_FALSE 兩條邊', () => {
|
||||||
|
const h = branchHintFor('if_control')!;
|
||||||
|
expect(h.edge_types).toContain('ON_TRUE');
|
||||||
|
expect(h.edge_types).toContain('ON_FALSE');
|
||||||
|
expect(h.branches).toEqual(['true', 'false']);
|
||||||
|
// 明說「不需要自己寫 code 判斷」——這句是防腹語術的關鍵
|
||||||
|
expect(h.usage).toContain('不需要自己寫 code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switch 明說用 ON_BRANCH 並在邊上標 case 名,且 default 不需特別邊型', () => {
|
||||||
|
const h = branchHintFor('switch')!;
|
||||||
|
expect(h.edge_types).toContain('ON_BRANCH');
|
||||||
|
expect(h.usage).toContain('ON_BRANCH');
|
||||||
|
expect(h.usage).toContain('default_branch');
|
||||||
|
// branches 是動態的(由 cases 決定),要誠實說明而非給死清單
|
||||||
|
expect(typeof h.branches).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('try_catch 明說 try/catch 兩條標籤,錯誤處理不必寫 code', () => {
|
||||||
|
const h = branchHintFor('try_catch')!;
|
||||||
|
expect(h.branches).toEqual(['try', 'catch']);
|
||||||
|
expect(h.edge_types).toContain('ON_BRANCH');
|
||||||
|
expect(h.usage).toContain('不需要寫 code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不分岔的零件沒有 branch_hint(不加噪音)', () => {
|
||||||
|
expect(branchHintFor('http_request')).toBeUndefined();
|
||||||
|
expect(branchHintFor('code')).toBeUndefined();
|
||||||
|
expect(branchHintFor(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 三型分支零件「真的接上引擎」的實測(SDD workflow-discovery 3.11)
|
||||||
|
*
|
||||||
|
* 為什麼要另立這一支(總管 08-01 抽驗要求,正確的要求):
|
||||||
|
* conditional-edges.test.ts 是用 Input 節點**手餵分支形狀**測引擎走邊邏輯,
|
||||||
|
* 那證明的是「引擎依標籤選邊」,**沒有證明「真零件吐出來的標籤真的對得上」**。
|
||||||
|
* leo 特別點名 switch/try_catch,且 `ON_CASE`/`ON_CATCH` grep=0
|
||||||
|
* ⇒ 必須排除「機制通用所以理論上支援」這種推論。
|
||||||
|
*
|
||||||
|
* 本檔的 given 全部是**真 WASM 零件的實跑輸出**(wasmtime 執行 .component-builds/*.wasm
|
||||||
|
* 抓回來的原文,非杜撰),再送進引擎驗證走對邊。
|
||||||
|
*
|
||||||
|
* 真零件實跑指令(可復驗):
|
||||||
|
* cd .component-builds
|
||||||
|
* echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm
|
||||||
|
* echo '{"value":"pending","cases":[...],"default_branch":"branch_default"}' | wasmtime switch/component.wasm
|
||||||
|
* echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm
|
||||||
|
*/
|
||||||
|
import { SELF } from 'cloudflare:test';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
async function run(graph: unknown) {
|
||||||
|
const res = await SELF.fetch('http://localhost/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ graph, context: {} }),
|
||||||
|
});
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
trace?: Array<{ nodeId: string }>;
|
||||||
|
};
|
||||||
|
return { body, visited: (body.trace ?? []).map(t => t.nodeId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真零件輸出 → 當作上游節點的 output 餵進圖 */
|
||||||
|
function graphWith(realOutput: unknown, edges: Array<Record<string, unknown>>, extraNodes: string[]) {
|
||||||
|
return {
|
||||||
|
id: 'real-branch',
|
||||||
|
name: '真零件輸出走邊',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'ctrl', type: 'Input', data: realOutput },
|
||||||
|
...extraNodes.map(id => ({
|
||||||
|
id, type: 'Component', componentId: 'comp_uppercase', data: { text: id },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
edges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('if_control 真輸出 → 引擎走對邊', () => {
|
||||||
|
// 真跑:echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm
|
||||||
|
const REAL_TRUE = { data: { branch: 'true', result: true }, success: true };
|
||||||
|
// 真跑:input.status = "inactive"
|
||||||
|
const REAL_FALSE = { data: { branch: 'false', result: false }, success: true };
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
{ from: 'ctrl', to: 'yes', type: 'ON_TRUE' },
|
||||||
|
{ from: 'ctrl', to: 'no', type: 'ON_FALSE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('條件成立(真輸出 branch="true")→ 走 ON_TRUE', async () => {
|
||||||
|
const { body, visited } = await run(graphWith(REAL_TRUE, edges, ['yes', 'no']));
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(visited).toContain('yes');
|
||||||
|
expect(visited).not.toContain('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('條件不成立(真輸出 branch="false")→ 走 ON_FALSE', async () => {
|
||||||
|
const { visited } = await run(graphWith(REAL_FALSE, edges, ['yes', 'no']));
|
||||||
|
expect(visited).toContain('no');
|
||||||
|
expect(visited).not.toContain('yes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('switch 真輸出 → 引擎走對邊(多路+default,leo:「switch 更嚴重」)', () => {
|
||||||
|
// 真跑(三個 case + default_branch):
|
||||||
|
// value="active" → {"data":{"branch":"branch_active"},"success":true}
|
||||||
|
// value="pending" → {"data":{"branch":"branch_pending"},"success":true}
|
||||||
|
// value="zzz" → {"data":{"branch":"branch_default"},"success":true}
|
||||||
|
const REAL_CASE1 = { data: { branch: 'branch_active' }, success: true };
|
||||||
|
const REAL_CASE3 = { data: { branch: 'branch_pending' }, success: true };
|
||||||
|
const REAL_DEFAULT = { data: { branch: 'branch_default' }, success: true };
|
||||||
|
|
||||||
|
const targets = ['p_active', 'p_inactive', 'p_pending', 'p_default'];
|
||||||
|
const edges = [
|
||||||
|
{ from: 'ctrl', to: 'p_active', type: 'ON_BRANCH', branch: 'branch_active' },
|
||||||
|
{ from: 'ctrl', to: 'p_inactive', type: 'ON_BRANCH', branch: 'branch_inactive' },
|
||||||
|
{ from: 'ctrl', to: 'p_pending', type: 'ON_BRANCH', branch: 'branch_pending' },
|
||||||
|
{ from: 'ctrl', to: 'p_default', type: 'ON_BRANCH', branch: 'branch_default' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('第 1 條 case(真輸出 branch_active)→ 只走 p_active', async () => {
|
||||||
|
const { body, visited } = await run(graphWith(REAL_CASE1, edges, targets));
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(visited).toContain('p_active');
|
||||||
|
expect(visited).not.toContain('p_inactive');
|
||||||
|
expect(visited).not.toContain('p_pending');
|
||||||
|
expect(visited).not.toContain('p_default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('第 3 條 case(真輸出 branch_pending)→ 只走 p_pending(證明第 N 條路走得對)', async () => {
|
||||||
|
const { visited } = await run(graphWith(REAL_CASE3, edges, targets));
|
||||||
|
expect(visited).toContain('p_pending');
|
||||||
|
expect(visited).not.toContain('p_active');
|
||||||
|
expect(visited).not.toContain('p_inactive');
|
||||||
|
expect(visited).not.toContain('p_default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('無匹配(真輸出 branch_default)→ 只走 p_default', async () => {
|
||||||
|
const { visited } = await run(graphWith(REAL_DEFAULT, edges, targets));
|
||||||
|
expect(visited).toContain('p_default');
|
||||||
|
expect(visited).not.toContain('p_active');
|
||||||
|
expect(visited).not.toContain('p_pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('try_catch 真輸出 → 引擎走對邊(ok/catch 兩路都驗)', () => {
|
||||||
|
// 真跑:echo '{"result":{"value":42},"error":""}' | wasmtime try_catch/component.wasm
|
||||||
|
const REAL_TRY = { data: { branch: 'try', result: { value: 42 } }, success: true };
|
||||||
|
// 真跑:echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm
|
||||||
|
const REAL_CATCH = { data: { branch: 'catch', error: 'boom' }, success: true };
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
{ from: 'ctrl', to: 'normal', type: 'ON_BRANCH', branch: 'try' },
|
||||||
|
{ from: 'ctrl', to: 'rescue', type: 'ON_BRANCH', branch: 'catch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('成功(真輸出 branch="try")→ 走 normal,不走 rescue', async () => {
|
||||||
|
const { body, visited } = await run(graphWith(REAL_TRY, edges, ['normal', 'rescue']));
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(visited).toContain('normal');
|
||||||
|
expect(visited).not.toContain('rescue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失敗(真輸出 branch="catch")→ 走 rescue,不走 normal', async () => {
|
||||||
|
const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue']));
|
||||||
|
expect(visited).toContain('rescue');
|
||||||
|
expect(visited).not.toContain('normal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('try_catch 的 catch 路承接了「上游失敗」——不必寫 code try 一遍', async () => {
|
||||||
|
// 這是 leo 點名 try_catch 的原因:schema 用文字寫「走 catch 分支」但機器層沒有那條路。
|
||||||
|
// 現在有了:catch 標籤 → ON_BRANCH branch="catch" → 補救節點。
|
||||||
|
const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue']));
|
||||||
|
expect(visited).toContain('rescue');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* 條件邊 ON_TRUE / ON_FALSE / ON_BRANCH —— CP `arcrun-usable` 步驟 5 缺口①
|
||||||
|
* SDD: workflow-discovery tasks 3.11
|
||||||
|
*
|
||||||
|
* 為什麼要有這組測試(別刪):
|
||||||
|
* `if_control` 零件回 `{success, data:{result, branch}}`,但引擎過去只有
|
||||||
|
* ON_SUCCESS / IF / FOREACH ⇒ 就算照規矩用 if_control,也只拿到布林值,
|
||||||
|
* 還是得寫 code 判斷該走哪條路 ⇒ 這正是「全變成 code」的根(Arcrun#5)。
|
||||||
|
*
|
||||||
|
* 本檔先寫測試再改引擎(引擎核心風險最高,紅線要求)。
|
||||||
|
* 既有邊行為的零變化迴歸另見 executor.test.ts(PIPE/IF/ON_SUCCESS 原樣通過)。
|
||||||
|
*/
|
||||||
|
import { SELF } from 'cloudflare:test';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
/** 送一張圖進 /execute,回 parsed JSON */
|
||||||
|
async function run(graph: unknown, context: Record<string, unknown> = {}) {
|
||||||
|
const res = await SELF.fetch('http://localhost/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ graph, context }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
body: (await res.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
trace?: Array<{ nodeId: string }>;
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用 Input 節點直接餵出 if_control 形狀的 output({data:{result,branch}}),
|
||||||
|
* 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。
|
||||||
|
*/
|
||||||
|
function branchGraph(branch: 'true' | 'false', edges: Array<Record<string, unknown>>) {
|
||||||
|
return {
|
||||||
|
id: `g-branch-${branch}`,
|
||||||
|
name: '條件邊測試',
|
||||||
|
nodes: [
|
||||||
|
// 模擬 if_control 的輸出形狀
|
||||||
|
{ id: 'cond', type: 'Input', data: { success: true, data: { result: branch === 'true', branch } } },
|
||||||
|
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||||
|
{ id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } },
|
||||||
|
],
|
||||||
|
edges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('條件邊:ON_TRUE / ON_FALSE(缺口① Arcrun#5 根治)', () => {
|
||||||
|
it('branch=true → 只走 ON_TRUE 那條,ON_FALSE 那條不執行', async () => {
|
||||||
|
const { body } = await run(
|
||||||
|
branchGraph('true', [
|
||||||
|
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||||
|
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('yes');
|
||||||
|
expect(visited).not.toContain('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('branch=false → 只走 ON_FALSE 那條,ON_TRUE 那條不執行', async () => {
|
||||||
|
const { body } = await run(
|
||||||
|
branchGraph('false', [
|
||||||
|
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||||
|
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('no');
|
||||||
|
expect(visited).not.toContain('yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('result 是布林但沒有 branch 欄位 → 仍judged得出(相容 {result:true} 形狀)', async () => {
|
||||||
|
const graph = {
|
||||||
|
id: 'g-bool-only',
|
||||||
|
name: '只有 result',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'cond', type: 'Input', data: { result: true } },
|
||||||
|
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||||
|
{ id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||||
|
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { body } = await run(graph);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('yes');
|
||||||
|
expect(visited).not.toContain('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('條件邊的下游拿得到上游 context(propagateCtx 一致)', async () => {
|
||||||
|
const graph = {
|
||||||
|
id: 'g-ctx',
|
||||||
|
name: 'context 傳遞',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' }, carried: 'keep-me' } },
|
||||||
|
{ id: 'yes', type: 'Component', componentId: 'comp_passthrough' },
|
||||||
|
],
|
||||||
|
edges: [{ from: 'cond', to: 'yes', type: 'ON_TRUE' }],
|
||||||
|
};
|
||||||
|
const { body } = await run(graph);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.carried).toBe('keep-me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('兩條 ON_TRUE 並存 → 都走(同分支多下游是合法 fan-out)', async () => {
|
||||||
|
const graph = {
|
||||||
|
id: 'g-fanout',
|
||||||
|
name: '同分支多下游',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' } } },
|
||||||
|
{ id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } },
|
||||||
|
{ id: 'b', type: 'Component', componentId: 'comp_uppercase', data: { text: 'b' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'cond', to: 'a', type: 'ON_TRUE' },
|
||||||
|
{ from: 'cond', to: 'b', type: 'ON_TRUE' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { body } = await run(graph);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('a');
|
||||||
|
expect(visited).toContain('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('條件邊:ON_BRANCH(switch 具名分支)', () => {
|
||||||
|
/** switch 零件回 {success, data:{branch:"branch_a"}} */
|
||||||
|
function switchGraph(branch: string) {
|
||||||
|
return {
|
||||||
|
id: 'g-switch',
|
||||||
|
name: 'switch 具名分支',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'sw', type: 'Input', data: { success: true, data: { branch } } },
|
||||||
|
{ id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } },
|
||||||
|
{ id: 'z', type: 'Component', componentId: 'comp_uppercase', data: { text: 'z' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'sw', to: 'a', type: 'ON_BRANCH', branch: 'branch_a' },
|
||||||
|
{ from: 'sw', to: 'z', type: 'ON_BRANCH', branch: 'fallback' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('branch=branch_a → 只走標 branch_a 的邊', async () => {
|
||||||
|
const { body } = await run(switchGraph('branch_a'));
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('a');
|
||||||
|
expect(visited).not.toContain('z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('branch=fallback → 只走標 fallback 的邊', async () => {
|
||||||
|
const { body } = await run(switchGraph('fallback'));
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('z');
|
||||||
|
expect(visited).not.toContain('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒有任何邊匹配 → 誠實地不走(不亂挑一條,也不報錯)', async () => {
|
||||||
|
const { body } = await run(switchGraph('no_such_branch'));
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).not.toContain('a');
|
||||||
|
expect(visited).not.toContain('z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('通用具名分支涵蓋三型零件(leo 08-01:switch 比 if 更嚴重)', () => {
|
||||||
|
/**
|
||||||
|
* 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string`:
|
||||||
|
* if_control → "true" | "false"(布林兩路)
|
||||||
|
* switch → case 的 branch 名 | default_branch(N 路)
|
||||||
|
* try_catch → "try" | "catch"(成功/失敗兩路)
|
||||||
|
* ⇒ 引擎只需要「依標籤選邊」這一個機制,不是為每顆零件開特例。
|
||||||
|
* ON_TRUE / ON_FALSE 只是 if 布林路的語法糖,底層與 ON_BRANCH 同一條路。
|
||||||
|
*/
|
||||||
|
async function branchTo(branch: string, edges: Array<Record<string, unknown>>) {
|
||||||
|
return run({
|
||||||
|
id: `g-generic-${branch}`,
|
||||||
|
name: '通用具名分支',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'ctrl', type: 'Input', data: { success: true, data: { branch } } },
|
||||||
|
{ id: 'p1', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p1' } },
|
||||||
|
{ id: 'p2', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p2' } },
|
||||||
|
{ id: 'p3', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p3' } },
|
||||||
|
],
|
||||||
|
edges,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const threeWay = [
|
||||||
|
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'branch_active' },
|
||||||
|
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'branch_inactive' },
|
||||||
|
{ from: 'ctrl', to: 'p3', type: 'ON_BRANCH', branch: 'branch_default' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('switch 多路:branch_active → 只走第一條,其餘兩條不走', async () => {
|
||||||
|
const { body } = await branchTo('branch_active', threeWay);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('p1');
|
||||||
|
expect(visited).not.toContain('p2');
|
||||||
|
expect(visited).not.toContain('p3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switch 多路:branch_inactive → 只走第二條', async () => {
|
||||||
|
const { body } = await branchTo('branch_inactive', threeWay);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('p2');
|
||||||
|
expect(visited).not.toContain('p1');
|
||||||
|
expect(visited).not.toContain('p3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switch default:無匹配 case 時零件回 default_branch → 走 default 那條', async () => {
|
||||||
|
// 注意:挑 default 是 switch 零件內部的事(它回 default_branch 名);
|
||||||
|
// 引擎這層看到的一律是「一個標籤」,故 default 不需要引擎特別處理。
|
||||||
|
const { body } = await branchTo('branch_default', threeWay);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('p3');
|
||||||
|
expect(visited).not.toContain('p1');
|
||||||
|
expect(visited).not.toContain('p2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('try_catch 成功路:branch=try → 走 try 邊,不走 catch 邊', async () => {
|
||||||
|
const { body } = await branchTo('try', [
|
||||||
|
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' },
|
||||||
|
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' },
|
||||||
|
]);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('p1');
|
||||||
|
expect(visited).not.toContain('p2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('try_catch 失敗路:branch=catch → 走 catch 邊,不走 try 邊', async () => {
|
||||||
|
const { body } = await branchTo('catch', [
|
||||||
|
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' },
|
||||||
|
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' },
|
||||||
|
]);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).toContain('p2');
|
||||||
|
expect(visited).not.toContain('p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ON_TRUE 與 ON_BRANCH branch="true" 等價(語法糖,底層同一條路)', async () => {
|
||||||
|
const sugar = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_TRUE' }]);
|
||||||
|
const raw = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'true' }]);
|
||||||
|
const v1 = (sugar.body.trace ?? []).map(t => t.nodeId);
|
||||||
|
const v2 = (raw.body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(v1).toEqual(v2);
|
||||||
|
expect(v1).toContain('p1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('零變化保證:新邊型不影響既有邊', () => {
|
||||||
|
it('ON_TRUE 邊存在時,同圖的 PIPE 邊照常走', async () => {
|
||||||
|
const graph = {
|
||||||
|
id: 'g-mixed',
|
||||||
|
name: '混合邊',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' }, count: 0 } },
|
||||||
|
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||||
|
{ id: 'always', type: 'Component', componentId: 'comp_counter' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||||
|
{ from: 'cond', to: 'always', type: 'PIPE' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { body } = await run(graph);
|
||||||
|
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(visited).not.toContain('yes'); // 條件邊擋掉
|
||||||
|
expect(visited).toContain('always'); // PIPE 不受影響
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/validate 接受 ON_TRUE / ON_FALSE / ON_BRANCH(schema 已放行)', async () => {
|
||||||
|
const res = await SELF.fetch('http://localhost/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'g-validate',
|
||||||
|
name: 'schema 驗證',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'a', type: 'Input' },
|
||||||
|
{ id: 'b', type: 'Output' },
|
||||||
|
{ id: 'c', type: 'Output' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'a', to: 'b', type: 'ON_TRUE' },
|
||||||
|
{ from: 'a', to: 'c', type: 'ON_FALSE' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { valid: boolean };
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(data.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
|
||||||
|
*
|
||||||
|
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||||
|
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
|
||||||
|
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
|
||||||
|
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
|
||||||
|
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
|
||||||
|
*/
|
||||||
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||||
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
const CF_API = 'https://api.cloudflare.com';
|
||||||
|
const CREDS_KEY = 'console:credentials';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.activate();
|
||||||
|
fetchMock.disableNetConnect();
|
||||||
|
});
|
||||||
|
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||||
|
|
||||||
|
function json(method: string, path: string, body?: unknown) {
|
||||||
|
return SELF.fetch(`http://localhost${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||||
|
const captured: Array<{ name: string; text: string }> = [];
|
||||||
|
fetchMock
|
||||||
|
.get(CF_API)
|
||||||
|
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||||
|
.reply(200, (opts) => {
|
||||||
|
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||||
|
captured.push(body);
|
||||||
|
return { success: true };
|
||||||
|
})
|
||||||
|
.times(times);
|
||||||
|
return { puts: () => captured };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
|
||||||
|
* 單純為了在測試端準備一筆能通過驗證的 legacy fixture,不是重新實作生產邏輯。 */
|
||||||
|
async function legacyHash(password: string, salt: string): Promise<string> {
|
||||||
|
async function sha256Hex(input: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||||
|
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
let h = `${salt}:${password}`;
|
||||||
|
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL = 'legacy-owner@example.com';
|
||||||
|
const PASSWORD = 'legacy-owner-pw-1';
|
||||||
|
const SALT = 'deadbeef00112233';
|
||||||
|
|
||||||
|
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
|
||||||
|
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
|
||||||
|
const hash = await legacyHash(PASSWORD, SALT);
|
||||||
|
await env.SESSIONS_KV.put(
|
||||||
|
CREDS_KEY,
|
||||||
|
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
|
||||||
|
);
|
||||||
|
const { puts } = mockAuthStoreWrite();
|
||||||
|
|
||||||
|
const res = await json('GET', '/console/auth-status');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
configured: boolean;
|
||||||
|
credentials_source: string;
|
||||||
|
auth_store: { console_configured: boolean };
|
||||||
|
};
|
||||||
|
expect(data.configured).toBe(true);
|
||||||
|
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
|
||||||
|
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成,
|
||||||
|
// 故 authStoreStatus 已經反映搬遷後的狀態
|
||||||
|
expect(data.auth_store.console_configured).toBe(true);
|
||||||
|
|
||||||
|
const shards = puts();
|
||||||
|
expect(shards.length).toBe(1);
|
||||||
|
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
|
||||||
|
expect(shard.console.email).toBe(EMAIL);
|
||||||
|
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
|
||||||
|
});
|
||||||
|
|
||||||
|
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
|
||||||
|
const res = await json('GET', '/console/auth-status');
|
||||||
|
const data = (await res.json()) as { credentials_source: string };
|
||||||
|
expect(data.credentials_source).toBe('secrets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
|
||||||
|
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55)
|
||||||
|
*
|
||||||
|
* 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials`
|
||||||
|
* (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失
|
||||||
|
* (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。
|
||||||
|
* D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。
|
||||||
|
*
|
||||||
|
* 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋):
|
||||||
|
* 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。
|
||||||
|
* 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。
|
||||||
|
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版
|
||||||
|
* 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。
|
||||||
|
* 4. 登入對錯:帳密正確 200;密碼錯 401。
|
||||||
|
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。
|
||||||
|
*
|
||||||
|
* 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`(PUT),走 fetchMock 假 host
|
||||||
|
* 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設
|
||||||
|
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。
|
||||||
|
*
|
||||||
|
* ⚠️ 測試順序不可打亂:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||||
|
* 一旦某則測試讓 /console/setup 或 reset 真的寫成功,overlay.console 就會在**這支檔案**
|
||||||
|
* 剩下的測試裡持續存在(同檔案不會在測試之間重置模組全域,只有 KV/D1 等 storage 才有
|
||||||
|
* isolatedStorage 重置)。因此本檔刻意排成一條線性故事:先驗證「全新、尚未設定」的分支,
|
||||||
|
* 再做一次成功的 /console/setup(之後永久變成「已設定」),後面的測試都建立在這個已設定
|
||||||
|
* 的基礎上。「帳密只存在舊 KV(尚未搬遷過)」這個分支需要 overlay 是空的,因此另開一支
|
||||||
|
* 檔案 tests/console-auth-legacy.test.ts(不同檔案=不同 worker 執行個體,狀態不互相污染)。
|
||||||
|
*/
|
||||||
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||||
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
const CF_API = 'https://api.cloudflare.com';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.activate();
|
||||||
|
fetchMock.disableNetConnect();
|
||||||
|
});
|
||||||
|
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||||
|
|
||||||
|
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||||
|
return SELF.fetch(`http://localhost${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */
|
||||||
|
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||||
|
const captured: Array<{ name: string; text: string }> = [];
|
||||||
|
fetchMock
|
||||||
|
.get(CF_API)
|
||||||
|
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||||
|
.reply(200, (opts) => {
|
||||||
|
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||||
|
captured.push(body);
|
||||||
|
return { success: true };
|
||||||
|
})
|
||||||
|
.times(times);
|
||||||
|
return { puts: () => captured };
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER_EMAIL = 'owner@example.com';
|
||||||
|
const OWNER_PW = 'owner-first-pw-1';
|
||||||
|
|
||||||
|
// ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════
|
||||||
|
|
||||||
|
describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||||
|
it('GET /console/auth-status → configured:false,不洩漏 email', async () => {
|
||||||
|
const res = await json('GET', '/console/auth-status');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { present: boolean } };
|
||||||
|
expect(data.configured).toBe(false);
|
||||||
|
expect(data.credentials_source).toBe('none');
|
||||||
|
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
|
||||||
|
const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const data = (await res.json()) as { code: string; error: string };
|
||||||
|
expect(data.code).toBe('auth_store_empty');
|
||||||
|
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /console/setup/reset(還沒設定過就想換密碼)→ 400,叫去用 /console/setup', async () => {
|
||||||
|
const res = await json('POST', '/console/setup/reset', {
|
||||||
|
current_password: 'whatever', email: 'x@y.co', password: 'newpassword1',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
|
||||||
|
|
||||||
|
describe('POST /console/setup — 首次設定', () => {
|
||||||
|
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
|
||||||
|
const { puts } = mockAuthStoreWrite();
|
||||||
|
const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean; session_token: string; tenant: string };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(typeof data.session_token).toBe('string');
|
||||||
|
|
||||||
|
// 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地
|
||||||
|
const shards = puts();
|
||||||
|
expect(shards.length).toBe(1);
|
||||||
|
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||||
|
expect(shards[0].text).not.toContain(OWNER_PW);
|
||||||
|
const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } };
|
||||||
|
expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫
|
||||||
|
expect(typeof shard.console.salt).toBe('string');
|
||||||
|
expect(typeof shard.console.hash).toBe('string');
|
||||||
|
|
||||||
|
// D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發)
|
||||||
|
expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
|
||||||
|
|
||||||
|
describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||||
|
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
|
||||||
|
const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
error: string; code: string; password_applied: boolean; reset_path: string;
|
||||||
|
};
|
||||||
|
expect(data.code).toBe('already_configured');
|
||||||
|
expect(data.password_applied).toBe(false);
|
||||||
|
expect(data.error).toContain('沒有被採用');
|
||||||
|
expect(data.reset_path).toBe('/console/setup/reset');
|
||||||
|
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /console/auth-status → configured:true,credentials_source:secrets(新家優先命中)', async () => {
|
||||||
|
const res = await json('GET', '/console/auth-status');
|
||||||
|
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
|
||||||
|
expect(data.configured).toBe(true);
|
||||||
|
expect(data.credentials_source).toBe('secrets');
|
||||||
|
expect(data.auth_store.console_configured).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
|
||||||
|
|
||||||
|
describe('POST /console/login', () => {
|
||||||
|
it('帳密正確 → 200,發 session token', async () => {
|
||||||
|
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(typeof data.session_token).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('密碼錯 → 401', async () => {
|
||||||
|
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => {
|
||||||
|
const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════
|
||||||
|
|
||||||
|
describe('POST /console/setup/reset', () => {
|
||||||
|
const NEW_PW = 'brand-new-owner-pw-1';
|
||||||
|
|
||||||
|
it('舊密碼錯 → 401,不寫入', async () => {
|
||||||
|
const res = await json('POST', '/console/setup/reset', {
|
||||||
|
current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||||
|
const { puts } = mockAuthStoreWrite();
|
||||||
|
const res = await json('POST', '/console/setup/reset', {
|
||||||
|
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
|
||||||
|
const shards = puts();
|
||||||
|
expect(shards.length).toBe(1);
|
||||||
|
expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地
|
||||||
|
const shard = JSON.parse(shards[0].text) as { console: { email: string } };
|
||||||
|
expect(shard.console.email).toBe(OWNER_EMAIL);
|
||||||
|
|
||||||
|
// 舊密碼立即失效
|
||||||
|
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||||
|
expect(oldLogin.status).toBe(401);
|
||||||
|
// 新密碼生效
|
||||||
|
const newLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: NEW_PW });
|
||||||
|
expect(newLogin.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,110 +1,260 @@
|
|||||||
/**
|
/**
|
||||||
* credential 治理端點測試。
|
* credentials 路由測試(D38 圍牆修復後補寫,2026-08-08)
|
||||||
*
|
*
|
||||||
* 範圍限制(誠實記錄,非本檔缺陷):`putWorkerSecret` / `deleteWorkerSecret` 呼叫真實
|
* 前身是刻意留紅的 placeholder(見 git history):2026-08-07 D38 把 credential 目錄從
|
||||||
* Cloudflare API(`fetch` 到 api.cloudflare.com)。測試環境(wrangler.test.toml)刻意不設
|
* 「獨立 credentials 表 + 原生 SQL」改成「KBDB entries(entry_type='credential')+
|
||||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID,所以本檔只覆蓋「不需要真的打 CF API」的路徑:
|
* HTTP API」,舊測試全部作廢,agent 中途被中斷沒補上,故意留一個會失敗的測試佔位、
|
||||||
* - D1-only 的 GET /credentials、/credentials/catalog
|
* 避免「no tests」被誤讀成「通過」。本檔依 placeholder 頭部列的五項補齊。
|
||||||
* - DELETE 在 D1 無 row 時 fallback 刪舊 KV(不會走到 deleteWorkerSecret)
|
*
|
||||||
* 真正打 CF Workers Secrets API 成功寫入/刪除的路徑,由部署到 leo21c 帳號後的端到端
|
* 測試手法比照姊妹模組 execution-logger.test.ts:`vi.stubGlobal('fetch', ...)` 攔截,
|
||||||
* curl 驗證覆蓋(見 credential-store-migration.md T8/T9 完成記錄)。
|
* 但這裡的攔截器是**有狀態的假 KBDB**(in-memory entries store),因為 credentials.ts
|
||||||
|
* 一次操作常涉及多輪 HTTP 呼叫(find → upsert / find → delete),單次回應的 mock 測不出
|
||||||
|
* 「查得到剛寫的」「刪掉後真的查不到」這類語意,需要一個會記狀態的假後端。
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { env, SELF } from 'cloudflare:test';
|
import { Hono } from 'hono';
|
||||||
|
import { credentialsRouter, getCredentialSecretRefs, hasCredential, invalidateCredentialCache } from '../src/routes/credentials';
|
||||||
|
import type { Bindings } from '../src/types';
|
||||||
|
// Workers runtime(@cloudflare/vitest-pool-workers)沒有 node:fs——原始碼掃描改用 Vite 的
|
||||||
|
// `?raw` import 取字串內容(build-time 讀檔,runtime 是純字串,不受 Workers 限制)。
|
||||||
|
// @ts-expect-error -- vite ?raw 型別由 tsconfig 的 vite/client 提供,非本檔關注重點
|
||||||
|
import credentialsSource from '../src/routes/credentials.ts?raw';
|
||||||
|
|
||||||
const API_KEY = 'test-tenant-t89';
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
async function insertCredentialRow(
|
// ── 有狀態假 KBDB:只實作 credentials.ts 實際會打的四個操作(GET list/find, POST, PATCH, DELETE)──
|
||||||
name: string,
|
interface FakeEntry {
|
||||||
secretRef: string,
|
id: string;
|
||||||
extra: Partial<{ service: string | null; sensitivity: string; last_used_at: number | null }> = {},
|
entry_type: string;
|
||||||
): Promise<void> {
|
owner_id: string;
|
||||||
await env.CREDENTIALS_DB
|
page_name: string;
|
||||||
.prepare(
|
metadata_json: string;
|
||||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
created_at: number;
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
API_KEY,
|
|
||||||
name,
|
|
||||||
extra.service ?? null,
|
|
||||||
extra.sensitivity ?? 'standard',
|
|
||||||
secretRef,
|
|
||||||
Math.floor(Date.now() / 1000),
|
|
||||||
extra.last_used_at ?? null,
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearTenantRows(): Promise<void> {
|
function makeFakeKbdb() {
|
||||||
await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run();
|
const entries: FakeEntry[] = [];
|
||||||
|
let idSeq = 0;
|
||||||
|
const secretsStore = new Map<string, string>(); // secretRef -> plaintext(模擬 CF Workers Secrets,唯寫,測試用來斷言「有沒有被塞值」)
|
||||||
|
const secretPuts: Array<{ name: string; text: string }> = [];
|
||||||
|
const secretDeletes: string[] = [];
|
||||||
|
const kbdbRequests: Array<{ method: string; url: string; body: unknown }> = [];
|
||||||
|
|
||||||
|
async function handle(url: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const method = (init.method ?? 'GET').toUpperCase();
|
||||||
|
const u = new URL(url);
|
||||||
|
|
||||||
|
// CF Workers Scripts secrets 管理 API(唯寫,讀不回值)
|
||||||
|
if (u.hostname === 'api.cloudflare.com') {
|
||||||
|
if (method === 'PUT' && u.pathname.endsWith('/secrets')) {
|
||||||
|
const body = JSON.parse(String(init.body)) as { name: string; text: string };
|
||||||
|
secretsStore.set(body.name, body.text);
|
||||||
|
secretPuts.push(body);
|
||||||
|
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (method === 'DELETE' && u.pathname.includes('/secrets/')) {
|
||||||
|
const name = u.pathname.split('/secrets/')[1];
|
||||||
|
secretsStore.delete(name);
|
||||||
|
secretDeletes.push(name);
|
||||||
|
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||||
|
}
|
||||||
|
throw new Error(`unhandled CF API call: ${method} ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KBDB entries API
|
||||||
|
kbdbRequests.push({ method, url, body: init.body ? JSON.parse(String(init.body)) : undefined });
|
||||||
|
|
||||||
|
if (method === 'POST' && u.pathname === '/entries') {
|
||||||
|
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
|
||||||
|
const entry: FakeEntry = {
|
||||||
|
id: `e_${++idSeq}`,
|
||||||
|
entry_type: body.entry_type!,
|
||||||
|
owner_id: body.owner_id!,
|
||||||
|
page_name: body.page_name!,
|
||||||
|
metadata_json: body.metadata_json!,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
entries.push(entry);
|
||||||
|
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && u.pathname === '/entries') {
|
||||||
|
const ownerId = u.searchParams.get('owner_id');
|
||||||
|
const entryType = u.searchParams.get('entry_type');
|
||||||
|
const pageName = u.searchParams.get('page_name');
|
||||||
|
let rows = entries.filter((e) => e.entry_type === entryType && e.owner_id === ownerId);
|
||||||
|
if (pageName) rows = rows.filter((e) => e.page_name === pageName);
|
||||||
|
return new Response(JSON.stringify({ success: true, entries: rows, count: rows.length }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'PATCH' && u.pathname.startsWith('/entries/')) {
|
||||||
|
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
|
||||||
|
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return new Response(JSON.stringify({ success: false }), { status: 404 });
|
||||||
|
if (body.metadata_json !== undefined) entry.metadata_json = body.metadata_json;
|
||||||
|
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'DELETE' && u.pathname.startsWith('/entries/')) {
|
||||||
|
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
|
||||||
|
const idx = entries.findIndex((e) => e.id === id);
|
||||||
|
if (idx === -1) return new Response(JSON.stringify({ success: false }), { status: 404 });
|
||||||
|
entries.splice(idx, 1); // 真的從陣列移除,不是標記
|
||||||
|
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`unhandled KBDB call: ${method} ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url: string, init?: RequestInit) => handle(url, init)));
|
||||||
|
|
||||||
|
return { entries, secretsStore, secretPuts, secretDeletes, kbdbRequests };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('GET /credentials (D1, T9)', () => {
|
function fakeEnv(): Bindings {
|
||||||
beforeEach(clearTenantRows);
|
return {
|
||||||
|
KBDB_BASE_URL: 'https://kbdb.test',
|
||||||
|
CF_SECRETS_API_TOKEN: 'fake-cf-token',
|
||||||
|
CF_ACCOUNT_ID: 'fake-account',
|
||||||
|
ENVIRONMENT: 'test',
|
||||||
|
CREDENTIALS_KV: { delete: vi.fn(async () => {}) } as unknown as KVNamespace,
|
||||||
|
} as unknown as Bindings;
|
||||||
|
}
|
||||||
|
|
||||||
it('缺 X-Arcrun-API-Key → 401', async () => {
|
function app() {
|
||||||
const res = await SELF.fetch('https://cypher.test/credentials');
|
const a = new Hono<{ Bindings: Bindings }>();
|
||||||
expect(res.status).toBe(401);
|
a.route('/', credentialsRouter);
|
||||||
});
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
it('無資料 → 空陣列(非拋錯)', async () => {
|
beforeEach(() => {
|
||||||
const res = await SELF.fetch('https://cypher.test/credentials', {
|
invalidateCredentialCache('tenant-a');
|
||||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
invalidateCredentialCache('tenant-b');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離)', () => {
|
||||||
|
it('POST /credentials 寫入後,entries 裡的 owner_id 就是呼叫者的 api_key', async () => {
|
||||||
|
const fake = makeFakeKbdb();
|
||||||
|
const env = fakeEnv();
|
||||||
|
const a = app();
|
||||||
|
const res = await a.request('/credentials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'telegram_bot_token', value: 'secret-plaintext-value', service: 'telegram' }),
|
||||||
|
}, env);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json() as { success: boolean; credentials: unknown[]; total: number };
|
const body = (await res.json()) as { success: boolean };
|
||||||
expect(body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
expect(body.credentials).toEqual([]);
|
expect(fake.entries).toHaveLength(1);
|
||||||
expect(body.total).toBe(0);
|
expect(fake.entries[0].owner_id).toBe('tenant-a');
|
||||||
|
expect(fake.entries[0].page_name).toBe('telegram_bot_token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('回傳 metadata,絕不含 secret_ref 或值', async () => {
|
it('兩個不同 api_key 各自建立的同名 credential 落在不同 owner_id、互不覆蓋', async () => {
|
||||||
await insertCredentialRow('telegram_bot_token', 'CRED_TELEGRAM_BOT_TOKEN_ABCDEF01', { service: 'telegram' });
|
const fake = makeFakeKbdb();
|
||||||
const res = await SELF.fetch('https://cypher.test/credentials', {
|
const env = fakeEnv();
|
||||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
const a = app();
|
||||||
});
|
await a.request('/credentials', {
|
||||||
const body = await res.json() as { success: boolean; credentials: Array<Record<string, unknown>> };
|
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||||
expect(body.success).toBe(true);
|
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
|
||||||
expect(body.credentials).toHaveLength(1);
|
}, env);
|
||||||
const row = body.credentials[0];
|
await a.request('/credentials', {
|
||||||
expect(row.name).toBe('telegram_bot_token');
|
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
|
||||||
expect(row.service).toBe('telegram');
|
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-b' }),
|
||||||
expect(row).not.toHaveProperty('secret_ref');
|
}, env);
|
||||||
expect(row).not.toHaveProperty('value');
|
expect(fake.entries).toHaveLength(2);
|
||||||
expect(JSON.stringify(row)).not.toMatch(/CRED_/);
|
const owners = fake.entries.map((e) => e.owner_id).sort();
|
||||||
});
|
expect(owners).toEqual(['tenant-a', 'tenant-b']);
|
||||||
|
|
||||||
it('/credentials/catalog 回同一份資料(Console 相容別名)', async () => {
|
|
||||||
await insertCredentialRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01');
|
|
||||||
const [listRes, catalogRes] = await Promise.all([
|
|
||||||
SELF.fetch('https://cypher.test/credentials', { headers: { 'X-Arcrun-API-Key': API_KEY } }),
|
|
||||||
SELF.fetch('https://cypher.test/credentials/catalog', { headers: { 'X-Arcrun-API-Key': API_KEY } }),
|
|
||||||
]);
|
|
||||||
const [listBody, catalogBody] = await Promise.all([listRes.json(), catalogRes.json()]) as Array<{
|
|
||||||
credentials: Array<{ name: string }>;
|
|
||||||
}>;
|
|
||||||
expect(listBody.credentials.map(r => r.name)).toEqual(catalogBody.credentials.map(r => r.name));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DELETE /credentials/:name (T9)', () => {
|
describe('2. 讀取查得回 secret_ref,且查不到別的租戶的', () => {
|
||||||
beforeEach(clearTenantRows);
|
it('getCredentialSecretRefs 回該租戶的 name→secret_ref 對照,不含其他租戶的', async () => {
|
||||||
|
makeFakeKbdb();
|
||||||
|
const env = fakeEnv();
|
||||||
|
const a = app();
|
||||||
|
await a.request('/credentials', {
|
||||||
|
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
|
||||||
|
}, env);
|
||||||
|
await a.request('/credentials', {
|
||||||
|
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'other_key', value: 'value-b' }),
|
||||||
|
}, env);
|
||||||
|
|
||||||
it('D1 無 row(從未回填)→ fallback 刪舊 KV,不誤報找不到', async () => {
|
const refsA = await getCredentialSecretRefs(env, 'tenant-a');
|
||||||
await env.CREDENTIALS_KV.put(
|
expect(Object.keys(refsA)).toEqual(['gemini_api_key']);
|
||||||
`${API_KEY}:cred:legacy_only`,
|
expect(refsA.gemini_api_key).toMatch(/^CRED_GEMINI_API_KEY_/);
|
||||||
JSON.stringify({ encrypted: 'x', iv: 'y' }),
|
expect(refsA.other_key).toBeUndefined(); // 查不到別租戶的
|
||||||
);
|
|
||||||
const res = await SELF.fetch('https://cypher.test/credentials/legacy_only', {
|
const refsB = await getCredentialSecretRefs(env, 'tenant-b');
|
||||||
method: 'DELETE',
|
expect(Object.keys(refsB)).toEqual(['other_key']);
|
||||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
});
|
||||||
});
|
|
||||||
const body = await res.json() as { success: boolean; source: string };
|
it('hasCredential:查得到自己的,查不到別租戶的同名 credential', async () => {
|
||||||
expect(res.status).toBe(200);
|
makeFakeKbdb();
|
||||||
expect(body.success).toBe(true);
|
const env = fakeEnv();
|
||||||
expect(body.source).toBe('legacy-kv');
|
const a = app();
|
||||||
const raw = await env.CREDENTIALS_KV.get(`${API_KEY}:cred:legacy_only`);
|
await a.request('/credentials', {
|
||||||
expect(raw).toBeNull();
|
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'kbdb_internal_token', value: 'v' }),
|
||||||
|
}, env);
|
||||||
|
expect(await hasCredential(env, 'tenant-a', 'kbdb_internal_token')).toBe(true);
|
||||||
|
expect(await hasCredential(env, 'tenant-b', 'kbdb_internal_token')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('3. 刪除是真的刪(不是 deprecated 標記)', () => {
|
||||||
|
it('DELETE /credentials/:name 後,該筆 entries row 從 KBDB 消失(不是 metadata 打 deprecated 標記)', async () => {
|
||||||
|
const fake = makeFakeKbdb();
|
||||||
|
const env = fakeEnv();
|
||||||
|
const a = app();
|
||||||
|
await a.request('/credentials', {
|
||||||
|
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'to_delete', value: 'v' }),
|
||||||
|
}, env);
|
||||||
|
expect(fake.entries).toHaveLength(1);
|
||||||
|
|
||||||
|
const res = await a.request('/credentials/to_delete', {
|
||||||
|
method: 'DELETE', headers: { 'X-Arcrun-API-Key': 'tenant-a' },
|
||||||
|
}, env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { success: boolean; source: string };
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.source).toBe('workers-secrets');
|
||||||
|
|
||||||
|
// 真的從陣列移除,不是留著、metadata 打上 status:deprecated
|
||||||
|
expect(fake.entries).toHaveLength(0);
|
||||||
|
// Workers Secret 本體也真的被刪(DELETE 呼叫過),不是只刪目錄留孤兒密文
|
||||||
|
expect(fake.secretDeletes.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('4. 零原生 SQL:整支檔案不得出現 .prepare/.exec/.batch', () => {
|
||||||
|
it('routes/credentials.ts 原始碼掃描:沒有任何 D1 原生呼叫語法', () => {
|
||||||
|
expect(/\.\s*(prepare|exec|batch)\s*\(/.test(credentialsSource)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變', () => {
|
||||||
|
it('送去 KBDB 的 body 裡從頭到尾沒有明文 credential value,只有 secret_ref', async () => {
|
||||||
|
const fake = makeFakeKbdb();
|
||||||
|
const env = fakeEnv();
|
||||||
|
const a = app();
|
||||||
|
const plaintext = 'super-secret-plaintext-should-never-leave-workers-secrets';
|
||||||
|
await a.request('/credentials', {
|
||||||
|
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'sensitive_key', value: plaintext }),
|
||||||
|
}, env);
|
||||||
|
|
||||||
|
// 明文只出現在 CF Workers Secrets 的 PUT(唯寫 API),不出現在任何打去 KBDB 的請求 body 裡
|
||||||
|
expect(fake.secretPuts.some((p) => p.text === plaintext)).toBe(true);
|
||||||
|
for (const req of fake.kbdbRequests) {
|
||||||
|
expect(JSON.stringify(req.body ?? '')).not.toContain(plaintext);
|
||||||
|
}
|
||||||
|
// entries 裡存的是 secret_ref 指標,不是值
|
||||||
|
expect(fake.entries[0].metadata_json).not.toContain(plaintext);
|
||||||
|
expect(fake.entries[0].metadata_json).toContain('secret_ref');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* execution-logger 測試(KV 額度事故修復,2026-08-07)
|
||||||
|
*
|
||||||
|
* KBDB=API-as-Wall(leo 2026-06-14):cypher-executor 端不直連任何 D1,一律 fire-and-forget
|
||||||
|
* fetch KBDB `/execution-log/record`。本檔驗的是「cypher 這一側」的職責,測試手法比照姊妹模組
|
||||||
|
* execution-evaluator.test.ts(recordComponentStats,同款「fire-and-forget POST 統計」):
|
||||||
|
* `vi.stubGlobal('fetch', ...)` 直接攔截,不用 fetchMock。
|
||||||
|
* 1. 送出的 payload 形狀正確(workflow_id/owner_id/verdict/duration_ms/message/target)
|
||||||
|
* 2. target 從 trigger context 的 page_name/path 擷取(少記:不整包送 input)
|
||||||
|
* 3. 任何錯誤(fetch reject、KBDB 回非 2xx)都不影響呼叫端(永不 throw)
|
||||||
|
*
|
||||||
|
* 「少記截斷長度」「A2 自我降級」的實際邏輯與驗證在 KBDB 端(kbdb/tests/execution-log.test.ts),
|
||||||
|
* 因為決策/儲存都搬到 KBDB 做了,cypher 只是薄殼呼叫端。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { writeExecutionVerdict } from '../src/actions/execution-logger';
|
||||||
|
import type { Bindings } from '../src/types';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function fakeEnv(): Bindings {
|
||||||
|
return {
|
||||||
|
KBDB_BASE_URL: 'https://kbdb.test',
|
||||||
|
ENVIRONMENT: 'test',
|
||||||
|
} as unknown as Bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetchCapture(): { calls: Array<{ url: string; body: Record<string, unknown> }> } {
|
||||||
|
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
||||||
|
calls.push({ url: String(url), body: JSON.parse(String(init.body)) });
|
||||||
|
return new Response(JSON.stringify({ success: true, written: true, mode: 'log' }), { status: 200 });
|
||||||
|
}));
|
||||||
|
return { calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('writeExecutionVerdict — 送出正確 payload(少記,不整包 input)', () => {
|
||||||
|
it('成功:POST 到 KBDB_BASE_URL/execution-log/record,帶 workflow_id/owner_id/verdict/duration_ms/message', async () => {
|
||||||
|
const { calls } = stubFetchCapture();
|
||||||
|
await writeExecutionVerdict(
|
||||||
|
fakeEnv(), 'wf-1', [], 'success', 123, '執行完成', { page_name: 'a.md' }, 'ak_test',
|
||||||
|
);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].url).toBe('https://kbdb.test/execution-log/record');
|
||||||
|
expect(calls[0].body).toEqual({
|
||||||
|
workflow_id: 'wf-1',
|
||||||
|
owner_id: 'ak_test',
|
||||||
|
verdict: 'success',
|
||||||
|
duration_ms: 123,
|
||||||
|
message: '執行完成',
|
||||||
|
target: 'a.md',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('target:page_name 優先,沒有時 fallback path;都沒有則為 null', async () => {
|
||||||
|
const { calls: calls1 } = stubFetchCapture();
|
||||||
|
await writeExecutionVerdict(fakeEnv(), 'wf-2', [], 'failed', 1, 'err', { path: 'docs/x.md' });
|
||||||
|
expect(calls1[0].body.target).toBe('docs/x.md');
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
const { calls: calls2 } = stubFetchCapture();
|
||||||
|
await writeExecutionVerdict(fakeEnv(), 'wf-3', [], 'failed', 1, 'err', undefined);
|
||||||
|
expect(calls2[0].body.target).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不整包送 input:巨大的無關欄位不會出現在送出的 payload 裡', async () => {
|
||||||
|
const { calls } = stubFetchCapture();
|
||||||
|
await writeExecutionVerdict(fakeEnv(), 'wf-4', [], 'failed', 1, 'err', {
|
||||||
|
page_name: 'a.md',
|
||||||
|
unrelated_huge_field: 'z'.repeat(10000),
|
||||||
|
});
|
||||||
|
expect(Object.keys(calls[0].body).sort()).toEqual(
|
||||||
|
['duration_ms', 'message', 'owner_id', 'target', 'verdict', 'workflow_id'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒有 apiKey(/execute 舊路徑):owner_id 送 null,不炸', async () => {
|
||||||
|
const { calls } = stubFetchCapture();
|
||||||
|
await writeExecutionVerdict(fakeEnv(), 'wf-5', [], 'success', 1, 'ok');
|
||||||
|
expect(calls[0].body.owner_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeExecutionVerdict — 記錄失敗不影響主流程(永不 throw)', () => {
|
||||||
|
it('KBDB 端點連不上(fetch reject):函式仍正常 resolve', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); }));
|
||||||
|
await expect(
|
||||||
|
writeExecutionVerdict(fakeEnv(), 'wf-broken', [], 'failed', 1, '任何訊息'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('KBDB 回非 2xx(例如額度打滿的 5xx):函式仍正常 resolve', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ success: false, error: 'quota exceeded' }), { status: 500 }),
|
||||||
|
));
|
||||||
|
await expect(
|
||||||
|
writeExecutionVerdict(fakeEnv(), 'wf-broken2', [], 'failed', 1, '任何訊息'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* GET /workflows/:name/executions — KV 額度事故修復(2026-08-07)路由測試。
|
||||||
|
* 改打 KBDB GET /execution-log(原走 ANALYTICS_KV list);KBDB=API-as-Wall,
|
||||||
|
* 本檔一律 fetchMock 攔截,不碰任何 D1(比照 tests/portal-data.test.ts 慣例)。
|
||||||
|
*/
|
||||||
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||||
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
const KBDB = 'https://kbdb.test'; // wrangler.test.toml KBDB_BASE_URL
|
||||||
|
const API_KEY = 'ak_exec_test';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.activate();
|
||||||
|
fetchMock.disableNetConnect();
|
||||||
|
});
|
||||||
|
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||||
|
|
||||||
|
function get(path: string, headers: Record<string, string> = {}) {
|
||||||
|
return SELF.fetch(`http://localhost${path}`, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /workflows/:name/executions', () => {
|
||||||
|
it('缺 X-Arcrun-API-Key → 401,不打 KBDB', async () => {
|
||||||
|
const res = await get('/workflows/wf-x/executions');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow 不存在或不屬於該 api_key → 404,不打 KBDB', async () => {
|
||||||
|
const res = await get('/workflows/nope/executions', { 'X-Arcrun-API-Key': API_KEY });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('workflow 存在 → 轉發打 KBDB GET /execution-log,回傳其 executions', async () => {
|
||||||
|
await env.WEBHOOKS.put(
|
||||||
|
`${API_KEY}:wf:daily_report`,
|
||||||
|
JSON.stringify({ graph: { id: 'daily_report', nodes: [] }, description: 'x', created_at: '2026-08-07T00:00:00Z' }),
|
||||||
|
);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({
|
||||||
|
path: (p: string) => p.startsWith('/execution-log?'),
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
.reply(200, {
|
||||||
|
success: true,
|
||||||
|
executions: [
|
||||||
|
{ verdict: 'success', duration_ms: 100, message: 'ok', recorded_at: 1783500000 },
|
||||||
|
{ verdict: 'failed', duration_ms: 50, message: '找不到 workflow', target: 'a.md', recorded_at: 1783400000 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await get('/workflows/daily_report/executions', { 'X-Arcrun-API-Key': API_KEY });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as {
|
||||||
|
ok: boolean;
|
||||||
|
data: { workflow_name: string; count: number; executions: Array<{ verdict: string; target?: string }> };
|
||||||
|
};
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.count).toBe(2);
|
||||||
|
expect(body.data.executions[0].verdict).toBe('success');
|
||||||
|
expect(body.data.executions[1].target).toBe('a.md');
|
||||||
|
|
||||||
|
await env.WEBHOOKS.delete(`${API_KEY}:wf:daily_report`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('KBDB 回非 success(例如全降級停記錄後空清單)→ 誠實回空陣列,不是假資料', async () => {
|
||||||
|
await env.WEBHOOKS.put(
|
||||||
|
`${API_KEY}:wf:empty_wf`,
|
||||||
|
JSON.stringify({ graph: { id: 'empty_wf', nodes: [] }, description: 'x', created_at: '2026-08-07T00:00:00Z' }),
|
||||||
|
);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/execution-log?'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, executions: [] });
|
||||||
|
|
||||||
|
const res = await get('/workflows/empty_wf/executions', { 'X-Arcrun-API-Key': API_KEY });
|
||||||
|
const body = await res.json() as { data: { count: number; executions: unknown[] } };
|
||||||
|
expect(body.data.count).toBe(0);
|
||||||
|
expect(body.data.executions).toEqual([]);
|
||||||
|
|
||||||
|
await env.WEBHOOKS.delete(`${API_KEY}:wf:empty_wf`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Cypher Executor 端到端測試
|
// Cypher Executor 端到端測試
|
||||||
import { SELF } from 'cloudflare:test';
|
import { SELF } from 'cloudflare:test';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { GraphExecutor } from '../src/graph-executor';
|
||||||
|
import type { ComponentRunner, ExecutionGraph } from '../src/types';
|
||||||
|
|
||||||
describe('GET /', () => {
|
describe('GET /', () => {
|
||||||
it('回傳服務狀態', async () => {
|
it('回傳服務狀態', async () => {
|
||||||
@@ -191,4 +193,141 @@ describe('POST /execute', () => {
|
|||||||
});
|
});
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status code(GraphExecutor 單元測試)
|
||||||
|
describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => {
|
||||||
|
it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => {
|
||||||
|
// mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"}
|
||||||
|
const failLoader = async (_: string): Promise<ComponentRunner> =>
|
||||||
|
async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } });
|
||||||
|
|
||||||
|
const executor = new GraphExecutor(failLoader);
|
||||||
|
|
||||||
|
const graph: ExecutionGraph = {
|
||||||
|
id: 'foreach-fail-t117',
|
||||||
|
name: 'FOREACH 全失敗',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
|
||||||
|
{ id: 'writer', type: 'Component', componentId: 'http_request' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// t117 核心驗證:全部失敗 → throw(不再靜默)
|
||||||
|
await expect(executor.execute(graph, {})).rejects.toThrow(/401/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
// 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑)
|
||||||
|
const mixedLoader = async (_: string): Promise<ComponentRunner> =>
|
||||||
|
async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' };
|
||||||
|
return { success: true, data: { ok: true } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const executor = new GraphExecutor(mixedLoader);
|
||||||
|
|
||||||
|
const graph: ExecutionGraph = {
|
||||||
|
id: 'foreach-mixed-t117',
|
||||||
|
name: 'FOREACH 部分失敗',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
|
||||||
|
{ id: 'writer', type: 'Component', componentId: 'http_request' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 部分失敗 → 不拋出,正常回傳 results 陣列
|
||||||
|
const result = await executor.execute(graph, {});
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// P8 短板齊平(2026-08-09):節點輸出只在「下游有 PIPE 邊會讀」時才寫 KV。
|
||||||
|
// 背景:BUILD-006 原本每個節點(含 FOREACH 每一圈)都 put 一次 EXEC_CONTEXT,
|
||||||
|
// 但全 codebase 唯一讀點是 PIPE 邊的 kvGetNodeOutput——rag 系工作流
|
||||||
|
// (ON_SUCCESS+對每個)一張卡白燒 15 次 KV write,把免費層 1,000/日
|
||||||
|
// 壓成比 Workers AI neurons 更短的板。此測試鎖住「無 PIPE 出邊=零 KV put」
|
||||||
|
// 與「有 PIPE 出邊=照舊寫、_kv_outputs 照舊可讀」兩個行為。
|
||||||
|
describe('P8:節點輸出 KV 寫入只服務 PIPE 讀者', () => {
|
||||||
|
// 計數型 KV mock:只記 put 次數(kvSetNodeOutput 只用到 put;get 給 PIPE 讀)
|
||||||
|
function countingKv() {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
let puts = 0;
|
||||||
|
const kv = {
|
||||||
|
put: async (k: string, v: string) => { puts++; store.set(k, v); },
|
||||||
|
get: async (k: string) => store.get(k) ?? null,
|
||||||
|
} as unknown as KVNamespace;
|
||||||
|
return { kv, getPuts: () => puts };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ON_SUCCESS+FOREACH 工作流(rag_ingest_card 形狀)→ 零 KV put', async () => {
|
||||||
|
const loader = async (id: string): Promise<ComponentRunner> => async () => {
|
||||||
|
if (id === 'parse') {
|
||||||
|
return { success: true, blocks: [{ n: 1 }, { n: 2 }, { n: 3 }], rels: [{ r: 1 }, { r: 2 }] };
|
||||||
|
}
|
||||||
|
return { success: true, data: { ok: true } };
|
||||||
|
};
|
||||||
|
const executor = new GraphExecutor(loader);
|
||||||
|
const graph: ExecutionGraph = {
|
||||||
|
id: 'p8-no-pipe',
|
||||||
|
name: 'rag 形狀(無 PIPE 邊)',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'input', type: 'Input', data: {} },
|
||||||
|
{ id: 'list_old', type: 'Component', componentId: 'http_request' },
|
||||||
|
{ id: 'parse_card', type: 'Component', componentId: 'parse' },
|
||||||
|
{ id: 'post_block', type: 'Component', componentId: 'http_request' },
|
||||||
|
{ id: 'post_triplet', type: 'Component', componentId: 'http_request' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'input', to: 'list_old', type: 'ON_SUCCESS' },
|
||||||
|
{ from: 'list_old', to: 'parse_card', type: 'ON_SUCCESS' },
|
||||||
|
{ from: 'parse_card', to: 'post_block', type: 'FOREACH', iterator: 'block' },
|
||||||
|
{ from: 'parse_card', to: 'post_triplet', type: 'FOREACH', iterator: 'rel' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { kv, getPuts } = countingKv();
|
||||||
|
const result = await executor.execute(graph, {}, kv);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
// 修法前這裡是 8(list_old + parse_card + 3×post_block + 2×post_triplet + input 不寫)
|
||||||
|
expect(getPuts()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PIPE 工作流 → 照舊寫 KV 且 _kv_outputs 傳遞不變(BUILD-006 語意保留)', async () => {
|
||||||
|
const seen: Record<string, unknown>[] = [];
|
||||||
|
const loader = async (id: string): Promise<ComponentRunner> => async (ctx) => {
|
||||||
|
seen.push(ctx as Record<string, unknown>);
|
||||||
|
return { success: true, data: { from: id } };
|
||||||
|
};
|
||||||
|
const executor = new GraphExecutor(loader);
|
||||||
|
const graph: ExecutionGraph = {
|
||||||
|
id: 'p8-pipe',
|
||||||
|
name: 'PIPE 鏈',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'input', type: 'Input', data: { message: 'hi' } },
|
||||||
|
{ id: 'a', type: 'Component', componentId: 'comp_a' },
|
||||||
|
{ id: 'b', type: 'Component', componentId: 'comp_b' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'input', to: 'a', type: 'PIPE' },
|
||||||
|
{ from: 'a', to: 'b', type: 'PIPE' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { kv, getPuts } = countingKv();
|
||||||
|
const result = await executor.execute(graph, {}, kv);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
// a 有 PIPE 出邊 → 寫;b 沒有出邊 → 不寫(原本 a、b 都寫=2)
|
||||||
|
expect(getPuts()).toBe(1);
|
||||||
|
// 下游 b 收到的 context 帶 _kv_outputs.a(BUILD-006 讀路徑不變)
|
||||||
|
const bCtx = seen[seen.length - 1];
|
||||||
|
expect((bCtx._kv_outputs as Record<string, unknown>)?.a).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SELF } from 'cloudflare:test';
|
||||||
|
import { healthRouter } from '../src/routes/health';
|
||||||
|
import type { Bindings, ExecutionContext } from '../src/types';
|
||||||
|
|
||||||
|
describe('GET /health — bundle_version 欄位', () => {
|
||||||
|
it('無 ARCRUN_BUNDLE_VERSION 時省略該欄(老實例情境)', async () => {
|
||||||
|
// wrangler.test.toml 不設此 var → health.ts 省略 bundle_version 欄位。
|
||||||
|
// daemon 端讀不到該欄=當作空字串=判 stale,對老實例而言**這是正確行為**
|
||||||
|
//(見 health.ts 檔頭註解)。此處驗「省略」而非「回空字串」,與實作對齊。
|
||||||
|
const res = await SELF.fetch('http://localhost/health');
|
||||||
|
const data = await res.json() as { ok: boolean; bundle_version?: string };
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.bundle_version).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => {
|
||||||
|
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '2026-07-28/6d06162' } as unknown as Bindings;
|
||||||
|
const res = await healthRouter.fetch(
|
||||||
|
new Request('http://localhost/health'),
|
||||||
|
fakeEnv,
|
||||||
|
{} as ExecutionContext,
|
||||||
|
);
|
||||||
|
const data = await res.json() as { ok: boolean; bundle_version: string };
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.bundle_version).toBe('2026-07-28/6d06162');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* /init/seed 必須把種子的 3.12 三層欄位原樣寫進 KV —— SDD: workflow-discovery task 3.12/3.13
|
||||||
|
*
|
||||||
|
* 為什麼要有這個測試(別刪):
|
||||||
|
* 3.12 給 `RecipeDefinition` 加了 body_template / response_map / auth / binding_name,
|
||||||
|
* 但 `/init/seed` 當時是**列舉欄位重建** recipe record ⇒ 不在名單上的欄位被靜默吃掉。
|
||||||
|
* 症狀最惡劣的地方在於「哪裡都不會紅」:recipe 查得到、canonical_id 對、endpoint 對,
|
||||||
|
* 只有跑起來像沒設定過(auth 掉了 ⇒ 走 HTTP 路徑去 fetch「@cf/…」這種不是網址的字串)。
|
||||||
|
* 這與 2026-08-02 `syncManifest()` 列舉式重建吃掉 `manifest.daemon` 欄是同一型事故——
|
||||||
|
* 當時的教訓寫著:「**東西還在不在**也要進機械閘」,本檔就是那道閘。
|
||||||
|
*
|
||||||
|
* 範圍:只驗「種子 → KV」這段(純資料搬運)。真的呼叫 Workers AI 由實例端到端驗。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { env, SELF } from 'cloudflare:test';
|
||||||
|
import { API_RECIPE_SEEDS } from '../src/lib/api-recipe-seeds';
|
||||||
|
|
||||||
|
type StoredRecipe = {
|
||||||
|
canonical_id: string;
|
||||||
|
endpoint: string;
|
||||||
|
auth?: string;
|
||||||
|
binding_name?: string;
|
||||||
|
body_template?: Record<string, unknown>;
|
||||||
|
response_map?: { text_path?: string; answer_marker?: string; strip_prefixes?: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function seedThenRead(canonicalId: string): Promise<StoredRecipe> {
|
||||||
|
const res = await SELF.fetch('https://example.com/init/seed', { method: 'POST' });
|
||||||
|
// 測試環境沒有 KBDB binding ⇒ portal template 那段必然失敗、整體回 207(誠實回報,非本測目標)。
|
||||||
|
// 本檔只管 API recipe 那半,所以驗它自己的計數,不驗整體 status。
|
||||||
|
const body = await res.json<{ api_recipes: { seeded: number; failed: number; errors: string[] } }>();
|
||||||
|
expect(body.api_recipes.errors).toEqual([]);
|
||||||
|
expect(body.api_recipes.failed).toBe(0);
|
||||||
|
const uuid = await env.RECIPES.get(`idx:installed:${canonicalId}`);
|
||||||
|
expect(uuid, `${canonicalId} 沒有被 seed 進 KV`).toBeTruthy();
|
||||||
|
return JSON.parse((await env.RECIPES.get(`recipe:${uuid}`))!) as StoredRecipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('/init/seed 不得靜默吃掉 recipe 的 3.12 欄位', () => {
|
||||||
|
it('workers_ai_chat 種子本身宣告齊四個欄位(種子端)', () => {
|
||||||
|
const seed = API_RECIPE_SEEDS.find(s => s.canonical_id === 'workers_ai_chat');
|
||||||
|
expect(seed, 'workers_ai_chat 種子不存在=裝完不會有免金鑰問答').toBeDefined();
|
||||||
|
expect(seed!.auth).toBe('binding');
|
||||||
|
expect(seed!.binding_name).toBe('AI');
|
||||||
|
expect(seed!.endpoint.startsWith('@cf/'), 'binding 型的 endpoint=模型 id').toBe(true);
|
||||||
|
expect(seed!.body_template).toBeDefined();
|
||||||
|
expect(seed!.response_map?.text_path).toBe('response');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seed 之後 KV 裡讀回來的仍帶 auth/binding_name/body_template/response_map(KV 端)', async () => {
|
||||||
|
const stored = await seedThenRead('workers_ai_chat');
|
||||||
|
expect(stored.auth, 'auth 掉了 ⇒ 會被當成 HTTP recipe 去 fetch 一個不是網址的字串').toBe('binding');
|
||||||
|
expect(stored.binding_name).toBe('AI');
|
||||||
|
expect(stored.body_template, 'body_template 掉了 ⇒ 整包 ctx 被當 payload 送給模型').toBeDefined();
|
||||||
|
expect(stored.response_map?.text_path, 'response_map 掉了 ⇒ 下游拿不到 text').toBe('response');
|
||||||
|
expect(stored.response_map?.answer_marker).toBe('【答】');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('既有 HTTP 種子不受影響:沒宣告新欄位就是 undefined,不憑空長出來', async () => {
|
||||||
|
const stored = await seedThenRead('telegram_send');
|
||||||
|
expect(stored.auth).toBeUndefined();
|
||||||
|
expect(stored.binding_name).toBeUndefined();
|
||||||
|
expect(stored.body_template).toBeUndefined();
|
||||||
|
expect(stored.response_map).toBeUndefined();
|
||||||
|
expect(stored.endpoint).toContain('api.telegram.org');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 意圖語法寫得出條件分支 → 編圖帶對邊型與標籤(SDD workflow-discovery 3.11)
|
||||||
|
*
|
||||||
|
* 為什麼補這一支(08-01 施工中自查發現的斷點,差點漏掉):
|
||||||
|
* 引擎支援了 ON_TRUE/ON_FALSE/ON_BRANCH,skill 文件也教了寫法,
|
||||||
|
* 但 `graph-builder` 原本**只認得 `對每個 X` 的參數化 label**,
|
||||||
|
* `ON_BRANCH(branch_active)` 這種帶括號的 label 會落到 `toEdgeType` 的預設值 **PIPE**
|
||||||
|
* ⇒ 「教了語法但引擎不收,而且是靜默的」——比沒做更糟(AI 以為分支了,實際全走同一條)。
|
||||||
|
*
|
||||||
|
* 本檔守的就是「文件教的寫法,編圖真的收得到」這條線。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildExecutionGraph } from '../src/actions/graph-builder';
|
||||||
|
import { parseTriplets, resolveNodeRole } from '../src/actions/triplet-parser';
|
||||||
|
|
||||||
|
/** 把意圖字串編成圖(走 AI 真正會走的那條路:triplets → graph)。
|
||||||
|
* nodeResults 用「全部 found」的最小替身——本檔只驗**邊**的編法,零件解析另有測試。 */
|
||||||
|
function build(triplets: string[]) {
|
||||||
|
const parsed = parseTriplets(triplets)!;
|
||||||
|
const nodeResults: Record<string, { status: 'found'; componentId: string; type: ReturnType<typeof resolveNodeRole> }> = {};
|
||||||
|
for (const name of parsed.nodeNames) {
|
||||||
|
nodeResults[name] = {
|
||||||
|
status: 'found',
|
||||||
|
componentId: name.toLowerCase().replace(/\s+/g, '_'),
|
||||||
|
type: resolveNodeRole(name, parsed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return buildExecutionGraph(parsed, nodeResults as never, 'test-graph', '測試');
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeBetween(graph: ReturnType<typeof build>, from: string, to: string) {
|
||||||
|
return graph.edges.find(e => e.from === from && e.to === to);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('意圖語法:if_control 兩路(ON_TRUE/ON_FALSE)', () => {
|
||||||
|
it('ON_TRUE/ON_FALSE 編成對應邊型,不會退化成 PIPE', () => {
|
||||||
|
const g = build([
|
||||||
|
'input >> ON_SUCCESS >> 判斷有沒有新資料',
|
||||||
|
'判斷有沒有新資料 >> ON_TRUE >> 傳到telegram',
|
||||||
|
'判斷有沒有新資料 >> ON_FALSE >> 結束',
|
||||||
|
]);
|
||||||
|
expect(edgeBetween(g, '判斷有沒有新資料', '傳到telegram')?.type).toBe('ON_TRUE');
|
||||||
|
expect(edgeBetween(g, '判斷有沒有新資料', '結束')?.type).toBe('ON_FALSE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('中文語意詞「成立時」「否則」也編得出來', () => {
|
||||||
|
const g = build([
|
||||||
|
'判斷有沒有新資料 >> 成立時 >> 傳到telegram',
|
||||||
|
'判斷有沒有新資料 >> 否則 >> 結束',
|
||||||
|
]);
|
||||||
|
expect(edgeBetween(g, '判斷有沒有新資料', '傳到telegram')?.type).toBe('ON_TRUE');
|
||||||
|
expect(edgeBetween(g, '判斷有沒有新資料', '結束')?.type).toBe('ON_FALSE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('意圖語法:switch 具名分支(ON_BRANCH(標籤))', () => {
|
||||||
|
it('括號裡的標籤被抽成 edge.branch,型別是 ON_BRANCH', () => {
|
||||||
|
const g = build([
|
||||||
|
'my_switch >> ON_BRANCH(branch_active) >> 處理啟用',
|
||||||
|
'my_switch >> ON_BRANCH(branch_pending) >> 處理待辦',
|
||||||
|
'my_switch >> ON_BRANCH(branch_default) >> 其他',
|
||||||
|
]);
|
||||||
|
const active = edgeBetween(g, 'my_switch', '處理啟用');
|
||||||
|
expect(active?.type).toBe('ON_BRANCH');
|
||||||
|
expect(active?.branch).toBe('branch_active');
|
||||||
|
|
||||||
|
const pending = edgeBetween(g, 'my_switch', '處理待辦');
|
||||||
|
expect(pending?.branch).toBe('branch_pending');
|
||||||
|
|
||||||
|
const dflt = edgeBetween(g, 'my_switch', '其他');
|
||||||
|
expect(dflt?.branch).toBe('branch_default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全形括號也收(中文輸入法常打出全形)', () => {
|
||||||
|
const g = build(['my_switch >> ON_BRANCH(branch_active) >> 處理啟用']);
|
||||||
|
const e = edgeBetween(g, 'my_switch', '處理啟用');
|
||||||
|
expect(e?.type).toBe('ON_BRANCH');
|
||||||
|
expect(e?.branch).toBe('branch_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('try_catch 的 try/catch 標籤同樣收得到', () => {
|
||||||
|
const g = build([
|
||||||
|
'my_try >> ON_BRANCH(try) >> 正常流程',
|
||||||
|
'my_try >> ON_BRANCH(catch) >> 補救流程',
|
||||||
|
]);
|
||||||
|
expect(edgeBetween(g, 'my_try', '正常流程')?.branch).toBe('try');
|
||||||
|
expect(edgeBetween(g, 'my_try', '補救流程')?.branch).toBe('catch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('零變化:既有語法不受影響', () => {
|
||||||
|
it('ON_SUCCESS 仍是 ON_SUCCESS', () => {
|
||||||
|
const g = build(['input >> ON_SUCCESS >> prep']);
|
||||||
|
expect(edgeBetween(g, 'input', 'prep')?.type).toBe('ON_SUCCESS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('「對每個 X」仍抽得到 iterator(不被新的 branch 抽取干擾)', () => {
|
||||||
|
const g = build(['parse_card >> 對每個 block >> post_block']);
|
||||||
|
const e = edgeBetween(g, 'parse_card', 'post_block');
|
||||||
|
expect(e?.type).toBe('FOREACH');
|
||||||
|
expect(e?.iterator).toBe('block');
|
||||||
|
expect(e?.branch).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* PATCH /kbdb/records/:recordId proxy 測試(2026-08-11,三元組 library 補標需求核實)
|
||||||
|
*
|
||||||
|
* 背景:基本盤 kbdb/src/routes/records.ts 早有 PATCH /records/:recordId(mira-dissolve T2.1,
|
||||||
|
* updateRecord 已支援「補一個 record 原本沒有的 slot 值」的 idempotent grow)。但這條 cypher
|
||||||
|
* proxy(kbdb-proxy.ts)之前只轉發 GET/POST /kbdb/records,沒開 PATCH——外部(工作流/CLI/
|
||||||
|
* 任何走 X-Arcrun-API-Key 的呼叫者)打不到,等於基本盤能力在,通道沒開。
|
||||||
|
*
|
||||||
|
* 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-map-proxy.test.ts 慣例):
|
||||||
|
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
|
||||||
|
* 2. body 沒有 values → 400,不轉發
|
||||||
|
* 3. 轉發:PATCH /kbdb/records/:id → base PATCH /records/:id,body 只帶 { values }
|
||||||
|
* 4. base 404(record 不存在)→ 原樣透傳,不假裝成功
|
||||||
|
*
|
||||||
|
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||||
|
* disableNetConnect——測試絕不外連。
|
||||||
|
*/
|
||||||
|
import { SELF, fetchMock } from 'cloudflare:test';
|
||||||
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.activate();
|
||||||
|
fetchMock.disableNetConnect();
|
||||||
|
});
|
||||||
|
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||||
|
|
||||||
|
describe('PATCH /kbdb/records/:recordId — 租戶閘', () => {
|
||||||
|
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ values: { library: 'kb' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /kbdb/records/:recordId — 參數驗證', () => {
|
||||||
|
it('body 沒有 values → 400,不轉發', async () => {
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: KEY,
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /kbdb/records/:recordId — 轉發', () => {
|
||||||
|
it('轉發 base PATCH /records/:id,body 只帶 values(不夾帶其他欄位)', async () => {
|
||||||
|
fetchMock
|
||||||
|
.get('https://kbdb.test')
|
||||||
|
.intercept({
|
||||||
|
path: '/records/rec_1',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
|
||||||
|
})
|
||||||
|
.reply(200, {
|
||||||
|
success: true,
|
||||||
|
record: { record_id: 'rec_1', template_id: 'tpl-triplet', values: { library: 'gitea:Leo/kb' } },
|
||||||
|
});
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: KEY,
|
||||||
|
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.record.values.library).toBe('gitea:Leo/kb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('base 404(record 不存在)→ 原樣透傳,不假裝成功', async () => {
|
||||||
|
fetchMock
|
||||||
|
.get('https://kbdb.test')
|
||||||
|
.intercept({ path: '/records/nope', method: 'PATCH' })
|
||||||
|
.reply(404, { success: false, error: 'not found' });
|
||||||
|
const res = await SELF.fetch('http://localhost/kbdb/records/nope', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: KEY,
|
||||||
|
body: JSON.stringify({ values: { library: 'kb' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const data = (await res.json()) as { success: boolean };
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* GET|POST /portal/admin/ai —— arcrun-rag#10 迴歸守衛
|
||||||
|
*
|
||||||
|
* 🔴 為什麼有這支測試(別刪):
|
||||||
|
* 這條 route **以前根本不存在**,但前端設定頁一直在打它 ⇒ 用戶填 Gemini key → 404
|
||||||
|
* ⇒ **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
|
||||||
|
* leo 實撞成「重裝後 key 不見」,真相是「從來沒存進去,所以重填也沒用」。
|
||||||
|
* 產物層鐵證(修復前):bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=**0**。
|
||||||
|
* ⇒ 這支測試的存在本身就是防線:**route 消失=測試紅**。
|
||||||
|
*
|
||||||
|
* 覆蓋:
|
||||||
|
* 1. 未登入 → 401;非 admin → 403(不是 404=route 真的在)
|
||||||
|
* 2. GET 回 has_key 布林,**永不回傳 key 本身**(D36)
|
||||||
|
* 3. POST 空 body → 400(不假裝成功)
|
||||||
|
* 4. POST 只改 Claude 偏好(不帶 key)→ 成功,且不碰 credential
|
||||||
|
*/
|
||||||
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||||
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
|
import { hashPassword } from '../src/lib/portal-auth';
|
||||||
|
|
||||||
|
const KBDB = 'https://kbdb.test';
|
||||||
|
|
||||||
|
let storedHash: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
fetchMock.activate();
|
||||||
|
fetchMock.disableNetConnect();
|
||||||
|
storedHash = await hashPassword('unit-test-pw-1', 10_000);
|
||||||
|
});
|
||||||
|
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||||
|
|
||||||
|
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||||
|
return SELF.fetch(`http://localhost${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockGetRecord(recordId: string, values: Record<string, string>) {
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: `/records/${recordId}`, method: 'GET' })
|
||||||
|
.reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminValues(overrides: Record<string, string> = {}): Record<string, string> {
|
||||||
|
return {
|
||||||
|
email: 'admin@example.com',
|
||||||
|
display_name: '管理員',
|
||||||
|
status: 'active',
|
||||||
|
role: 'admin',
|
||||||
|
password_hash: storedHash,
|
||||||
|
libraries: '["*"]',
|
||||||
|
created_at: '2026-07-14T00:00:00.000Z',
|
||||||
|
updated_at: '2026-07-14T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSession(token: string, recordId: string) {
|
||||||
|
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHdr = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
|
describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () => {
|
||||||
|
it('未登入 → 401(不是 404 ⇒ route 真的在)', async () => {
|
||||||
|
const res = await json('GET', '/portal/admin/ai');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.status).not.toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非 admin → 403', async () => {
|
||||||
|
await seedSession('tok-user', 'rec_user');
|
||||||
|
mockGetRecord('rec_user', adminValues({ role: 'user', email: 'u@example.com' }));
|
||||||
|
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-user'));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => {
|
||||||
|
it('回 has_key 布林,且回應完全不含金鑰值', async () => {
|
||||||
|
await seedSession('tok-a1', 'rec_admin');
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const raw = await res.text();
|
||||||
|
const d = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(typeof d.has_key).toBe('boolean');
|
||||||
|
|
||||||
|
// D36:回應裡不得出現任何疑似金鑰的欄位
|
||||||
|
expect(raw).not.toContain('gemini_api_key_value');
|
||||||
|
expect(d).not.toHaveProperty('key');
|
||||||
|
expect(d).not.toHaveProperty('value');
|
||||||
|
expect(d).not.toHaveProperty('secret_ref');
|
||||||
|
});
|
||||||
|
|
||||||
|
// t176 回歸守衛(leo 08-03):雲端不再有「地端用哪個模型」的概念。
|
||||||
|
// 這兩個欄位若復活,代表又走回「雲端控制地端」的老路——那正是 08-03 事故根因
|
||||||
|
//(extractor_config 全租戶共用一把,任一處設 claude 就讓所有人萃取全滅)。
|
||||||
|
it('不再回 claude_available/use_claude_for_extract(地端模型改由小幫手自己設)', async () => {
|
||||||
|
await seedSession('tok-a1b', 'rec_admin');
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1b'));
|
||||||
|
const d = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(d).not.toHaveProperty('claude_available');
|
||||||
|
expect(d).not.toHaveProperty('use_claude_for_extract');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /portal/admin/ai — 不假裝成功', () => {
|
||||||
|
it('空 body(沒帶金鑰)→ 400,不回 success', async () => {
|
||||||
|
await seedSession('tok-a2', 'rec_admin');
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2'));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const d = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(d.success).toBeUndefined();
|
||||||
|
expect(String(d.error)).toContain('沒有要變更');
|
||||||
|
});
|
||||||
|
|
||||||
|
// t176 回歸守衛:只送 Claude 偏好=沒有要變更的項目 → 400(該欄位已不存在)。
|
||||||
|
it('只送 use_claude_for_extract(已廢欄位)→ 400,不得假裝成功', async () => {
|
||||||
|
await seedSession('tok-a3', 'rec_admin');
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3'));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const d = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(d.success).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
* 覆蓋(=tasks.md P4+總管派工驗收重點):
|
* 覆蓋(=tasks.md P4+總管派工驗收重點):
|
||||||
* 1. **最後一個 active admin 鎖死保護**:停用 → 409;降級 role=user → 409;
|
* 1. **最後一個 active admin 鎖死保護**:停用 → 409;降級 role=user → 409;
|
||||||
* 「還有另一個 active admin」才放行;另一個 admin 是 disabled 不算數。
|
* 「還有另一個 active admin」才放行;另一個 admin 是 disabled 不算數。
|
||||||
* 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落 KBDB
|
* 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落地
|
||||||
* (庫裡只有 pbkdf2 hash);自帶密碼 → 回應無 generated_password。
|
* (新家只有 pbkdf2 hash,D61 起帳號建立走認證儲存不再落 KBDB);自帶密碼 → 回應無 generated_password。
|
||||||
* 3. reset-password:回一次性新密碼;PATCH 進 KBDB 的是 hash 非明碼。
|
* 3. reset-password:回一次性新密碼;PATCH 落地的是 hash 非明碼(目標帳號沿用舊家 fixture,
|
||||||
|
* 仍走 KBDB PATCH——見下方 mockPatchPrelude 的說明)。
|
||||||
* 4. 庫權限:PATCH libraries=["*"](全庫)合法;空陣列/壞庫名 → 400。
|
* 4. 庫權限:PATCH libraries=["*"](全庫)合法;空陣列/壞庫名 → 400。
|
||||||
* 5. 庫目錄:POST 建庫寫 {tenant}::portal 子 namespace;PATCH graph_source boolean。
|
* 5. 庫目錄:POST 建庫寫 {tenant}::portal 子 namespace;PATCH graph_source boolean。
|
||||||
* 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、
|
* 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、
|
||||||
@@ -14,12 +15,21 @@
|
|||||||
*
|
*
|
||||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||||
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
|
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
|
||||||
|
*
|
||||||
|
* D61(ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 fixture(rec_admin/rec_u1/rec_admin2…)
|
||||||
|
* 全部沿用「record_id 不是 auth: 開頭」這個既有慣例——這正是 portal.ts 的相容分流點
|
||||||
|
* (isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走原本的 KBDB 路徑,行為與 D61 之前
|
||||||
|
* 完全一致,故本檔絕大多數測試不需要改。**只有「新建帳號」這個動作**(POST /portal/admin/users、
|
||||||
|
* POST /portal/admin/bootstrap 走同一支 createPortalUser)改成寫進認證儲存(CF Workers
|
||||||
|
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`(PUT)——見 mockAuthStoreWrite。
|
||||||
*/
|
*/
|
||||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||||
|
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||||
|
|
||||||
const KBDB = 'https://kbdb.test';
|
const KBDB = 'https://kbdb.test';
|
||||||
|
const CF_API = 'https://api.cloudflare.com';
|
||||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||||
|
|
||||||
let storedHash: string;
|
let storedHash: string;
|
||||||
@@ -39,6 +49,21 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
|
||||||
|
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||||
|
const captured: Array<{ name: string; text: string }> = [];
|
||||||
|
fetchMock
|
||||||
|
.get(CF_API)
|
||||||
|
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||||
|
.reply(200, (opts) => {
|
||||||
|
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||||
|
captured.push(body);
|
||||||
|
return { success: true };
|
||||||
|
})
|
||||||
|
.times(times);
|
||||||
|
return { puts: () => captured };
|
||||||
|
}
|
||||||
|
|
||||||
function mockHeadLookup(email: string, recordId: string | null) {
|
function mockHeadLookup(email: string, recordId: string | null) {
|
||||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||||
fetchMock
|
fetchMock
|
||||||
@@ -66,7 +91,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mockTemplatesExist() {
|
function mockTemplatesExist() {
|
||||||
for (const name of ['portal_user', 'portal_library']) {
|
for (const name of ['portal_user', 'portal_library', 'triplet']) {
|
||||||
fetchMock
|
fetchMock
|
||||||
.get(KBDB)
|
.get(KBDB)
|
||||||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||||||
@@ -177,23 +202,11 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id)', () => {
|
|||||||
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
|
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
|
||||||
|
|
||||||
describe('POST /portal/admin/users(一次性密碼)', () => {
|
describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||||
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
|
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼(D61)', async () => {
|
||||||
await seedAdminSession();
|
await seedAdminSession();
|
||||||
mockGetRecord('rec_admin', adminValues());
|
mockGetRecord('rec_admin', adminValues());
|
||||||
mockHeadLookup('new@example.com', null); // email 未占用
|
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||||
let recordBody = '';
|
const { puts } = mockAuthStoreWrite();
|
||||||
fetchMock
|
|
||||||
.get(KBDB)
|
|
||||||
.intercept({ path: '/records', method: 'POST' })
|
|
||||||
.reply(200, (opts) => {
|
|
||||||
recordBody = String(opts.body);
|
|
||||||
return { success: true, record: { record_id: 'rec_new', template_id: 'tpl_pu', values: {} } };
|
|
||||||
});
|
|
||||||
fetchMock
|
|
||||||
.get(KBDB)
|
|
||||||
.intercept({ path: '/entries', method: 'POST' })
|
|
||||||
.reply(200, { success: true, entry: { id: 'e_head' } });
|
|
||||||
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
|
|
||||||
const res = await json(
|
const res = await json(
|
||||||
'POST',
|
'POST',
|
||||||
'/portal/admin/users',
|
'/portal/admin/users',
|
||||||
@@ -205,26 +218,23 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
|
|||||||
expect(typeof data.generated_password).toBe('string');
|
expect(typeof data.generated_password).toBe('string');
|
||||||
expect(data.generated_password!.length).toBe(16);
|
expect(data.generated_password!.length).toBe(16);
|
||||||
expect('password_hash' in data.user).toBe(false);
|
expect('password_hash' in data.user).toBe(false);
|
||||||
// 一次性密碼不落庫:KBDB 收到的 record body 只有 hash、無明碼
|
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
|
||||||
expect(recordBody).not.toContain(data.generated_password!);
|
|
||||||
const rec = JSON.parse(recordBody) as { owner_id: string; values: Record<string, string> };
|
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
|
||||||
expect(rec.owner_id).toBe(NS);
|
const shards = puts();
|
||||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
expect(shards.length).toBe(1);
|
||||||
|
expect(shards[0].text).not.toContain(data.generated_password!);
|
||||||
|
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||||
|
const stored = shard.users.find((u) => u.email === 'new@example.com');
|
||||||
|
expect(stored).toBeDefined();
|
||||||
|
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('自帶 password → 回應**無** generated_password', async () => {
|
it('自帶 password → 回應**無** generated_password', async () => {
|
||||||
await seedAdminSession();
|
await seedAdminSession();
|
||||||
mockGetRecord('rec_admin', adminValues());
|
mockGetRecord('rec_admin', adminValues());
|
||||||
mockHeadLookup('own@example.com', null);
|
mockHeadLookup('own@example.com', null);
|
||||||
fetchMock
|
mockAuthStoreWrite();
|
||||||
.get(KBDB)
|
|
||||||
.intercept({ path: '/records', method: 'POST' })
|
|
||||||
.reply(200, { success: true, record: { record_id: 'rec_own', template_id: 'tpl_pu', values: {} } });
|
|
||||||
fetchMock
|
|
||||||
.get(KBDB)
|
|
||||||
.intercept({ path: '/entries', method: 'POST' })
|
|
||||||
.reply(200, { success: true, entry: { id: 'e_head2' } });
|
|
||||||
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
|
|
||||||
const res = await json(
|
const res = await json(
|
||||||
'POST',
|
'POST',
|
||||||
'/portal/admin/users',
|
'/portal/admin/users',
|
||||||
@@ -263,6 +273,70 @@ describe('POST /portal/admin/users/:id/reset-password', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 3.5 recover-password(arcrun-rag#25:admin 忘記 portal 密碼自救)═══════════════
|
||||||
|
|
||||||
|
describe('POST /portal/admin/recover-password', () => {
|
||||||
|
it('無 console owner session → 401,不碰 KBDB', async () => {
|
||||||
|
const res = await json('POST', '/portal/admin/recover-password', { email: 'admin@example.com' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有 console session 但 email 格式不對 → 400,不碰 KBDB', async () => {
|
||||||
|
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||||
|
const res = await json(
|
||||||
|
'POST',
|
||||||
|
'/portal/admin/recover-password',
|
||||||
|
{ email: 'not-an-email' },
|
||||||
|
{ Authorization: 'Bearer owner-token' },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('查無此 email 的 portal 帳號 → 404,不誤導成別種錯誤', async () => {
|
||||||
|
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||||
|
mockHeadLookup('ghost@example.com', null);
|
||||||
|
const res = await json(
|
||||||
|
'POST',
|
||||||
|
'/portal/admin/recover-password',
|
||||||
|
{ email: 'ghost@example.com' },
|
||||||
|
{ Authorization: 'Bearer owner-token' },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('console session 有效+帳號存在 → 回一次性新密碼;PATCH 落 KBDB 的是新 hash 非明碼;**不需要任何 portal session**', async () => {
|
||||||
|
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||||
|
// 刻意不 seedAdminSession():這條路唯一該吃的是 console session,機械證明繞得過
|
||||||
|
// 「忘記 portal 密碼 ⇒ 沒有 portal_sess ⇒ 打不進其他 admin 端點」這個死結。
|
||||||
|
mockHeadLookup('admin@example.com', 'rec_admin');
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
let patched = '';
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/records/rec_admin', method: 'PATCH' })
|
||||||
|
.reply(200, (opts) => {
|
||||||
|
patched = String(opts.body);
|
||||||
|
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: adminValues() } };
|
||||||
|
});
|
||||||
|
const res = await json(
|
||||||
|
'POST',
|
||||||
|
'/portal/admin/recover-password',
|
||||||
|
{ email: 'Admin@Example.com' }, // 混寫大小寫,驗證正規化成小寫再查
|
||||||
|
{ Authorization: 'Bearer owner-token' },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean; email: string; password: string };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.email).toBe('admin@example.com');
|
||||||
|
expect(typeof data.password).toBe('string');
|
||||||
|
expect(data.password.length).toBe(16);
|
||||||
|
expect(patched).not.toContain(data.password); // 明碼不落 KBDB
|
||||||
|
const sent = JSON.parse(patched) as { values: Record<string, string> };
|
||||||
|
expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||||
|
expect(sent.values.password_hash).not.toBe(storedHash); // 真的換了
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════ 4. 庫權限勾選(libraries PATCH)═══════════════
|
// ═══════════════ 4. 庫權限勾選(libraries PATCH)═══════════════
|
||||||
|
|
||||||
describe('PATCH libraries(每帳號可查庫)', () => {
|
describe('PATCH libraries(每帳號可查庫)', () => {
|
||||||
@@ -350,12 +424,259 @@ describe('/portal/admin/libraries', () => {
|
|||||||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' });
|
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET auto 庫列表過濾 general(general 是系統桶,不在用戶目錄顯示)', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
mockListByTemplate('portal_library', []);
|
||||||
|
// t142:GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||||
|
.reply(200, { libraries: ['kb', 'general', 'notes'] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [] });
|
||||||
|
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] };
|
||||||
|
const names = data.libraries.map((l) => l.name);
|
||||||
|
expect(names).toContain('kb');
|
||||||
|
expect(names).toContain('notes');
|
||||||
|
expect(names).not.toContain('general');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
|
// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════
|
||||||
|
|
||||||
|
describe('GET /portal/admin/libraries + stats(t142)', () => {
|
||||||
|
it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
mockListByTemplate('portal_library', [
|
||||||
|
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } },
|
||||||
|
]);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||||
|
.reply(200, { libraries: ['kb'] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] });
|
||||||
|
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] };
|
||||||
|
const kb = data.libraries.find((l) => l.name === 'kb');
|
||||||
|
expect(kb).toBeDefined();
|
||||||
|
expect(kb!.card_count).toBe(42);
|
||||||
|
expect(kb!.triplet_count).toBe(111);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto 庫也帶 card_count + triplet_count', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
mockListByTemplate('portal_library', []);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||||
|
.reply(200, { libraries: ['notes'] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] });
|
||||||
|
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] };
|
||||||
|
const notes = data.libraries.find((l) => l.name === 'notes');
|
||||||
|
expect(notes).toBeDefined();
|
||||||
|
expect(notes!.auto).toBe(true);
|
||||||
|
expect(notes!.card_count).toBe(7);
|
||||||
|
expect(notes!.triplet_count).toBe(108);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
mockListByTemplate('portal_library', [
|
||||||
|
{ record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } },
|
||||||
|
]);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||||
|
.reply(200, { libraries: [] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [] });
|
||||||
|
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] };
|
||||||
|
const empty = data.libraries.find((l) => l.name === 'empty');
|
||||||
|
expect(empty).toBeDefined();
|
||||||
|
expect(empty!.card_count).toBe(0);
|
||||||
|
expect(empty!.triplet_count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ t135 庫目錄移除 ═══════════════
|
||||||
|
|
||||||
|
describe('DELETE /portal/admin/libraries(t135)', () => {
|
||||||
|
it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
// 成員驗證:list by template 回有該 record
|
||||||
|
mockListByTemplate('portal_library', [
|
||||||
|
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
|
||||||
|
]);
|
||||||
|
let deleteCalled = false;
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/records/rec_lib1', method: 'DELETE' })
|
||||||
|
.reply(200, () => { deleteCalled = true; return { success: true }; });
|
||||||
|
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean; name: string; message: string };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.name).toBe('finance');
|
||||||
|
expect(deleteCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /:id — 庫不在目錄 → 404', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
mockListByTemplate('portal_library', []); // 空目錄
|
||||||
|
const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /:id — 非 admin → 403', async () => {
|
||||||
|
await seedAdminSession('tok-user', 'rec_u1');
|
||||||
|
mockGetRecord('rec_u1', userValues());
|
||||||
|
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
let deprecateCalled = false;
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
|
||||||
|
.reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
|
||||||
|
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean; deprecated_count: number };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.deprecated_count).toBe(12);
|
||||||
|
expect(deprecateCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /by-name/:name — 無 confirm → 400', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
|
||||||
|
await seedAdminSession();
|
||||||
|
mockGetRecord('rec_admin', adminValues());
|
||||||
|
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /by-name/:name — 非 admin → 403', async () => {
|
||||||
|
await seedAdminSession('tok-user', 'rec_u1');
|
||||||
|
mockGetRecord('rec_u1', userValues());
|
||||||
|
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 6. t176:雲端不再管地端 LLM 設定(取代原 t122/t131 兩組測試)═══════════════
|
||||||
|
//
|
||||||
|
// leo 2026-08-03 架構翻案:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,
|
||||||
|
// 而不是雲端設置後控制地端」。原因是 extractor_config 的 KV key 由 portalTenant() 組出,
|
||||||
|
// 而 portalTenant 是 **worker 層級**環境變數 ⇒ **全租戶共用一把**:任一處設了 claude,
|
||||||
|
// 所有人的 daemon 都收到 claude,沒裝 Claude Code 的機器萃取全滅,
|
||||||
|
// 而 portal 的 Claude 勾選框又恆 disabled(daemon 從未回報 has_claude)⇒ 用戶自己解不開。
|
||||||
|
//
|
||||||
|
// 以下是**回歸守衛**:這些端點/欄位若復活,代表又走回「雲端控制地端」的老路。
|
||||||
|
|
||||||
|
describe('t176:雲端不再下發/設定地端 LLM', () => {
|
||||||
|
const USER_EMAIL = 'daemon@example.com';
|
||||||
|
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(外層 beforeAll 計算)
|
||||||
|
const USER_RECORD = 'rec_daemon_user';
|
||||||
|
|
||||||
|
/** mock email head lookup(findUserRecordId 走這個路徑)*/
|
||||||
|
function mockEmailLookup(email: string, recordId: string | null) {
|
||||||
|
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({
|
||||||
|
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('POST /portal/daemon/config 只回連線欄位,**不含任何 LLM 欄位**', async () => {
|
||||||
|
mockEmailLookup(USER_EMAIL, USER_RECORD);
|
||||||
|
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
|
||||||
|
|
||||||
|
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const d = (await res.json()) as { config: Record<string, unknown> };
|
||||||
|
|
||||||
|
// 連線欄位照舊(daemon 靠它上線)
|
||||||
|
expect(d.config.cypher_url).toBeTruthy();
|
||||||
|
expect(d.config.namespace).toBeTruthy();
|
||||||
|
expect(d.config.library).toBe('kb');
|
||||||
|
|
||||||
|
// LLM 欄位一律不下發(t176 核心)
|
||||||
|
expect(d.config).not.toHaveProperty('extractor');
|
||||||
|
expect(d.config).not.toHaveProperty('gemini_api_key');
|
||||||
|
expect(d.config).not.toHaveProperty('llm_model');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 註:route 不存在 ⇒ 在認證之前就 404,因此不需要(也不能)預先掛 record mock,
|
||||||
|
// 否則 afterEach 的 assertNoPendingInterceptors 會因「mock 沒被用到」而失敗。
|
||||||
|
it('POST /portal/admin/extractor 已移除(雲端不再有指定地端引擎的入口)', async () => {
|
||||||
|
const res = await json('POST', '/portal/admin/extractor', { engine: 'claude' }, { Authorization: 'Bearer tok-ex' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /portal/daemon/report-capabilities 已移除(has_claude 回報鏈整條退役)', async () => {
|
||||||
|
const res = await json('POST', '/portal/daemon/report-capabilities', {
|
||||||
|
email: USER_EMAIL, password: USER_PW, has_claude: true,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 7. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
|
||||||
|
|
||||||
describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
|
describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
|
||||||
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira', async () => {
|
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => {
|
||||||
const res = await SELF.fetch('http://localhost/portal');
|
const res = await SELF.fetch('http://localhost/portal');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
@@ -367,5 +688,63 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
|
|||||||
expect(html).not.toContain('/kbdb/');
|
expect(html).not.toContain('/kbdb/');
|
||||||
expect(html).not.toContain('X-Arcrun-API-Key');
|
expect(html).not.toContain('X-Arcrun-API-Key');
|
||||||
expect(html).not.toContain('Mira');
|
expect(html).not.toContain('Mira');
|
||||||
|
// t97a:bootstrap 後不再預埋 kb 庫
|
||||||
|
expect(html).not.toContain('"name": "kb"');
|
||||||
|
expect(html).not.toContain("name: 'kb'");
|
||||||
|
// t114:無「登記到目錄」按鈕
|
||||||
|
expect(html).not.toContain('lib-adopt');
|
||||||
|
expect(html).not.toContain('登記到目錄');
|
||||||
|
// t131:合併 AI 設定(舊兩區塊已移除)
|
||||||
|
expect(html).toContain('st-ai-panel');
|
||||||
|
expect(html).toContain('st-ai-key');
|
||||||
|
expect(html).toContain('st-ai-use-claude');
|
||||||
|
expect(html).not.toContain('st-extractor-panel');
|
||||||
|
expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// t131/t122 測試已隨 main 的 t176(刪除雲端下發 LLM 設定)一併移除;
|
||||||
|
// 此處只保留 t181(daemon 走 Workers AI)的守衛。
|
||||||
|
|
||||||
|
describe('POST /portal/daemon/extract(t181:Workers AI 萃卡,免金鑰)', () => {
|
||||||
|
// 認證=X-Arcrun-API-Key(=namespace,wrangler.test.toml CONSOLE_TENANT=leo),
|
||||||
|
// **不是帳密**:daemon 密碼不落地(連線精靈用完即丟),背景萃取拿不到密碼。
|
||||||
|
const KEY = { 'X-Arcrun-API-Key': 'leo' };
|
||||||
|
|
||||||
|
it('沒帶 API Key → 401', async () => {
|
||||||
|
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔴 t189:這則原本是「API Key 錯 → 401(租戶隔離)」,**是錯的,而且害我看到假綠**。
|
||||||
|
//
|
||||||
|
// 它假設「daemon 的 api_key = 實例的 CONSOLE_TENANT」,但實測不成立:
|
||||||
|
// geek6688:tenant=ckxt8yr9、daemon api_key=yuga3bse ⇒ 真用戶**永遠 401**、萃不了
|
||||||
|
// youlin :兩者碰巧相同 ⇒ 我這邊測起來都對
|
||||||
|
// 舊測試只證明「符合我的假設」,不證明「假設是對的」——
|
||||||
|
// **把錯誤假設寫成測試,就是把假綠焊死。**
|
||||||
|
//
|
||||||
|
// 翻轉成守衛:**key 與 tenant 不同也要能萃**(這正是 leo 撞到的情境)。
|
||||||
|
// 若哪天有人又加回等值比對,這則會紅。
|
||||||
|
it('key 與實例 tenant 不同也要能用(t189:多帳號 daemon 的常態)', async () => {
|
||||||
|
const res = await json('POST', '/portal/daemon/extract',
|
||||||
|
{ page_name: 'x', text: 'y' }, { 'X-Arcrun-API-Key': 'another-tenant-key' });
|
||||||
|
expect(res.status).not.toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺 page_name 或 text → 400(不打 AI、不假裝成功)', async () => {
|
||||||
|
const res = await json('POST', '/portal/daemon/extract', {}, KEY);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const d = (await res.json()) as { error?: string };
|
||||||
|
expect(String(d.error)).toContain('page_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔴 回歸守衛:這條路**不得**要求任何 Gemini/API 金鑰——免金鑰正是它存在的理由。
|
||||||
|
// 若哪天有人把它改回打 Google,錯誤訊息會出現 credential/gemini_api_key ⇒ 這則會紅。
|
||||||
|
it('錯誤訊息不得要求任何金鑰(免金鑰是本端點存在的理由)', async () => {
|
||||||
|
const res = await json('POST', '/portal/daemon/extract', {}, KEY);
|
||||||
|
const raw = await res.text();
|
||||||
|
expect(raw).not.toContain('gemini_api_key');
|
||||||
|
expect(raw).not.toContain('credential');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 覆蓋(=tasks.md P2 測試項):
|
* 覆蓋(=tasks.md P2 測試項):
|
||||||
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
||||||
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
|
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
|
||||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫 {tenant}::portal 子 namespace;
|
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進**認證儲存**(D61);
|
||||||
* 已有 admin → 409
|
* 已有 admin → 409
|
||||||
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
||||||
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
||||||
@@ -12,15 +12,28 @@
|
|||||||
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
||||||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||||||
*
|
*
|
||||||
|
* D61(ADR D61 / Leo/arcrun-rag#55)補的覆蓋(原本沒有,這次變更的重點):
|
||||||
|
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
|
||||||
|
* 9. 舊實例相容:帳號只存在 KBDB(舊家)時仍登得進去,登入成功後自動搬進認證儲存
|
||||||
|
*
|
||||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||||
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
|
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
|
||||||
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
|
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
|
||||||
|
*
|
||||||
|
* D61 起,帳號的家從 KBDB 換成認證儲存(CF Workers Secrets)——寫入會呼叫
|
||||||
|
* `https://api.cloudflare.com/.../secrets`(PUT),同樣走 fetchMock 假 host 攔截,不外連。
|
||||||
|
* wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒(比照真實裝妥的實例)。
|
||||||
*/
|
*/
|
||||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||||
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
|
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
|
||||||
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||||
|
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
|
||||||
|
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||||
|
import { portalRouter } from '../src/routes/portal';
|
||||||
|
import type { Bindings, ExecutionContext } from '../src/types';
|
||||||
|
|
||||||
const KBDB = 'https://kbdb.test';
|
const KBDB = 'https://kbdb.test';
|
||||||
|
const CF_API = 'https://api.cloudflare.com';
|
||||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||||
const EMAIL = 'user@example.com';
|
const EMAIL = 'user@example.com';
|
||||||
const PASSWORD = 'correct-horse-9';
|
const PASSWORD = 'correct-horse-9';
|
||||||
@@ -43,6 +56,34 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D61:認證儲存的寫入路徑(單元測試層級——一次呼叫=一片,測試資料量小不會觸發溢位分片)。
|
||||||
|
* 攔截 CF Workers Scripts secrets 管理 API 的 PUT,捕捉 body 供斷言(片名/內容)。
|
||||||
|
* 用法:每個會觸發寫入的測試呼叫一次,回傳的 `puts()` 拿到依序捕捉到的 {name, text}[]。
|
||||||
|
*
|
||||||
|
* ⚠️ 讀路徑沒有對應的「seed 進 env」捷徑可用:`cloudflare:test` 的 `env` 物件是傳給
|
||||||
|
* `vitest` 主 context 用的,對 `SELF.fetch()` 打的那個 worker isolate **不生效**(實測驗證,
|
||||||
|
* mutate `env.XXX` 後 SELF 端讀到的仍是 wrangler.test.toml 的原值)。因此「舊實例相容」
|
||||||
|
* 一類的讀路徑測試,一律靠**既有的 KBDB fetchMock**(新家預設空,天然等於「帳號只在舊家」);
|
||||||
|
* 要驗證「新家已經有資料」則靠**真的呼叫一次寫入端點**(bootstrap/新增同仁),讓 portal-auth-store
|
||||||
|
* 模組內的 per-isolate overlay 落地——這個 overlay 在同一支測試檔案裡的後續測試\*也讀得到\*
|
||||||
|
* (模組級全域變數不隨 test 重置,只有 KV/D1 等 storage 才有 isolatedStorage 重置),
|
||||||
|
* 這是刻意善用而非意外:想要「乾淨無帳號」的情境,該測試必須排在檔案裡**第一個寫入動作之前**。
|
||||||
|
*/
|
||||||
|
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||||
|
const captured: Array<{ name: string; text: string }> = [];
|
||||||
|
fetchMock
|
||||||
|
.get(CF_API)
|
||||||
|
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||||
|
.reply(200, (opts) => {
|
||||||
|
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||||
|
captured.push(body);
|
||||||
|
return { success: true };
|
||||||
|
})
|
||||||
|
.times(times);
|
||||||
|
return { puts: () => captured };
|
||||||
|
}
|
||||||
|
|
||||||
// ── KBDB mock helpers ──────────────────────────────────────────────────────
|
// ── KBDB mock helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1) */
|
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1) */
|
||||||
@@ -73,7 +114,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mockTemplatesExist() {
|
function mockTemplatesExist() {
|
||||||
for (const name of ['portal_user', 'portal_library']) {
|
for (const name of ['portal_user', 'portal_library', 'triplet']) {
|
||||||
fetchMock
|
fetchMock
|
||||||
.get(KBDB)
|
.get(KBDB)
|
||||||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||||||
@@ -133,6 +174,33 @@ describe('PBKDF2 模組(lib/portal-auth)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 1.5 D61:整台實例沒有任何認證資料 ═══════════════
|
||||||
|
//
|
||||||
|
// 🔴 這個 describe 必須留在檔案裡「第一個會寫入認證儲存的測試」之前(下面 2. bootstrap
|
||||||
|
// 的「console session OK」那則)——見 mockAuthStoreWrite 檔頭註解:portal-auth-store.ts
|
||||||
|
// 的 per-isolate overlay 是模組級全域變數,同一支測試檔案跑起來不會在測試之間重置,
|
||||||
|
// 一旦有測試寫入過,後面的測試都會看到那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||||
|
describe('D61:整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||||
|
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
|
||||||
|
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前);
|
||||||
|
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
|
||||||
|
// 才是「這台實例真的沒有認證資料」。
|
||||||
|
mockHeadLookup('anyone@example.com', null);
|
||||||
|
mockListByTemplate('portal_user', []);
|
||||||
|
const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
const data = (await res.json()) as { error: string; code: string; auth_store: { present: boolean; users: number } };
|
||||||
|
expect(data.code).toBe('auth_store_empty');
|
||||||
|
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
|
||||||
|
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
|
||||||
|
expect(data.error).toContain('不是密碼錯');
|
||||||
|
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
|
||||||
|
expect(data.auth_store.users).toBe(0);
|
||||||
|
// 不計入鎖定:lockfail 計數器完全沒被寫入
|
||||||
|
expect(await env.SESSIONS_KV.get('portal_lockfail:anyone@example.com')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════ 2. bootstrap 閘 ═══════════════
|
// ═══════════════ 2. bootstrap 閘 ═══════════════
|
||||||
|
|
||||||
describe('POST /portal/admin/bootstrap', () => {
|
describe('POST /portal/admin/bootstrap', () => {
|
||||||
@@ -141,28 +209,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
|||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('console session OK → 建第一個 admin:record + head entry 都寫 {tenant}::portal 子 namespace', async () => {
|
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB)', async () => {
|
||||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||||
mockTemplatesExist();
|
mockTemplatesExist();
|
||||||
mockListByTemplate('portal_user', []); // 尚無 admin
|
mockListByTemplate('portal_user', []); // 尚無 admin(新家空,舊家也空)
|
||||||
mockHeadLookup('admin@example.com', null); // email 未占用
|
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||||
|
const { puts } = mockAuthStoreWrite();
|
||||||
let recordBody = '';
|
|
||||||
fetchMock
|
|
||||||
.get(KBDB)
|
|
||||||
.intercept({ path: '/records', method: 'POST' })
|
|
||||||
.reply(200, (opts) => {
|
|
||||||
recordBody = String(opts.body);
|
|
||||||
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: {} } };
|
|
||||||
});
|
|
||||||
let headBody = '';
|
|
||||||
fetchMock
|
|
||||||
.get(KBDB)
|
|
||||||
.intercept({ path: '/entries', method: 'POST' })
|
|
||||||
.reply(200, (opts) => {
|
|
||||||
headBody = String(opts.body);
|
|
||||||
return { success: true, entry: { id: 'e_head' } };
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await json(
|
const res = await json(
|
||||||
'POST',
|
'POST',
|
||||||
@@ -173,23 +225,25 @@ describe('POST /portal/admin/bootstrap', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const data = (await res.json()) as Record<string, unknown>;
|
const data = (await res.json()) as Record<string, unknown>;
|
||||||
expect(data.success).toBe(true);
|
expect(data.success).toBe(true);
|
||||||
expect(data.record_id).toBe('rec_admin');
|
expect(typeof data.record_id).toBe('string');
|
||||||
|
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61)
|
||||||
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1)
|
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1)
|
||||||
|
|
||||||
const rec = JSON.parse(recordBody) as { owner_id: string; values: Record<string, string>; template: string };
|
// D61:一次寫入=一片,落進認證儲存(Workers Secrets),不再有 KBDB record/head entry
|
||||||
expect(rec.template).toBe('portal_user');
|
const shards = puts();
|
||||||
expect(rec.owner_id).toBe(NS); // ← D-2 子 namespace 機械斷言
|
expect(shards.length).toBe(1);
|
||||||
expect(rec.values.role).toBe('admin');
|
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||||
expect(rec.values.status).toBe('active');
|
const shard = JSON.parse(shards[0].text) as {
|
||||||
expect(rec.values.libraries).toBe('["*"]');
|
users: Array<{ email: string; role: string; status: string; libraries: string[]; password_hash: string }>;
|
||||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
};
|
||||||
expect(recordBody).not.toContain('bootstrap-pw-1'); // 明碼絕不落 KBDB
|
expect(shard.users.length).toBe(1);
|
||||||
|
const stored = shard.users[0];
|
||||||
const head = JSON.parse(headBody) as Record<string, string>;
|
expect(stored.email).toBe('admin@example.com');
|
||||||
expect(head.owner_id).toBe(NS);
|
expect(stored.role).toBe('admin');
|
||||||
expect(head.entry_type).toBe('portal_user');
|
expect(stored.status).toBe('active');
|
||||||
expect(head.page_name).toBe('admin@example.com');
|
expect(stored.libraries).toEqual(['*']);
|
||||||
expect(head.content).toBe('rec_admin');
|
expect(stored.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||||
|
expect(shards[0].text).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||||
});
|
});
|
||||||
|
|
||||||
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
|
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
|
||||||
@@ -209,6 +263,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
|||||||
// ═══════════════ 3. 登入對錯 ═══════════════
|
// ═══════════════ 3. 登入對錯 ═══════════════
|
||||||
|
|
||||||
describe('POST /portal/login', () => {
|
describe('POST /portal/login', () => {
|
||||||
|
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
|
||||||
|
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay,
|
||||||
|
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
|
||||||
|
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌。
|
||||||
|
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
|
||||||
|
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||||
it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => {
|
it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => {
|
||||||
mockHeadLookup(EMAIL, 'rec_1');
|
mockHeadLookup(EMAIL, 'rec_1');
|
||||||
mockGetRecord('rec_1', activeUserValues());
|
mockGetRecord('rec_1', activeUserValues());
|
||||||
@@ -226,6 +286,11 @@ describe('POST /portal/login', () => {
|
|||||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||||
expect(sess).toBeTruthy();
|
expect(sess).toBeTruthy();
|
||||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
|
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
|
||||||
|
// D61:promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mock,disableNetConnect 之下
|
||||||
|
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
|
||||||
|
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
|
||||||
|
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
|
||||||
|
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
|
||||||
});
|
});
|
||||||
|
|
||||||
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
|
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
|
||||||
@@ -414,3 +479,206 @@ describe('admin 端點 role 閘', () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════ t130 — triplet template seed ═══════════════
|
||||||
|
|
||||||
|
describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,ensurePortalTemplates 冪等)', () => {
|
||||||
|
it('PORTAL_TEMPLATE_SEEDS 含 triplet 且必要 slots 齊備(pure data)', () => {
|
||||||
|
const seed = PORTAL_TEMPLATE_SEEDS.find((s) => s.name === 'triplet');
|
||||||
|
expect(seed).toBeDefined();
|
||||||
|
for (const slot of ['subject', 'predicate', 'object', 'source_uri', 'status', 'library']) {
|
||||||
|
expect(seed!.slots).toContain(slot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /init/seed — triplet 已存 → existing(冪等,不重建)', async () => {
|
||||||
|
for (const name of ['portal_user', 'portal_library', 'triplet']) {
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||||||
|
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
|
||||||
|
}
|
||||||
|
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
|
||||||
|
expect(data.portal_templates.existing).toContain('triplet');
|
||||||
|
expect(data.portal_templates.created).not.toContain('triplet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /init/seed — triplet 缺 → 自動補建(新實例首次 seed)', async () => {
|
||||||
|
for (const name of ['portal_user', 'portal_library']) {
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||||||
|
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
|
||||||
|
}
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/templates/triplet', method: 'GET' })
|
||||||
|
.reply(404, { success: false, error: 'template not found: triplet' });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/templates', method: 'POST' })
|
||||||
|
.reply(200, { success: true, template: { id: 'tpl-triplet-new', name: 'triplet' } });
|
||||||
|
|
||||||
|
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
|
||||||
|
expect(data.portal_templates.created).toContain('triplet');
|
||||||
|
expect(data.portal_templates.existing).not.toContain('triplet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ D61:舊實例登入自癒(搬進新家)═══════════════
|
||||||
|
//
|
||||||
|
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
|
||||||
|
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
|
||||||
|
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||||
|
// 不會有更後面的測試共用這個 email 而被污染。
|
||||||
|
describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
|
||||||
|
const LEGACY_EMAIL = 'legacy-promote@example.com';
|
||||||
|
|
||||||
|
it('登入成功;promoteLegacyUser 把這筆帳號寫進認證儲存(一片、含正確 email/hash)', async () => {
|
||||||
|
mockHeadLookup(LEGACY_EMAIL, 'rec_legacy_1');
|
||||||
|
mockGetRecord('rec_legacy_1', activeUserValues({ email: LEGACY_EMAIL }));
|
||||||
|
const { puts } = mockAuthStoreWrite();
|
||||||
|
|
||||||
|
const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
|
||||||
|
const shards = puts();
|
||||||
|
expect(shards.length).toBe(1);
|
||||||
|
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||||
|
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||||
|
const promoted = shard.users.find((u) => u.email === LEGACY_EMAIL);
|
||||||
|
expect(promoted).toBeDefined();
|
||||||
|
expect(promoted!.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||||
|
});
|
||||||
|
|
||||||
|
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
|
||||||
|
// 直接呼叫 router、帶一份缺寫入路徑的 env(health.test.ts 已有的直呼叫慣例),
|
||||||
|
// 證明 promoteLegacyUser 的失敗被 best-effort 吞掉,不影響登入本身。
|
||||||
|
const email = 'legacy-promote-writeless@example.com';
|
||||||
|
mockHeadLookup(email, 'rec_legacy_2');
|
||||||
|
mockGetRecord('rec_legacy_2', activeUserValues({ email }));
|
||||||
|
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||||
|
const res = await portalRouter.fetch(
|
||||||
|
new Request('http://localhost/portal/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password: PASSWORD }),
|
||||||
|
}),
|
||||||
|
fakeEnv,
|
||||||
|
{} as ExecutionContext,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { success: boolean };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
|
||||||
|
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
|
||||||
|
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
|
||||||
|
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ D62 + arcrun-rag#66(2026-08-10)═══════════════
|
||||||
|
//
|
||||||
|
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
|
||||||
|
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
|
||||||
|
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
|
||||||
|
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
|
||||||
|
|
||||||
|
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
|
||||||
|
const D62_EMAIL = 'd62-reset@example.com';
|
||||||
|
|
||||||
|
it('/portal/password/change 帶 reset_token:**不需要登入、不需要現有密碼**,且票用完即失效', async () => {
|
||||||
|
// 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張),
|
||||||
|
// 存的是 token 的 sha256——KV 裡看不到可用的連結。
|
||||||
|
const { sha256Hex } = await import('../src/lib/portal-auth');
|
||||||
|
const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||||
|
const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
|
||||||
|
await env.SESSIONS_KV.put(
|
||||||
|
`portal_pwreset:${await sha256Hex(token)}`,
|
||||||
|
JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 票有效時,先「看一眼」不會消耗它
|
||||||
|
const peek = await json('GET', `/portal/password/reset?token=${token}`);
|
||||||
|
expect(peek.status).toBe(200);
|
||||||
|
expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);
|
||||||
|
|
||||||
|
// 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回)
|
||||||
|
const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
|
||||||
|
expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的
|
||||||
|
// 關鍵斷言:同一條連結**不能再用第二次**
|
||||||
|
const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
|
||||||
|
expect(again.status).toBe(400);
|
||||||
|
expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
|
||||||
|
// 而且票在 KV 裡真的沒了
|
||||||
|
expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => {
|
||||||
|
for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
|
||||||
|
const res = await json('GET', `/portal/password/reset?token=${t}`);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json() as { valid: boolean }).valid).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => {
|
||||||
|
const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => {
|
||||||
|
const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => {
|
||||||
|
const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
|
||||||
|
const TOKEN_A = 'sess-token-66-propagating';
|
||||||
|
const TOKEN_B = 'sess-token-66-really-gone';
|
||||||
|
const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;
|
||||||
|
|
||||||
|
it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => {
|
||||||
|
await seedPortalSession(TOKEN_A, MISSING);
|
||||||
|
// 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗
|
||||||
|
await env.SESSIONS_KV.put(
|
||||||
|
'auth_store_recent',
|
||||||
|
JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
|
||||||
|
// 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來
|
||||||
|
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => {
|
||||||
|
await seedPortalSession(TOKEN_B, MISSING);
|
||||||
|
await env.SESSIONS_KV.delete('auth_store_recent');
|
||||||
|
|
||||||
|
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
// 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收
|
||||||
|
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => {
|
||||||
|
await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
|
||||||
|
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||||
import { workflowsVisible } from '../src/routes/portal';
|
import { workflowsVisible } from '../src/routes/portal';
|
||||||
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data';
|
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data';
|
||||||
import type { Bindings } from '../src/types';
|
import type { Bindings } from '../src/types';
|
||||||
|
|
||||||
const KBDB = 'https://kbdb.test';
|
const KBDB = 'https://kbdb.test';
|
||||||
@@ -297,8 +297,12 @@ describe('GET /portal/data/workflows(D-8:admin 唯讀)', () => {
|
|||||||
`${TENANT}:wf:daily_report`,
|
`${TENANT}:wf:daily_report`,
|
||||||
JSON.stringify({ description: '每日彙整', created_at: '2026-07-14T00:00:00Z', cron_expr: '0 9 * * *' }),
|
JSON.stringify({ description: '每日彙整', created_at: '2026-07-14T00:00:00Z', cron_expr: '0 9 * * *' }),
|
||||||
);
|
);
|
||||||
await env.ANALYTICS_KV.put('stats:daily_report:1783500000000', JSON.stringify({ verdict: 'success' }));
|
// KV 額度事故修復(2026-08-07):last_execution 資料源改打 KBDB GET /execution-log/latest
|
||||||
await env.ANALYTICS_KV.put('stats:daily_report:1783400000000', JSON.stringify({ verdict: 'failed' }));
|
// (KBDB=API-as-Wall,本檔一律 fetchMock 攔截,不碰任何 D1)。
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/execution-log/latest?'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, execution: { verdict: 'success', recorded_at: 1783500000 } });
|
||||||
const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w2' });
|
const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w2' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
@@ -309,13 +313,11 @@ describe('GET /portal/data/workflows(D-8:admin 唯讀)', () => {
|
|||||||
const wf = data.workflows.find((w) => w.name === 'daily_report');
|
const wf = data.workflows.find((w) => w.name === 'daily_report');
|
||||||
expect(wf).toBeTruthy();
|
expect(wf).toBeTruthy();
|
||||||
expect(wf!.description).toBe('每日彙整');
|
expect(wf!.description).toBe('每日彙整');
|
||||||
expect(wf!.last_execution?.verdict).toBe('success'); // 取到「最新」那筆(timestamp 較大者)
|
expect(wf!.last_execution?.verdict).toBe('success');
|
||||||
expect(JSON.stringify(data)).not.toContain('webhook_url');
|
expect(JSON.stringify(data)).not.toContain('webhook_url');
|
||||||
expect(JSON.stringify(data)).not.toContain('/trigger');
|
expect(JSON.stringify(data)).not.toContain('/trigger');
|
||||||
// 清場(KV 是 suite 共用實例,避免污染其他測試)
|
// 清場(KV 是 suite 共用實例,避免污染其他測試)
|
||||||
await env.WEBHOOKS.delete(`${TENANT}:wf:daily_report`);
|
await env.WEBHOOKS.delete(`${TENANT}:wf:daily_report`);
|
||||||
await env.ANALYTICS_KV.delete('stats:daily_report:1783500000000');
|
|
||||||
await env.ANALYTICS_KV.delete('stats:daily_report:1783400000000');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('workflowsVisible 單元:admin(預設/壞值)/ all / off', () => {
|
it('workflowsVisible 單元:admin(預設/壞值)/ all / off', () => {
|
||||||
@@ -417,3 +419,526 @@ describe('mapGraphWorkflowOutput(#57 workflow 輸出 → plugin 形狀)', ()
|
|||||||
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
|
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════
|
||||||
|
|
||||||
|
describe('normalizeCjkQuery(t95 CJK/ASCII 邊界補空白)', () => {
|
||||||
|
it('純中文 → 不動', () => {
|
||||||
|
expect(normalizeCjkQuery('中文')).toBe('中文');
|
||||||
|
expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複
|
||||||
|
});
|
||||||
|
it('純 ASCII/數字 → 不動', () => {
|
||||||
|
expect(normalizeCjkQuery('ABC123')).toBe('ABC123');
|
||||||
|
expect(normalizeCjkQuery('')).toBe('');
|
||||||
|
});
|
||||||
|
it('CJK→ASCII 邊界插空白', () => {
|
||||||
|
expect(normalizeCjkQuery('協作AI')).toBe('協作 AI');
|
||||||
|
expect(normalizeCjkQuery('中文1234')).toBe('中文 1234');
|
||||||
|
});
|
||||||
|
it('ASCII→CJK 邊界插空白', () => {
|
||||||
|
expect(normalizeCjkQuery('AI協作')).toBe('AI 協作');
|
||||||
|
expect(normalizeCjkQuery('1234中文')).toBe('1234 中文');
|
||||||
|
});
|
||||||
|
it('已有空白不重複插', () => {
|
||||||
|
expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書');
|
||||||
|
});
|
||||||
|
it('全形符號(非 ASCII alnum)不觸發插空白', () => {
|
||||||
|
expect(normalizeCjkQuery('全形:中文')).toBe('全形:中文');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════
|
||||||
|
|
||||||
|
describe('findBestNodeMatch(t96 fuzzy 節點比對)', () => {
|
||||||
|
it('空清單 → null', () => {
|
||||||
|
expect(findBestNodeMatch('AI 協作', [])).toBeNull();
|
||||||
|
});
|
||||||
|
it('完全不包含 → null', () => {
|
||||||
|
expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull();
|
||||||
|
});
|
||||||
|
it('精確子字串命中 → 返回', () => {
|
||||||
|
expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書');
|
||||||
|
});
|
||||||
|
it('多命中 → 取最短(最精確優先)', () => {
|
||||||
|
const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']);
|
||||||
|
expect(result).toBe('AI'); // 最短
|
||||||
|
});
|
||||||
|
it('CJK 未正規化的搜尋詞也能比對(normalizeCjkQuery 先處理)', () => {
|
||||||
|
// 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」
|
||||||
|
expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書');
|
||||||
|
});
|
||||||
|
it('大小寫不敏感', () => {
|
||||||
|
expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════
|
||||||
|
|
||||||
|
describe('GET /portal/data/search(t95 CJK 正規化)', () => {
|
||||||
|
it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => {
|
||||||
|
await seedSession('tok-cn1', 'rec_3');
|
||||||
|
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||||
|
const cap = captureSearch();
|
||||||
|
await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' });
|
||||||
|
const sent = new URLSearchParams(cap.url().split('?')[1]);
|
||||||
|
expect(sent.get('q')).toBe('AI 協作'); // 已補空白
|
||||||
|
});
|
||||||
|
it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => {
|
||||||
|
await seedSession('tok-cn2', 'rec_3');
|
||||||
|
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||||
|
const cap = captureSearch();
|
||||||
|
await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' });
|
||||||
|
const sent = new URLSearchParams(cap.url().split('?')[1]);
|
||||||
|
expect(sent.get('q')).toBe('AI 協作'); // 無重複空白
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════
|
||||||
|
|
||||||
|
describe('GET /portal/data/graph/neighbors/:name(t96 fuzzy fallback)', () => {
|
||||||
|
it('plugin 精確命中有鄰居 → 直接回,不觸發 fallback', async () => {
|
||||||
|
await seedSession('tok-gf1', 'rec_a');
|
||||||
|
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||||
|
fetchMock
|
||||||
|
.get(GRAPH)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
|
||||||
|
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 });
|
||||||
|
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { neighbors: unknown[] };
|
||||||
|
expect(data.neighbors.length).toBe(1); // 有鄰居直接回
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => {
|
||||||
|
await seedSession('tok-gf2', 'rec_a');
|
||||||
|
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||||
|
// 精確命中「AI 協作」→ 0 鄰居
|
||||||
|
fetchMock
|
||||||
|
.get(GRAPH)
|
||||||
|
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
|
||||||
|
.reply(200, { neighbors: [], edges: [] });
|
||||||
|
// KBDB triplets → 含「AI 協作規範書」
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||||
|
.reply(200, {
|
||||||
|
records: [
|
||||||
|
{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } },
|
||||||
|
{ values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// fallback 以「AI 協作規範書」重查 → 有鄰居
|
||||||
|
fetchMock
|
||||||
|
.get(GRAPH)
|
||||||
|
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
|
||||||
|
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
|
||||||
|
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { neighbors: unknown[] };
|
||||||
|
expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => {
|
||||||
|
await seedSession('tok-gf3', 'rec_a');
|
||||||
|
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||||
|
fetchMock
|
||||||
|
.get(GRAPH)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
|
||||||
|
.reply(200, { neighbors: [], edges: [] });
|
||||||
|
// KBDB triplets → 完全沒有能比對的節點
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||||
|
.reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] });
|
||||||
|
const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
|
||||||
|
expect(data.neighbors.length).toBe(0); // 誠實回 0,不偽造
|
||||||
|
expect(data.edges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => {
|
||||||
|
await seedSession('tok-gf4', 'rec_a');
|
||||||
|
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||||
|
// plugin 收到的是正規化後的「AI 協作」(%20 分隔)
|
||||||
|
fetchMock
|
||||||
|
.get(GRAPH)
|
||||||
|
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
|
||||||
|
.reply(200, { neighbors: [], edges: [] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||||
|
.reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] });
|
||||||
|
fetchMock
|
||||||
|
.get(GRAPH)
|
||||||
|
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
|
||||||
|
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
|
||||||
|
// 前端傳「AI協作」(無空白,URL encoded)
|
||||||
|
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { neighbors: unknown[] };
|
||||||
|
expect(data.neighbors.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 12. t116: graph_neighbors workflow 補傳 kbdb_base ═══════════════
|
||||||
|
|
||||||
|
describe('GET /portal/data/graph/neighbors/:name(t116 kbdb_base 補傳)', () => {
|
||||||
|
it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_base,workflow 正常執行不崩', async () => {
|
||||||
|
// 設定 session(["*"] 全庫,放行 graph 粗閘)
|
||||||
|
await seedSession('tok-t116', 'rec_t116');
|
||||||
|
mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||||
|
|
||||||
|
// 在 WEBHOOKS KV 放 graph_neighbors workflow(Input→Output 直通)
|
||||||
|
// 這個 workflow 不用 {{input.kbdb_base}},只驗工作流路徑正常執行(不走 graphBase fallback)
|
||||||
|
// 若沒補傳 kbdb_base 但 workflow 內有 {{input.kbdb_base}} 的節點,URL 解析失敗 → executeWebhookGraph 回 error
|
||||||
|
// 此測試退而求其次:用無外部依賴的直通圖確認整個路徑都通(workflow 取代 plugin fallback)
|
||||||
|
const wfKey = `${TENANT}:wf:graph_neighbors`;
|
||||||
|
await env.WEBHOOKS.put(wfKey, JSON.stringify({
|
||||||
|
graph: {
|
||||||
|
id: 'gn-t116',
|
||||||
|
name: 'graph_neighbors',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'input', type: 'Input' },
|
||||||
|
// comp_passthrough 是內建零件,不需外部 fetch,直接回傳 context
|
||||||
|
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
|
||||||
|
{ id: 'output', type: 'Output' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'input', to: 'pass', type: 'PIPE' },
|
||||||
|
{ from: 'pass', to: 'output', type: 'PIPE' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
description: 't116 test',
|
||||||
|
created_at: '2026-07-29T00:00:00.000Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t116' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[]; count: number; kbdb_base?: string };
|
||||||
|
// workflow 走 comp_passthrough,output = 整個 context(含 kbdb_base)
|
||||||
|
// mapGraphWorkflowOutput 只取 neighbors/edges,其他欄位不影響回應
|
||||||
|
expect(Array.isArray(data.neighbors)).toBe(true);
|
||||||
|
expect(Array.isArray(data.edges)).toBe(true);
|
||||||
|
// 確認不是 502(graph_neighbors workflow 執行失敗)
|
||||||
|
expect(res.status).not.toBe(502);
|
||||||
|
|
||||||
|
await env.WEBHOOKS.delete(wfKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 13. t128: graph_neighbors workflow 補傳 template ═══════════════
|
||||||
|
|
||||||
|
describe('GET /portal/data/graph/neighbors/:name(t128 template 補傳)', () => {
|
||||||
|
it('tenant 有 graph_neighbors workflow → portal 傳入 template=triplet,workflow 不崩', async () => {
|
||||||
|
await seedSession('tok-t128', 'rec_t128');
|
||||||
|
mockGetRecord('rec_t128', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||||
|
|
||||||
|
const wfKey = `${TENANT}:wf:graph_neighbors`;
|
||||||
|
await env.WEBHOOKS.put(wfKey, JSON.stringify({
|
||||||
|
graph: {
|
||||||
|
id: 'gn-t128',
|
||||||
|
name: 'graph_neighbors',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'input', type: 'Input' },
|
||||||
|
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
|
||||||
|
{ id: 'output', type: 'Output' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'input', to: 'pass', type: 'PIPE' },
|
||||||
|
{ from: 'pass', to: 'output', type: 'PIPE' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t128' });
|
||||||
|
// template 有進 context → workflow 執行不崩(非 502)
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
|
||||||
|
expect(Array.isArray(data.neighbors)).toBe(true);
|
||||||
|
|
||||||
|
await env.WEBHOOKS.delete(wfKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 14. t129: dedupeSourcesByPage 純函式 ═══════════════
|
||||||
|
|
||||||
|
describe('dedupeSourcesByPage(t129 出處去重)', () => {
|
||||||
|
it('同 page_name 合併,hit_count 標計數', () => {
|
||||||
|
const srcs = [
|
||||||
|
{ page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
|
||||||
|
{ page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
|
||||||
|
{ page_name: '企業版功能', mode: 'keyword', source: 'gitea://docs/enterprise.md' },
|
||||||
|
];
|
||||||
|
const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
|
||||||
|
expect(out.length).toBe(1); // 3 筆→1 筆
|
||||||
|
expect(out[0].page_name).toBe('企業版功能');
|
||||||
|
expect(out[0].hit_count).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同 page_name 各保留一筆;單筆無 hit_count', () => {
|
||||||
|
const srcs = [
|
||||||
|
{ page_name: 'A 頁', mode: 'semantic' },
|
||||||
|
{ page_name: 'B 頁', mode: 'keyword' },
|
||||||
|
];
|
||||||
|
const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
|
||||||
|
expect(out.length).toBe(2);
|
||||||
|
expect(out.every(s => s.hit_count === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page 欄(備用)也能去重', () => {
|
||||||
|
const srcs = [
|
||||||
|
{ page: '備用頁', mode: 'semantic' },
|
||||||
|
{ page: '備用頁', mode: 'keyword' },
|
||||||
|
];
|
||||||
|
const out = dedupeSourcesByPage(srcs) as { page?: string; hit_count?: number }[];
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
expect(out[0].hit_count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空陣列 → 空陣列;非物件條目跳過', () => {
|
||||||
|
expect(dedupeSourcesByPage([])).toEqual([]);
|
||||||
|
const out = dedupeSourcesByPage([null, 'oops', { page_name: 'X' }]);
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page_name 優先於 page', () => {
|
||||||
|
const srcs = [
|
||||||
|
{ page_name: '優先頁', page: '備用頁' },
|
||||||
|
{ page_name: '優先頁', page: '備用頁' },
|
||||||
|
];
|
||||||
|
const out = dedupeSourcesByPage(srcs) as { hit_count?: number }[];
|
||||||
|
expect(out.length).toBe(1); // 同 page_name → 合為一筆
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 7. GET /portal/data/diagnostics(檢修孔,2026-08-07) ═══════════════
|
||||||
|
|
||||||
|
describe('GET /portal/data/diagnostics', () => {
|
||||||
|
it('未登入 → 401,不碰 KBDB', async () => {
|
||||||
|
const res = await get('/portal/data/diagnostics');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('登入 → 200,聚合 embed 健康狀態+規模統計(即時查,非 /map 快取)+版本;只含數字/布林/字串狀態', async () => {
|
||||||
|
// 2026-08-08 修復對應測試:library_count/triplet_count 改走 listRecordsByTemplate(portal_library)
|
||||||
|
// + /entries/libraries + /records/triplet-stats(與 GET /portal/admin/libraries 同一套即時查),
|
||||||
|
// 不再靠 /map(library_map 快取,recompute 從未被呼叫,恆回空——這正是 08-07 leo 實測抓到的病根)。
|
||||||
|
await seedSession('tok-diag1', 'rec_diag1');
|
||||||
|
mockGetRecord('rec_diag1', userValues());
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: true, pending: 3, embedded: 80 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' });
|
||||||
|
// 已登記庫:1 筆(kb),values 帶不該外流的內容欄位(display_name/description)驗紅線。
|
||||||
|
mockLibraryList([
|
||||||
|
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '不該出現在診斷檔', description: '密卡內容' } },
|
||||||
|
]);
|
||||||
|
// 資料裡實際蓋章出現過的庫:kb(與登記簿重複,去重)+notes(未登記但蓋章過,t52「蓋章即現身」)+general(fallback 桶,排除不算庫)。
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, libraries: ['general', 'kb', 'notes'], count: 3 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 40 }, { library: 'notes', triplet_count: 27 }] });
|
||||||
|
|
||||||
|
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
library_count: number;
|
||||||
|
triplet_count: number;
|
||||||
|
library_scope_check: { ran: boolean };
|
||||||
|
embedding: { module_enabled: boolean; cards_embedded: number; cards_pending: number; self_test: { ran: boolean; found_itself: boolean | null } };
|
||||||
|
instance_url: string;
|
||||||
|
bundle_version: string | null;
|
||||||
|
};
|
||||||
|
expect(body.library_count).toBe(2); // kb(登記簿+資料面重複,去重)+notes;general 不算
|
||||||
|
expect(body.triplet_count).toBe(67); // 40+27,實際聚合 SQL 算出,非快取
|
||||||
|
expect(body.library_scope_check.ran).toBe(false); // 數字不是 0,不需要自我探測
|
||||||
|
expect(body.embedding.module_enabled).toBe(true);
|
||||||
|
expect(body.embedding.cards_embedded).toBe(80);
|
||||||
|
expect(body.embedding.cards_pending).toBe(3);
|
||||||
|
expect(body.embedding.self_test.ran).toBe(true);
|
||||||
|
expect(body.embedding.self_test.found_itself).toBe(false);
|
||||||
|
expect(body.instance_url).toBe('http://localhost');
|
||||||
|
// 紅線斷言:整份回應不含知識卡內容本體(登記簿 values 裡的 display_name/description 沒被轉發,只取了 name 算數)
|
||||||
|
const raw = JSON.stringify(body);
|
||||||
|
expect(raw).not.toContain('不該出現在診斷檔');
|
||||||
|
expect(raw).not.toContain('密卡');
|
||||||
|
expect(raw).not.toContain('"kb"'); // 連庫名本身都不外流,只回數字
|
||||||
|
});
|
||||||
|
|
||||||
|
it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index;庫/三元組真的是空 → 自我探測也回空,不誤判為查詢錯誤', async () => {
|
||||||
|
await seedSession('tok-diag2', 'rec_diag2');
|
||||||
|
mockGetRecord('rec_diag2', userValues());
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
|
||||||
|
mockLibraryList([]);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, libraries: [], count: 0 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [] });
|
||||||
|
// library_count/triplet_count 都是 0 → 觸發自我探測;這裡探測也回真的空(total:0)。
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, entries: [], count: 0, total: 0 });
|
||||||
|
|
||||||
|
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
library_count: number;
|
||||||
|
triplet_count: number;
|
||||||
|
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
|
||||||
|
embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } };
|
||||||
|
};
|
||||||
|
expect(body.library_count).toBe(0);
|
||||||
|
expect(body.triplet_count).toBe(0);
|
||||||
|
expect(body.library_scope_check.ran).toBe(true);
|
||||||
|
expect(body.library_scope_check.any_entries_found).toBe(false);
|
||||||
|
expect(body.embedding.module_enabled).toBe(false);
|
||||||
|
expect(body.embedding.self_test.ran).toBe(false);
|
||||||
|
expect(body.embedding.self_test.found_itself).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('統計自我檢查抓到 t161 同型病:庫/三元組回 0,但這個租戶底下其實查得到其他資料 → 標「像是查詢方式或租戶對不上」而非誤判成真的沒有資料', async () => {
|
||||||
|
await seedSession('tok-diag3', 'rec_diag3');
|
||||||
|
mockGetRecord('rec_diag3', userValues());
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
|
||||||
|
mockLibraryList([]);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, libraries: [], count: 0 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [] });
|
||||||
|
// 自我探測:這個租戶底下其實有 12 筆 entries——庫/三元組統計卻回 0,兩者矛盾,該被標記。
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, entries: [{ id: 'e1' }], count: 1, total: 12 });
|
||||||
|
|
||||||
|
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag3' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
library_count: number;
|
||||||
|
triplet_count: number;
|
||||||
|
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
|
||||||
|
};
|
||||||
|
expect(body.library_count).toBe(0);
|
||||||
|
expect(body.triplet_count).toBe(0);
|
||||||
|
expect(body.library_scope_check.ran).toBe(true);
|
||||||
|
expect(body.library_scope_check.any_entries_found).toBe(true);
|
||||||
|
expect(body.library_scope_check.note).toContain('查詢方式或租戶對不上');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════ 8. GET /portal/daemon/diagnostics(t213,daemon 免帳密版檢修孔,2026-08-08) ═══════════
|
||||||
|
//
|
||||||
|
// 與上面 /portal/data/diagnostics 共用同一個 buildDiagnostics()(portal.ts)——這裡只驗證
|
||||||
|
// ①認證換了一套(X-Arcrun-API-Key,非 session)②apiKey 當 owner_id 打 KBDB,不與
|
||||||
|
// portalTenant(env)(='leo',見上方 TENANT 常數)比對/不要求相等(t189 教訓)③回應形狀
|
||||||
|
// 與 session 版一致。核心查詢邏輯已在上面 7 組測試驗過,這裡不重複。
|
||||||
|
describe('GET /portal/daemon/diagnostics(t213 daemon 版)', () => {
|
||||||
|
it('沒帶 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||||
|
const res = await get('/portal/daemon/diagnostics');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('帶 key(刻意與 CONSOLE_TENANT="leo" 不同)→ 200,且 KBDB 查詢用的 owner_id 是這把 key 本身,不是 leo(t189:不假設 apiKey===portalTenant)', async () => {
|
||||||
|
const daemonKey = 'yuga3bse'; // 刻意選一個跟 TENANT('leo') 不同的值,比照 t189 geek6688 案例
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: true, pending: 2, embedded: 9 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/selftest') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: true, tested: true, passed: true, note: '' });
|
||||||
|
mockLibraryList([{ record_id: 'rec_lib_kb2', values: { name: 'kb' } }]);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
|
||||||
|
.reply(200, { success: true, libraries: ['general', 'kb'], count: 2 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 9 }] });
|
||||||
|
|
||||||
|
const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
library_count: number;
|
||||||
|
triplet_count: number;
|
||||||
|
embedding: { module_enabled: boolean; cards_embedded: number };
|
||||||
|
instance_url: string;
|
||||||
|
bundle_version: string | null;
|
||||||
|
};
|
||||||
|
expect(body.library_count).toBe(1);
|
||||||
|
expect(body.triplet_count).toBe(9);
|
||||||
|
expect(body.embedding.module_enabled).toBe(true);
|
||||||
|
expect(body.embedding.cards_embedded).toBe(9);
|
||||||
|
expect(body.instance_url).toBe('http://localhost');
|
||||||
|
// 沒有任何 session 檢查——不打 SESSIONS_KV/portal_user record(本測試從未 seedSession/mockGetRecord
|
||||||
|
// 仍然 200,證明這條路徑真的不吃 session)。
|
||||||
|
});
|
||||||
|
|
||||||
|
it('回應形狀與 session 版一致(同一組欄位名)', async () => {
|
||||||
|
const daemonKey = 'shape-check-key';
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: '' });
|
||||||
|
mockLibraryList([]);
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, libraries: [], count: 0 });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, stats: [] });
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, entries: [], count: 0, total: 0 });
|
||||||
|
|
||||||
|
const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(Object.keys(body).sort()).toEqual(
|
||||||
|
['generated_at', 'instance_url', 'bundle_version', 'library_count', 'triplet_count', 'library_scope_check', 'embedding', 'notes'].sort(),
|
||||||
|
);
|
||||||
|
// t213 leo 08-08 指令:舊的「需在失敗當下截圖」那句已刪,notes 不該再含這句話。
|
||||||
|
expect(JSON.stringify(body.notes)).not.toContain('截圖');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* recipe payload 與回應處理層 —— CP `arcrun-usable` 步驟 5 缺口②
|
||||||
|
* SDD: workflow-discovery task 3.12
|
||||||
|
*
|
||||||
|
* 為什麼要有這三層(別刪):
|
||||||
|
* 舊 schema 只有 {canonical_id, endpoint, method, auth_service}(body 有但淺)
|
||||||
|
* ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code;
|
||||||
|
* 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式,換源必壞。
|
||||||
|
* leo:三層模型=①零件 ②auth recipe ③payload recipe,第③層過去不存在。
|
||||||
|
*
|
||||||
|
* 本檔測純函式層(body_template 插值 / response_map 正規化),
|
||||||
|
* 不打真外部 API——外部呼叫由 stage 端到端驗(features/09)。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderBodyTemplate, applyResponseMap } from '../src/lib/recipe-payload';
|
||||||
|
|
||||||
|
describe('body_template:payload 收回 recipe(第③層)', () => {
|
||||||
|
it('巢狀結構的 {{var}} 都會被替換(不只 top-level)', () => {
|
||||||
|
const out = renderBodyTemplate(
|
||||||
|
{ contents: [{ parts: [{ text: '{{prompt}}' }] }] },
|
||||||
|
{ prompt: '你好' },
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ contents: [{ parts: [{ text: '你好' }] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單一引用保留原型別(陣列/物件不被 stringify)', () => {
|
||||||
|
const out = renderBodyTemplate(
|
||||||
|
{ messages: '{{history}}', n: '{{count}}' },
|
||||||
|
{ history: [{ role: 'user' }], count: 3 },
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
expect(out.messages).toEqual([{ role: 'user' }]);
|
||||||
|
expect(out.n).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('混合文字仍拼成字串', () => {
|
||||||
|
const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record<string, unknown>;
|
||||||
|
expect(out.q).toBe('請回答:天氣');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支援 dot path 取值', () => {
|
||||||
|
const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, {
|
||||||
|
assemble: { data: { prompt: '深層值' } },
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
expect(out.t).toBe('深層值');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug)', () => {
|
||||||
|
const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record<string, unknown>;
|
||||||
|
expect(out.t).toBe('{{nope}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒有 body_template → 回 undefined(呼叫端沿用既有行為)', () => {
|
||||||
|
expect(renderBodyTemplate(undefined, { a: 1 })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('response_map:回應正規化(換源不必改 workflow)', () => {
|
||||||
|
const geminiBody = {
|
||||||
|
candidates: [{ content: { parts: [{ text: '【答】台北是首都' }] } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('path 取值:Gemini 形狀 → 純文字', () => {
|
||||||
|
const out = applyResponseMap(geminiBody, { text_path: 'candidates.0.content.parts.0.text' });
|
||||||
|
expect(out.text).toBe('【答】台北是首都');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('換源=換 recipe:Claude 形狀用不同 path,同樣取得出文字', () => {
|
||||||
|
const claudeBody = { content: [{ type: 'text', text: 'Claude 的答案' }] };
|
||||||
|
const out = applyResponseMap(claudeBody, { text_path: 'content.0.text' });
|
||||||
|
expect(out.text).toBe('Claude 的答案');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Workers AI 形狀(binding 回傳)同樣走 path', () => {
|
||||||
|
const waiBody = { response: 'Workers AI 的答案' };
|
||||||
|
const out = applyResponseMap(waiBody, { text_path: 'response' });
|
||||||
|
expect(out.text).toBe('Workers AI 的答案');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('思考型模型:thought=true 的 part 要被剔除,取最後一個非 thought', () => {
|
||||||
|
const gemma = {
|
||||||
|
candidates: [{
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{ text: '讓我想想…', thought: true },
|
||||||
|
{ text: '真正的答案' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const out = applyResponseMap(gemma, {
|
||||||
|
text_path: 'candidates.0.content.parts',
|
||||||
|
thinking_model: true,
|
||||||
|
});
|
||||||
|
expect(out.text).toBe('真正的答案');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('淨化規則:剝掉【答】前的草稿前綴(實撞三型之一)', () => {
|
||||||
|
const out = applyResponseMap(
|
||||||
|
{ r: 'Draft: 【答】正確內容' },
|
||||||
|
{ text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' },
|
||||||
|
);
|
||||||
|
expect(out.text).toBe('正確內容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('淨化規則:前綴組合順序不定 → 循環剝殼剝乾淨', () => {
|
||||||
|
const out = applyResponseMap(
|
||||||
|
{ r: 'Answer: * 【答】內容' },
|
||||||
|
{ text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' },
|
||||||
|
);
|
||||||
|
expect(out.text).toBe('內容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒有 response_map → 原樣回傳(既有 recipe 行為完全不變)', () => {
|
||||||
|
const out = applyResponseMap(geminiBody, undefined);
|
||||||
|
expect(out.text).toBeUndefined();
|
||||||
|
expect(out.raw).toEqual(geminiBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('path 取不到 → 誠實回 undefined,不編造', () => {
|
||||||
|
const out = applyResponseMap({ a: 1 }, { text_path: 'b.c.d' });
|
||||||
|
expect(out.text).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Arcrun#88:「零件目錄會說『這顆零件不存在』,但同一台實例上那顆零件跑得動」
|
||||||
|
*
|
||||||
|
* 病史(leo21c 實例實測,2026-08-11):
|
||||||
|
* `/cypher/search` 對 `if_control`/`http_request` 回 `not_found`,
|
||||||
|
* 但兩者其實由 component-loader.ts 直接解析(LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS),
|
||||||
|
* 從不查 registry;registry catalog 端點在該實例回 404(`GET /components/catalog` →
|
||||||
|
* `{"success":false,"error":"零件 catalog 不存在"}`),search 因此誤判成「兩庫都查過沒有」。
|
||||||
|
*
|
||||||
|
* 本測試複現病史的環境條件(wrangler.test.toml 未設 WORKER_SUBDOMAIN → registryBase
|
||||||
|
* undefined → catalog.status='unreachable',等價於「registry 完全連不到」),
|
||||||
|
* 驗證修法:RUNTIME_NATIVE_COMPONENT_IDS 的成員必須在 registry 查詢**之前**就短路成 found,
|
||||||
|
* 不受 registry 健康狀態影響——因為它們的存在性從不依賴 registry。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { parseTriplets } from '../src/actions/triplet-parser';
|
||||||
|
import { searchNodes } from '../src/actions/search-nodes';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const IF_CONTROL_TRIPLETS = [
|
||||||
|
'input >> ON_SUCCESS >> if_control',
|
||||||
|
];
|
||||||
|
|
||||||
|
const HTTP_REQUEST_TRIPLETS = [
|
||||||
|
'input >> ON_SUCCESS >> http_request',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MULTI_BUILTIN_TRIPLETS = [
|
||||||
|
'input >> ON_SUCCESS >> switch',
|
||||||
|
'input >> ON_SUCCESS >> filter',
|
||||||
|
'input >> ON_SUCCESS >> code',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAKE_COMPONENT_TRIPLETS = [
|
||||||
|
'input >> ON_SUCCESS >> totally_made_up_component_xyz',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Arcrun#88:執行期原生零件不受 registry 健康狀態影響', () => {
|
||||||
|
it('if_control(LOGIC_BINDING_MAP 成員)在 registry 不可達時仍回 found', async () => {
|
||||||
|
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
|
||||||
|
// 無 WORKER_SUBDOMAIN/REGISTRY_BASE_URL → registryBase undefined → catalog unreachable
|
||||||
|
});
|
||||||
|
expect(nodeResults.if_control.status).toBe('found');
|
||||||
|
expect(nodeResults.if_control.source).toBe('builtin');
|
||||||
|
// if_control 會分岔,branch_hint 應隨 found 一併附上(不必逐顆再查一次)
|
||||||
|
expect(nodeResults.if_control.branch_hint?.edge_types).toEqual(['ON_TRUE', 'ON_FALSE']);
|
||||||
|
expect(missingNodes).not.toContain('if_control');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('http_request(WASM_HTTP_RUNNER_IDS 成員)在 registry 不可達時仍回 found', async () => {
|
||||||
|
const parsed = parseTriplets(HTTP_REQUEST_TRIPLETS);
|
||||||
|
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {});
|
||||||
|
expect(nodeResults.http_request.status).toBe('found');
|
||||||
|
expect(nodeResults.http_request.source).toBe('builtin');
|
||||||
|
expect(missingNodes).not.toContain('http_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switch/filter/code(同一批白名單的其他成員)也回 found,不逐一漏網', async () => {
|
||||||
|
const parsed = parseTriplets(MULTI_BUILTIN_TRIPLETS);
|
||||||
|
const { nodeResults } = await searchNodes(parsed!, undefined, {});
|
||||||
|
expect(nodeResults.switch.status).toBe('found');
|
||||||
|
expect(nodeResults.filter.status).toBe('found');
|
||||||
|
expect(nodeResults.code.status).toBe('found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registry 完全連不到時,真正不存在的名字誠實回 unknown(不敢空口說沒有——既有行為,修法沒有動它)', async () => {
|
||||||
|
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
|
||||||
|
const { nodeResults } = await searchNodes(parsed!, undefined, {});
|
||||||
|
expect(nodeResults.totally_made_up_component_xyz.status).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registry 查得到但目錄是空的(複現 leo21c 實例 catalog 404 的真實症狀):真正不存在的名字回 not_found', async () => {
|
||||||
|
// 複現生產實測:GET /components/catalog → HTTP 200 空陣列(本測試模擬「registry 活著但沒東西」,
|
||||||
|
// 與 leo21c 實例的「404 零件 catalog 不存在」殊途同歸——都會落到「查得到、目錄無此零件」)。
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
|
||||||
|
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
|
||||||
|
WORKER_SUBDOMAIN: 'test-sub',
|
||||||
|
});
|
||||||
|
expect(nodeResults.totally_made_up_component_xyz.status).toBe('not_found');
|
||||||
|
expect(missingNodes).toContain('totally_made_up_component_xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registry 目錄是空的(catalog 通但無資料)時,執行期原生零件依然 found——這才是 Arcrun#88 的核心場景', async () => {
|
||||||
|
// 這就是 leo21c 實例的真實狀態:registry 活著、目錄卻沒有任何一顆執行期原生零件的記錄
|
||||||
|
// (SUBMISSIONS_KV 從未收到 if_control/http_request 的 submit)。若沒有本次修法,
|
||||||
|
// 這裡會落到「兩庫都查過沒有」→ not_found,正是 Arcrun#88 回報的病徵。
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||||
|
const { nodeResults } = await searchNodes(parsed!, undefined, { WORKER_SUBDOMAIN: 'test-sub' });
|
||||||
|
expect(nodeResults.if_control.status).toBe('found');
|
||||||
|
expect(nodeResults.if_control.source).toBe('builtin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('target=recipe 明確只查 recipe 庫時,執行期原生零件不搶答 found(尊重使用者明確限庫)', async () => {
|
||||||
|
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||||
|
const { nodeResults } = await searchNodes(parsed!, undefined, {}, 'discover', 'recipe');
|
||||||
|
// if_control 從來不是 recipe,target=recipe 下不該被 builtin 短路成 found
|
||||||
|
expect(nodeResults.if_control.status).not.toBe('found');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* CP `arcrun-usable` 步驟 5 驗收(SDD workflow-discovery task 3.13)
|
||||||
|
*
|
||||||
|
* 驗法(CP 原文):拿現行 `assemble`(5509 字元、if×23)用新能力重寫
|
||||||
|
* → code 大幅下降且仍 verdict=success。
|
||||||
|
*
|
||||||
|
* 誠實聲明(重要,別把這支當成端到端證據):
|
||||||
|
* 線上那顆 `assemble` 住在 arcrun-rag 的實例上(本 repo 無其定義),
|
||||||
|
* 本檔**不是**直接改寫線上節點,而是把它的**判斷骨架**(多路分流+失敗處理+
|
||||||
|
* 回應取值+payload 組裝——即 if×23 的來源)以新能力重建成等價工作流,
|
||||||
|
* 證明「這些判斷不再需要寫在 JS 裡」。
|
||||||
|
* 線上節點的真正改寫=stage 端到端(features/09),不在單元測試層宣稱。
|
||||||
|
*
|
||||||
|
* 對照基準(08-01 實測,來源:頂層 pending-changes「零件層系統性違規盤點」段):
|
||||||
|
* rag_chat 的 assemble=5509 字元、if×23、for×12
|
||||||
|
*/
|
||||||
|
import { SELF } from 'cloudflare:test';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
async function execute(graph: unknown, context: Record<string, unknown> = {}) {
|
||||||
|
const res = await SELF.fetch('http://localhost/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ graph, context }),
|
||||||
|
});
|
||||||
|
return (await res.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
trace?: Array<{ nodeId: string }>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('步驟 5 驗收:判斷骨架不再需要 code 節點', () => {
|
||||||
|
/**
|
||||||
|
* 舊寫法的形狀(assemble 那 5509 字元在做的事):
|
||||||
|
* 一個 code 節點內部 if×23 —— 判斷資料有沒有/走哪一路/失敗了怎麼辦/
|
||||||
|
* 從回應裡挖哪個欄位/把 payload 拼出來。
|
||||||
|
* 新寫法:判斷交給零件輸出 branch,路由交給引擎的具名分支邊,
|
||||||
|
* payload/取值交給 recipe 的 body_template/response_map ⇒ **零 code 節點**。
|
||||||
|
*/
|
||||||
|
it('多路分流+失敗路:三條路各自到位,全程零 code 節點', async () => {
|
||||||
|
const graph = {
|
||||||
|
id: 'step5-acceptance',
|
||||||
|
name: '步驟5 驗收:assemble 判斷骨架重寫',
|
||||||
|
nodes: [
|
||||||
|
// if_control/switch 形狀的輸出(線上是零件算出來的,這裡直接餵形狀)
|
||||||
|
{ id: 'route', type: 'Input', data: { success: true, data: { branch: 'has_data' } } },
|
||||||
|
{ id: 'handle_data', type: 'Component', componentId: 'comp_uppercase', data: { text: 'has-data' } },
|
||||||
|
{ id: 'handle_empty', type: 'Component', componentId: 'comp_uppercase', data: { text: 'empty' } },
|
||||||
|
{ id: 'handle_error', type: 'Component', componentId: 'comp_uppercase', data: { text: 'error' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'route', to: 'handle_data', type: 'ON_BRANCH', branch: 'has_data' },
|
||||||
|
{ from: 'route', to: 'handle_empty', type: 'ON_BRANCH', branch: 'empty' },
|
||||||
|
{ from: 'route', to: 'handle_error', type: 'ON_BRANCH', branch: 'error' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = await execute(graph);
|
||||||
|
const visited = (out.trace ?? []).map(t => t.nodeId);
|
||||||
|
|
||||||
|
expect(out.success).toBe(true); // = verdict success
|
||||||
|
expect(visited).toContain('handle_data');
|
||||||
|
expect(visited).not.toContain('handle_empty');
|
||||||
|
expect(visited).not.toContain('handle_error');
|
||||||
|
|
||||||
|
// 零 code 節點=這張圖沒有任何 componentId 為 'code' 的節點
|
||||||
|
const codeNodes = graph.nodes.filter(n => n.componentId === 'code');
|
||||||
|
expect(codeNodes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('布林兩路(if_control)同樣零 code', async () => {
|
||||||
|
const graph = {
|
||||||
|
id: 'step5-bool',
|
||||||
|
name: '布林兩路',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' } } },
|
||||||
|
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||||
|
{ id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||||
|
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = await execute(graph);
|
||||||
|
const visited = (out.trace ?? []).map(t => t.nodeId);
|
||||||
|
expect(out.success).toBe(true);
|
||||||
|
expect(visited).toContain('no');
|
||||||
|
expect(visited).not.toContain('yes');
|
||||||
|
expect(graph.nodes.filter(n => n.componentId === 'code')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('步驟 5 驗收:字元數對照(判斷骨架的體積)', () => {
|
||||||
|
/**
|
||||||
|
* 把「同一組判斷」用兩種寫法各寫一次,量體積。
|
||||||
|
* 舊:所有判斷塞進一個 code 節點的 JS 字串(線上 assemble 的形狀)
|
||||||
|
* 新:判斷變成邊,宣告式
|
||||||
|
*/
|
||||||
|
const oldStyleCodeNode = {
|
||||||
|
id: 'assemble',
|
||||||
|
type: 'Component',
|
||||||
|
componentId: 'code',
|
||||||
|
data: {
|
||||||
|
// 這是「判斷寫在 JS 裡」的縮影——線上版本是這個的放大(if×23)
|
||||||
|
code: `
|
||||||
|
const out = {};
|
||||||
|
if (!ctx.rows || ctx.rows.length === 0) { out.branch = 'empty'; }
|
||||||
|
else if (ctx.error) { out.branch = 'error'; }
|
||||||
|
else { out.branch = 'has_data'; }
|
||||||
|
if (out.branch === 'has_data') {
|
||||||
|
if (ctx.mode === 'strict') { out.text = ctx.rows[0].text; }
|
||||||
|
else if (ctx.mode === 'loose') { out.text = ctx.rows.map(r => r.text).join('\\n'); }
|
||||||
|
else { out.text = String(ctx.rows[0] && ctx.rows[0].text || ''); }
|
||||||
|
if (out.text.indexOf('【答】') >= 0) {
|
||||||
|
out.text = out.text.slice(out.text.lastIndexOf('【答】') + 3);
|
||||||
|
}
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
out.text = out.text.trimStart();
|
||||||
|
for (const p of ['Draft:', '*', 'Answer:']) {
|
||||||
|
if (out.text.startsWith(p)) { out.text = out.text.slice(p.length); changed = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (out.branch === 'error') {
|
||||||
|
out.text = 'failed: ' + String(ctx.error);
|
||||||
|
} else {
|
||||||
|
out.text = '';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const newStyleEdges = [
|
||||||
|
{ from: 'route', to: 'handle_data', type: 'ON_BRANCH', branch: 'has_data' },
|
||||||
|
{ from: 'route', to: 'handle_empty', type: 'ON_BRANCH', branch: 'empty' },
|
||||||
|
{ from: 'route', to: 'handle_error', type: 'ON_BRANCH', branch: 'error' },
|
||||||
|
];
|
||||||
|
// 淨化/取值不再手寫,改成 recipe 的宣告(隨 recipe 走,換源不必改 workflow)
|
||||||
|
const newStyleResponseMap = {
|
||||||
|
text_path: 'candidates.0.content.parts',
|
||||||
|
thinking_model: true,
|
||||||
|
answer_marker: '【答】',
|
||||||
|
strip_prefixes: ['Draft:', '*', 'Answer:'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('新寫法的體積顯著小於舊寫法,且判斷全部離開 JS', () => {
|
||||||
|
const oldChars = JSON.stringify(oldStyleCodeNode).length;
|
||||||
|
const newChars =
|
||||||
|
JSON.stringify(newStyleEdges).length + JSON.stringify(newStyleResponseMap).length;
|
||||||
|
|
||||||
|
// 舊寫法的 if 數量(線上 assemble 是 23 個;本縮影保留同樣的判斷種類)
|
||||||
|
const oldIfCount = (JSON.stringify(oldStyleCodeNode).match(/if\s*\(/g) ?? []).length;
|
||||||
|
const newIfCount = 0; // 宣告式,沒有任何 if
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[步驟5 驗收] 舊寫法 ${oldChars} 字元 / if×${oldIfCount} → ` +
|
||||||
|
`新寫法 ${newChars} 字元 / if×${newIfCount} ` +
|
||||||
|
`(下降 ${Math.round((1 - newChars / oldChars) * 100)}%)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newChars).toBeLessThan(oldChars);
|
||||||
|
expect(newIfCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,3 +49,11 @@ KBDB_BASE_URL = "https://kbdb.test"
|
|||||||
CONSOLE_TENANT = "leo"
|
CONSOLE_TENANT = "leo"
|
||||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||||
KBDB_GRAPH_URL = "https://graph.test"
|
KBDB_GRAPH_URL = "https://graph.test"
|
||||||
|
# D61(ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
|
||||||
|
# Scripts secrets 管理 API(https://api.cloudflare.com/...),authStoreWritable() 只看這兩項
|
||||||
|
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
|
||||||
|
# PUT/DELETE 呼叫一律靠 tests/*.ts 裡的 fetchMock 攔截,不外連。要測「寫入路徑未就緒」
|
||||||
|
# 的分支才需要繞過 SELF、直接呼叫 router.fetch(req, fakeEnv, ctx) 帶缺項的 env(見
|
||||||
|
# tests/health.test.ts 既有前例)。
|
||||||
|
CF_SECRETS_API_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,見上方註解
|
||||||
|
CF_ACCOUNT_ID = "test-account"
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 零件 / binding PR 審核規範
|
||||||
|
|
||||||
|
> **為什麼有這份**:靠「AI 記住規則」防架構錯誤不 scale(規定說幾次都沒用)。零件與 service binding 本來就**要走 PR**——所以把錯誤擋在 **PR 審核這道結構性閘**,讓錯誤路徑「根本碰不到」,不靠自覺。
|
||||||
|
> **適用**:任何「新增/改一個 component」或「改 worker binding(`[[services]]` 等)」或「新增 workflow/部署到 cypher」的 PR。
|
||||||
|
> **審核者**:先由 reviewer(AI subagent 戴 reviewer 人格)逐條過;未過不得 merge/deploy。逐步補機械化 CI(見文末)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist(逐條,任一「否」→ 打回)
|
||||||
|
|
||||||
|
### A. 這東西該不該存在(反過度工程,D27)
|
||||||
|
- [ ] **新增命名零件?** 只准當它是「**常用、多人/多 workflow 會用的可複用原語**」(如 http_request/cron)。
|
||||||
|
- 一次性 / 專案專用邏輯 → **打回**,改用通用 **`code` 零件**內聯。
|
||||||
|
- 判準:**三個月後會有第二個 workflow 用它嗎?** 不會=不是零件。
|
||||||
|
- 反例:`km_wiki_card_parse`(card→envelope 一次性解析)被否。
|
||||||
|
|
||||||
|
### B. binding 層級對不對(D28,最常踩)
|
||||||
|
- [ ] **用了 Service Bindings(`[[services]]` / `env.SVC.fetch()`)?**
|
||||||
|
- 只准**唯一例外**:把**幾個 wasm 綁成「一個複合零件」**(零件等級的組合)。
|
||||||
|
- **跨-worker 編排**(workflow 串多個 worker/零件,如 ingest 串 code+kbdb+graph)→ **打回**,走 **cypher binding=跑成 cypher 上的 workflow**。
|
||||||
|
- 判準:這是「**零件內部組 wasm**」還是「**工作流編排多 worker**」?後者一律 cypher binding。
|
||||||
|
- 反例:ingest drainer 自建 standalone worker + service binding 串 code/kbdb/graph=錯位,被否。
|
||||||
|
|
||||||
|
### C. 零件 contract 合規
|
||||||
|
- [ ] stdin JSON → stdout JSON;`no_network`/`no_filesystem`(除非明確申報且審核放行);資源限制(timeout/mem/輸出/code 上限);錯誤**結構化回傳**(不讓 Worker 掛)。
|
||||||
|
|
||||||
|
### D. 驗證誠實(測試≠執行路徑)
|
||||||
|
- [ ] 驗證打的是**部署後的真端點**,不是只 `wrangler dev`/miniflare 本地(本地不強制 worker-to-worker 等生產限制,會假綠)。**禁假綠。**
|
||||||
|
- 反例:drainer 本地 miniflare 綠、production 撞 1042。
|
||||||
|
|
||||||
|
### E. 部署 / 資料鐵律
|
||||||
|
- [ ] 部署 **wrangler 直推**、**不用 `acr update`**(部署源綁 GitHub codeload,會假綠蓋改動)。
|
||||||
|
- [ ] account 正確(self-hosted=leo21c;別讓 repo `.env` 的官方帳號 id 污染)。
|
||||||
|
- [ ] 碰 KBDB=**零建表、全走 base API、零 SQL**(插件層)。
|
||||||
|
|
||||||
|
### F. 走 PR(結構性,不繞道)
|
||||||
|
- [ ] component/binding/workflow 變更**走 PR + 本規範審核 + merge 才 deploy**,不 ad-hoc 從 branch 直接 wrangler deploy 上 production。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 機械化補強(讓它「根本碰不到」,待實作)
|
||||||
|
逐步把可機械判的移到 CI,PR 命中即 fail,不等人審:
|
||||||
|
- lint `wrangler.toml` 出現 `[[services]]` → 標記需 B 條人工放行理由(wasm-composite 例外)。
|
||||||
|
- 偵測新增 `registry/components/<name>/` 目錄 → 要求 A 條「可複用原語」論證。
|
||||||
|
- 偵測 KBDB migration/`CREATE TABLE` → 直接 fail(鐵律)。
|
||||||
|
- 偵測 `acr update` 於部署腳本 → 警告。
|
||||||
|
|
||||||
|
## 對應決策
|
||||||
|
D27(一次性用 code 零件不鑄 domain 零件)、D28(跨-worker 走 cypher binding 不走 service binding)、KBDB 鐵律(D6)、測試≠執行路徑(mistakes)。
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
-- credential-primitives-wasm — credential-store-migration T2(D19:D1 只存目錄,不存密文)
|
-- credential-primitives-wasm — credential-store-migration T2(D19:D1 只存目錄,不存密文)
|
||||||
-- SDD: system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md §2.2
|
-- SDD: system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md §2.2
|
||||||
--
|
--
|
||||||
|
-- ⚠️ 已退役(D38 圍牆修復,2026-08-07):本檔在 KBDB 裡多開了一張獨立表,違反「KBDB 只有
|
||||||
|
-- 三張核心表」的鐵律(見 kbdb-usage skill「反例」)。deploy.ts 已不再套用本檔——新裝置改跑
|
||||||
|
-- 0005_credential_template.sql(template 定義)+ 0006_drop_credentials_table.sql(把舊資料
|
||||||
|
-- 搬進 entries 後拆表)。本檔保留純供歷史對照(欄位定義與 0005 的 slots_json 一字對應),
|
||||||
|
-- 不要再照抄這個形狀;新資料類型請照 0003/0004/0005 的手法(template + entries)。
|
||||||
|
--
|
||||||
-- 密文本體不在這裡:值住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理 API 唯寫)。
|
-- 密文本體不在這裡:值住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理 API 唯寫)。
|
||||||
-- 這張表只存「目錄」:租戶(api_key) / 名字 / 服務 / 敏感度 / 指向 Workers Secrets 的 env var 名(secret_ref)。
|
-- 這張表只存「目錄」:租戶(api_key) / 名字 / 服務 / 敏感度 / 指向 Workers Secrets 的 env var 名(secret_ref)。
|
||||||
-- 冪等(IF NOT EXISTS),與 0001_base.sql 同模式,套用機制走 cli/src/lib/deploy.ts applyD1Migration。
|
-- 冪等(IF NOT EXISTS),與 0001_base.sql 同模式,套用機制走 cli/src/lib/deploy.ts applyD1Migration。
|
||||||
-- 同一顆 D1(與 KBDB base 共用 arcrun-kbdb),不新建第二顆。
|
-- 同一顆 D1(與 KBDB base 共用 arcrun-kbdb),不新建第二顆。
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS credentials (
|
CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 已退役的歷史存底,deploy.ts 不再套用本檔(改跑 0005+0006),保留純供欄位對照
|
||||||
api_key TEXT NOT NULL, -- 租戶
|
api_key TEXT NOT NULL, -- 租戶
|
||||||
name TEXT NOT NULL, -- credential 名(= auth-recipe required_secrets[].key,如 telegram_bot_token)
|
name TEXT NOT NULL, -- credential 名(= auth-recipe required_secrets[].key,如 telegram_bot_token)
|
||||||
service TEXT, -- 對應 service(telegram / notion …),可空
|
service TEXT, -- 對應 service(telegram / notion …),可空
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- execution_log template seed — KV 額度事故修復(總管交辦,2026-08-07)
|
||||||
|
-- SDD:無專屬 SDD(事故修復任務)。root cause:cypher-executor/src/actions/execution-logger.ts
|
||||||
|
-- 舊版每跑完一次 workflow 就 ANALYTICS_KV.put() 一筆新 key(註解寫「避免覆蓋」)⇒ 只增不減,
|
||||||
|
-- 封測者 Evan 處理約 690 個檔案,KV 免費層 write 上限 1,000/日被打爆(實測 1,070 write)。
|
||||||
|
--
|
||||||
|
-- KBDB 鐵律(leo 2026-06-14):三張表打天下,永遠不加新 table,新資料類型一律用 template。
|
||||||
|
-- 本檔**零 schema 異動**——只 INSERT OR IGNORE 一列 template 定義,手法與本檔同目錄
|
||||||
|
-- 0001_base.sql §3(seed tpl-recipe-stat)完全相同。
|
||||||
|
--
|
||||||
|
-- 儲存精神比照既有 recipe_stat(kbdb/src/actions/recipe-stat.ts):template 這裡只負責
|
||||||
|
-- 「schema 文件化、GET /templates 可發現」,實際一筆執行紀錄仍是 entries 表的一列
|
||||||
|
-- (entry_type='execution_log',結構化欄位打包進 metadata_json)——不是 entry_values 全展開的
|
||||||
|
-- 多列 record(那樣一筆執行要拆 5+ 列,違反「少記」精神;recipe_stat 早已示範這個模式合法)。
|
||||||
|
-- 實作見 kbdb/src/actions/execution-log.ts。
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
|
||||||
|
VALUES (
|
||||||
|
'tpl-execution-log',
|
||||||
|
'execution_log',
|
||||||
|
'workflow 執行紀錄(KV 額度事故修復;欄位收斂=少記,成功記最少/失敗記多一點,見 execution-log.ts)',
|
||||||
|
'["workflow_id","verdict","duration_ms","message","target","api_key"]',
|
||||||
|
'system'
|
||||||
|
);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// credential-legacy-migration.ts — 「新讀取端上線、舊資料還沒搬完」的自癒補丁
|
||||||
|
// (D38 圍牆修復收尾,總管交辦,2026-08-08)。
|
||||||
|
//
|
||||||
|
// ── 為什麼這支檔案存在 ────────────────────────────────────────────────────
|
||||||
|
// 7ba7855(D38 圍牆修復)把 credential 目錄的讀寫端從舊表 `credentials`(0002,違規多開
|
||||||
|
// 的第四張表)改成走 entries 表(entry_type='credential')。0006_drop_credentials_table.sql
|
||||||
|
// 寫了「把舊表資料搬進 entries 後讓舊表退場」的一次性 migration,但這支 migration **要有人
|
||||||
|
// 手動觸發部署才會跑**——2026-08-07 youlin 測試實例的事故就是「code 部署了、migration 沒
|
||||||
|
// 跑」造成 20/20 workflow 全部找不到 credential。
|
||||||
|
//
|
||||||
|
// leo 追加的硬要求(2026-08-08):credential 資料住在**用戶自己的 Cloudflare 帳號**,
|
||||||
|
// 換讀取路徑=每個既有實例的資料都要跟著搬,但**用戶不准做任何手動步驟**——不能要求他
|
||||||
|
// 跑指令、改設定、重裝。搬遷必須內建在「用戶本來就會走的路」裡(因此天然無感)。
|
||||||
|
//
|
||||||
|
// ── 解法:把「搬」變成「讀」的副作用,而不是獨立一步 ─────────────────────
|
||||||
|
// KBDB worker(本檔)是 D38 唯一允許碰 SQL 的地方(牆內)。這裡在**每次查詢某租戶的
|
||||||
|
// credential 目錄之前**,先確認舊表資料是否已經搬進 entries——沒有就搬(scoped 到這個
|
||||||
|
// owner_id,NOT EXISTS 防重複),有就是零成本的一次 sqlite_master 檢查。
|
||||||
|
//
|
||||||
|
// 呼叫時機只有一個:cypher-executor 的 credentials.ts 熱路徑(getCredentialDirectory /
|
||||||
|
// findCredentialEntry)本來就會在**每次 workflow 執行**打一次 GET /entries?entry_type=
|
||||||
|
// credential&owner_id=X(60 秒快取未命中時)。只要 KBDB worker 部署了本檔的邏輯,
|
||||||
|
// 下一次任何人跑 workflow,那個租戶的資料就自動搬好了——**不需要用戶多做任何事**,
|
||||||
|
// 也不需要「更新流程」額外呼叫一支新端點:更新 KBDB worker 本身就是唯一需要發生的事,
|
||||||
|
// 之後的搬遷由使用行為自然觸發。
|
||||||
|
//
|
||||||
|
// ── 三個安全性質(都經得起故意製造壞狀態來驗證,見 tests/credential-legacy-migration.test.ts)──
|
||||||
|
// 1. 冪等:NOT EXISTS 防止同一筆搬兩次;同一個 owner 呼叫 N 次只搬一次。
|
||||||
|
// 2. 對「已經搬過」與「還沒搬」的實例都正確:已搬過 → legacyTableExists 一旦舊表被真的
|
||||||
|
// 清空退場(未來清理步驟)就直接短路回 false,query 零成本;還沒搬 → 這次呼叫就地補齊。
|
||||||
|
// 3. 不砍表:本檔刻意不執行「讓舊表退場」那句 SQL——多個實例的搬遷時間點不同,
|
||||||
|
// 表還留著才能讓「還沒搬的」與「已經搬的」實例同時安全運作(leo 08-08:
|
||||||
|
// 「他們會同時存在一段時間」)。退場是之後所有租戶都確認搬完才做的獨立清理步驟。
|
||||||
|
|
||||||
|
/** 舊表是否還存在(sqlite_master 查詢,索引命中、幾乎零成本)。
|
||||||
|
* 一旦舊表被清理步驟真的清空退場,這裡會回 false,後續呼叫直接短路,不再嘗試搬遷。 */
|
||||||
|
async function legacyCredentialsTableExists(db: D1Database): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = 'credentials'`)
|
||||||
|
.first<{ x: number }>();
|
||||||
|
return row !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把某個租戶(owner_id=api_key)在舊 `credentials` 表裡、entries 還沒有對應列的 row
|
||||||
|
* 搬進 entries(entry_type='credential')。scoped 到單一 owner,故查詢便宜,可安全地在
|
||||||
|
* 熱路徑(每次 workflow 執行)前呼叫。
|
||||||
|
*
|
||||||
|
* 欄位對應與 0006_drop_credentials_table.sql 逐字一致(page_name=name 冪等鍵,
|
||||||
|
* metadata_json 打包 service/sensitivity/secret_ref/last_used_at)。
|
||||||
|
*
|
||||||
|
* @returns 實際搬移的筆數(0 = 這個 owner 沒有待搬資料,含「舊表本來就不存在」與
|
||||||
|
* 「已經搬過」兩種情況——呼叫端不需要分辨,行為一致)。
|
||||||
|
*/
|
||||||
|
export async function migrateLegacyCredentialsForOwner(db: D1Database, ownerId: string): Promise<number> {
|
||||||
|
if (!ownerId) return 0; // 沒有 owner_id 的查詢(極少見)不觸發:搬遷是 per-tenant 動作,範圍不明確就不做
|
||||||
|
if (!(await legacyCredentialsTableExists(db))) return 0; // 舊表不存在(從未有 / 已清理)→ 零成本短路
|
||||||
|
|
||||||
|
const before = await db
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`)
|
||||||
|
.bind(ownerId)
|
||||||
|
.first<{ n: number }>();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
'e_cred_' || lower(hex(randomblob(8))),
|
||||||
|
'credential',
|
||||||
|
c.api_key,
|
||||||
|
c.name,
|
||||||
|
json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at),
|
||||||
|
c.created_at,
|
||||||
|
unixepoch()
|
||||||
|
FROM credentials c
|
||||||
|
WHERE c.api_key = ?1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM entries e
|
||||||
|
WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.bind(ownerId)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const after = await db
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`)
|
||||||
|
.bind(ownerId)
|
||||||
|
.first<{ n: number }>();
|
||||||
|
|
||||||
|
return (after?.n ?? 0) - (before?.n ?? 0);
|
||||||
|
}
|
||||||
@@ -84,13 +84,21 @@ export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Pr
|
|||||||
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
|
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
|
||||||
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
|
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
|
||||||
if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); }
|
if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); }
|
||||||
if (f.q) { conds.push('content LIKE ?'); params.push(`%${f.q}%`); }
|
if (f.q) {
|
||||||
|
const m = buildContentLike(f.q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
||||||
|
conds.push(...m.conds); params.push(...m.params);
|
||||||
|
}
|
||||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||||
const limit = Math.min(f.limit ?? 100, 1000);
|
const limit = Math.min(f.limit ?? 100, 1000);
|
||||||
const offset = f.offset ?? 0;
|
const offset = f.offset ?? 0;
|
||||||
const [rowsRes, countRow] = await Promise.all([
|
const [rowsRes, countRow] = await Promise.all([
|
||||||
db
|
db
|
||||||
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
// `, rowid DESC` 二級排序(KV 額度事故修復,2026-08-07 發現):created_at 是
|
||||||
|
// unixepoch()=秒級解析度,高頻寫入(例如 execution_log 一秒內多筆執行)常同秒,
|
||||||
|
// 單靠 created_at DESC 的同分排序不保證插入序,「最新一筆」可能取到錯的一列。
|
||||||
|
// rowid 是 SQLite/D1 一般表的隱含遞增欄,同分時退回插入序,不改變既有排序結果
|
||||||
|
// (created_at 不同時完全一字不變),純粹補上同分時的決定性。
|
||||||
|
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`)
|
||||||
.bind(...params, limit, offset)
|
.bind(...params, limit, offset)
|
||||||
.all<Entry>(),
|
.all<Entry>(),
|
||||||
db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first<{ total: number }>(),
|
db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first<{ total: number }>(),
|
||||||
@@ -126,6 +134,332 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
|||||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。
|
||||||
|
* 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。
|
||||||
|
* 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 撈出某 owner 下某庫、**目前還有向量**的 entry id(供下架時連帶清向量用)。
|
||||||
|
*
|
||||||
|
* 🔴 2026-08-05 leo:「已經被刪掉的內容?理論上它的向量也要刪掉,就不會有殘影了吧?」——對。
|
||||||
|
* 單筆真刪(`DELETE /entries/:id`)已經接了 `VECTORIZE.deleteByIds`(b7af622),
|
||||||
|
* 但「移除整個庫」走軟刪(只標 status),**向量原地不動** ⇒ 殘影就是這樣長出來的:
|
||||||
|
* 搜尋端每次都要靠事後過濾擋它,而它還會頂著高分去影響門檻計算。
|
||||||
|
* ⇒ 標 deprecated 的同時把向量刪掉,讓殘影**在源頭就不存在**。
|
||||||
|
* 不違背 t135「資料保留可還原」:**D1 那列原封不動**,還原後跑
|
||||||
|
* `POST /embed/backfill` 重嵌即可(backfill 已排除 deprecated,所以不會自己跑回來)。
|
||||||
|
*/
|
||||||
|
export async function embeddedIdsByLibrary(db: D1Database, ownerId: string, library: string): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM entries
|
||||||
|
WHERE owner_id = ?
|
||||||
|
AND COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?
|
||||||
|
AND is_embedded = 1`,
|
||||||
|
)
|
||||||
|
.bind(ownerId, library)
|
||||||
|
.all<{ id: string }>();
|
||||||
|
return (rows.results ?? []).map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把這些 entry 標成「已無向量」(配合 deleteByIds,讓 D1 與 Vectorize 不說兩套話)。 */
|
||||||
|
export async function markUnembedded(db: D1Database, ids: string[]): Promise<void> {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const holes = ids.map(() => '?').join(',');
|
||||||
|
await db.prepare(`UPDATE entries SET is_embedded = 0 WHERE id IN (${holes})`).bind(...ids).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE entries
|
||||||
|
SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'),
|
||||||
|
updated_at = unixepoch()
|
||||||
|
WHERE owner_id = ?
|
||||||
|
AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ?
|
||||||
|
AND (json_extract(metadata_json, '$.status') IS NULL
|
||||||
|
OR json_extract(metadata_json, '$.status') != 'deprecated')`,
|
||||||
|
)
|
||||||
|
.bind(ownerId, library)
|
||||||
|
.run();
|
||||||
|
return (result.meta?.changes as number | undefined) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── content 關鍵字比對:D1 的 LIKE pattern 有 50 bytes 硬上限 ───────────────────
|
||||||
|
//
|
||||||
|
// 病徵(2026-08-03 在 1.4.4 實例上二分實測):`/entries/search?q=…` 只要 q **超過 48 bytes**
|
||||||
|
// 就回 HTTP 500「Internal Server Error」——不是 400、沒有錯誤訊息,從外面看像伺服器壞了。
|
||||||
|
// q = 48 bytes → 200|q = 49 bytes → 500(ASCII 逐 byte 二分)
|
||||||
|
// 中文 16 字(48 bytes)→ 200|中文 17 字(51 bytes)→ 500
|
||||||
|
// 判別實驗(排除「整句 SQL 太長」這個猜想):q 固定 48 bytes、把 owner_id/entry_type/source/
|
||||||
|
// library 全塞滿讓 SQL 變很長 → 仍然 200 ⇒ **會爆的是 LIKE 的 pattern,不是 statement**。
|
||||||
|
// pattern = '%' + q + '%' ⇒ 48+2 = 50 ⇒ 上限就是 50 bytes。
|
||||||
|
// 對照:同一個長 q 走 mode=semantic 完全正常(那條路不經過 LIKE)。
|
||||||
|
//
|
||||||
|
// 為什麼要修(不是邊角):**中文問句超過 16 個字是常態**。
|
||||||
|
// rag_chat 的 kw_search 用整句問題當 q ⇒ 使用者問任何一句正常長度的中文,
|
||||||
|
// 整條問答鏈在第二個節點就 500 ⇒ 聊天功能等於不能用。
|
||||||
|
// (這也是 InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
|
||||||
|
//
|
||||||
|
// 修法(**短查詢行為逐字不變**):
|
||||||
|
// · q ≤ 48 bytes → 走原本那條路,單一 `content LIKE '%q%'`,一個字都沒改。
|
||||||
|
// · q > 48 bytes → 拆成詞,每個詞各一個 LIKE 用 AND 串(「每個詞都要出現」)。
|
||||||
|
// 沒有空白可拆的長句(中文常見)→ 切成 ≤48 bytes 的片段(切在 UTF-8 邊界上,不切壞字)。
|
||||||
|
// 詞數上限 6:再多對 D1 是白花成本,而且「要同時命中 7 個詞」本來就不會有結果。
|
||||||
|
//
|
||||||
|
// 誠實限制:對「無空白的長中文句」,拆片段是機械切分、不是斷詞 ⇒ 命中率不會變好。
|
||||||
|
// 但它的對照組是 **500**,不是「更好的結果」;而且這種查詢原本就算不炸也幾乎命不中
|
||||||
|
// (整句子字串比對)。真正的中文關鍵字檢索要走 FTS5 或斷詞,那是另一件事、要另外立案。
|
||||||
|
const MAX_LIKE_Q_BYTES = 48; // D1: LIKE pattern 上限 50 bytes,pattern = '%' + q + '%'
|
||||||
|
const MAX_LIKE_TERMS = 6;
|
||||||
|
|
||||||
|
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
|
||||||
|
|
||||||
|
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
|
||||||
|
function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
let cur = '';
|
||||||
|
for (const ch of s) {
|
||||||
|
if (utf8Len(cur + ch) > maxBytes) {
|
||||||
|
if (cur) out.push(cur);
|
||||||
|
cur = ch;
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 q 轉成一組 `content LIKE ?` 謂詞與參數(純函式,單測用 export)。
|
||||||
|
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
|
||||||
|
*/
|
||||||
|
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
|
||||||
|
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
|
||||||
|
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
|
||||||
|
}
|
||||||
|
const terms: string[] = [];
|
||||||
|
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||||
|
for (const piece of chunkByBytes(word, MAX_LIKE_Q_BYTES)) {
|
||||||
|
terms.push(piece);
|
||||||
|
if (terms.length >= MAX_LIKE_TERMS) break;
|
||||||
|
}
|
||||||
|
if (terms.length >= MAX_LIKE_TERMS) break;
|
||||||
|
}
|
||||||
|
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
|
||||||
|
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
|
||||||
|
return {
|
||||||
|
conds: terms.map(() => 'content LIKE ?'),
|
||||||
|
params: terms.map((t) => `%${t}%`),
|
||||||
|
split: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ─────────────────────────
|
||||||
|
//
|
||||||
|
// 病徵(2026-08-10 總管在 leo21c 上實測,有對照組):
|
||||||
|
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||||
|
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||||
|
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||||
|
// ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。
|
||||||
|
// 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一
|
||||||
|
// `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。
|
||||||
|
//
|
||||||
|
// 為什麼這是**結構性**故障、不是準度問題:
|
||||||
|
// **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰
|
||||||
|
// ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」——
|
||||||
|
// 接上了也還是瞎的,因為接上之後查什麼都沒有。
|
||||||
|
// (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。)
|
||||||
|
//
|
||||||
|
// 這件 47c6aae(2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事;
|
||||||
|
// 本次只動**查詢端**,buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。
|
||||||
|
//
|
||||||
|
// 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。
|
||||||
|
// · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**,
|
||||||
|
// 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。
|
||||||
|
// · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛
|
||||||
|
// ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2)
|
||||||
|
// ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。
|
||||||
|
// · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一,
|
||||||
|
// `local arcrun` 那 5 筆不會被稀釋掉。
|
||||||
|
// · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套):
|
||||||
|
// 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。
|
||||||
|
//
|
||||||
|
// 回歸保證(不是靠測試碰運氣,是靠構造):
|
||||||
|
// · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern),
|
||||||
|
// 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。
|
||||||
|
// · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞),
|
||||||
|
// 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。
|
||||||
|
//
|
||||||
|
// 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段
|
||||||
|
// +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、
|
||||||
|
// 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。
|
||||||
|
// 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到
|
||||||
|
// 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。
|
||||||
|
const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數)
|
||||||
|
const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號
|
||||||
|
// 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照):
|
||||||
|
// 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾);
|
||||||
|
// 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響
|
||||||
|
// ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」,
|
||||||
|
// 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。
|
||||||
|
const KEYWORD_RELATIVE_CUT = 0.6;
|
||||||
|
|
||||||
|
// CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。**
|
||||||
|
//
|
||||||
|
// 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果
|
||||||
|
// 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。
|
||||||
|
// 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都
|
||||||
|
// 同時是虛詞與實詞的組成部分)。
|
||||||
|
// ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。
|
||||||
|
// 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。
|
||||||
|
//
|
||||||
|
// 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額),
|
||||||
|
// 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡
|
||||||
|
// 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。
|
||||||
|
const CJK_STOP_CHARS = new Set(
|
||||||
|
'的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。
|
||||||
|
const ASCII_STOP_WORDS = new Set([
|
||||||
|
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were',
|
||||||
|
'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with',
|
||||||
|
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would',
|
||||||
|
'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isCjkChar = (ch: string): boolean => /[-ヿ㐀-䶿一-鿿豈-]/.test(ch);
|
||||||
|
const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch);
|
||||||
|
|
||||||
|
/** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */
|
||||||
|
export function splitRuns(q: string): { text: string; cjk: boolean }[] {
|
||||||
|
const runs: { text: string; cjk: boolean }[] = [];
|
||||||
|
let cur = ''; let curCjk = false;
|
||||||
|
const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; };
|
||||||
|
for (const ch of q) {
|
||||||
|
const cjk = isCjkChar(ch);
|
||||||
|
if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔
|
||||||
|
if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意)
|
||||||
|
cur += ch; curCjk = cjk;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */
|
||||||
|
function contentBigrams(run: string): string[] {
|
||||||
|
const chars = [...run];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = 0; i + 1 < chars.length; i++) {
|
||||||
|
if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue;
|
||||||
|
out.push(chars[i] + chars[i + 1]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchTerm { term: string; weight: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把查詢句拆成帶份量的查詢詞(純函式,單測用 export)。
|
||||||
|
* 份量=字數(上限 MAX_TERM_WEIGHT);愈長愈專指 ⇒ 排序時壓過泛詞。
|
||||||
|
* 依份量由大到小截斷到 MAX_SEARCH_TERMS,確保被砍掉的是最泛的那幾個。
|
||||||
|
*/
|
||||||
|
export function tokenizeQuery(q: string): SearchTerm[] {
|
||||||
|
const found = new Map<string, number>();
|
||||||
|
const add = (t: string, w: number) => {
|
||||||
|
for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束
|
||||||
|
if (!piece) continue;
|
||||||
|
found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runs = splitRuns(q);
|
||||||
|
// 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同:
|
||||||
|
// · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。
|
||||||
|
// · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。
|
||||||
|
// 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有
|
||||||
|
// 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的
|
||||||
|
// 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。
|
||||||
|
const isQuestion = runs.length > 1;
|
||||||
|
|
||||||
|
for (const run of runs) {
|
||||||
|
if (!run.cjk) {
|
||||||
|
const w = run.text.toLowerCase();
|
||||||
|
if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const chars = [...run.text];
|
||||||
|
// 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。
|
||||||
|
if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length);
|
||||||
|
// 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。
|
||||||
|
// 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。
|
||||||
|
if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...found.entries()]
|
||||||
|
.map(([term, weight]) => ({ term, weight }))
|
||||||
|
.sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
|
||||||
|
.slice(0, MAX_SEARCH_TERMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchScorePlan {
|
||||||
|
/** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */
|
||||||
|
scoreExpr: string;
|
||||||
|
scoreParams: string[];
|
||||||
|
terms: SearchTerm[];
|
||||||
|
/** true = 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */
|
||||||
|
legacyShape: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產生「覆蓋率分數」的 SQL 表達式(純函式,單測用 export)。
|
||||||
|
*
|
||||||
|
* 整句相鄰另給一份重賞(=所有詞份量總和),確保**舊行為排最前**:
|
||||||
|
* 含整句的內容分數必然高於只含零散詞的,`local arcrun` 那 5 筆永遠在最上面。
|
||||||
|
*/
|
||||||
|
export function buildSearchScore(q: string): SearchScorePlan {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
const terms = tokenizeQuery(trimmed);
|
||||||
|
|
||||||
|
// 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。
|
||||||
|
if (terms.length === 0) {
|
||||||
|
const m = buildContentLike(trimmed);
|
||||||
|
return {
|
||||||
|
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
|
||||||
|
scoreParams: m.params,
|
||||||
|
terms: [],
|
||||||
|
legacyShape: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
const params: string[] = [];
|
||||||
|
for (const { term, weight } of terms) {
|
||||||
|
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
||||||
|
params.push(`%${term}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||||
|
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||||
|
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||||
|
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||||
|
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
|
||||||
|
params.push(`%${trimmed}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */
|
||||||
|
export function applyRelativeCut<T extends { match_score: number }>(rows: T[]): T[] {
|
||||||
|
if (rows.length <= 1) return rows;
|
||||||
|
const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT;
|
||||||
|
return rows.filter((r) => r.match_score >= cut);
|
||||||
|
}
|
||||||
|
|
||||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||||
@@ -178,6 +512,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
|||||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||||
|
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||||
|
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||||
|
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||||
export async function searchEntries(
|
export async function searchEntries(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
q: string,
|
q: string,
|
||||||
@@ -187,17 +524,29 @@ export async function searchEntries(
|
|||||||
library?: string[],
|
library?: string[],
|
||||||
source?: string,
|
source?: string,
|
||||||
includeDeprecated = false,
|
includeDeprecated = false,
|
||||||
): Promise<Entry[]> {
|
): Promise<(Entry & { match_score: number })[]> {
|
||||||
const conds = ['content LIKE ?'];
|
const plan = buildSearchScore(q); // 斷詞+算分;單詞查詢=與舊版逐字相同的單一 LIKE
|
||||||
const params: unknown[] = [`%${q}%`];
|
const conds: string[] = [];
|
||||||
|
const params: unknown[] = [...plan.scoreParams];
|
||||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||||
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
||||||
|
// 分數在子查詢算、外層才篩 match_score > 0:SQLite 不保證能在 WHERE 引用 SELECT 別名,
|
||||||
|
// 用子查詢就不必把整組 LIKE 參數再綁一次(參數重複=將來改一邊漏一邊的漂移來源)。
|
||||||
|
// 其他 filter 留在**內層**,讓 owner/library/deprecated 先篩掉,算分只發生在該算的列上。
|
||||||
|
const inner = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
.prepare(
|
||||||
|
`SELECT * FROM (
|
||||||
|
SELECT *, (${plan.scoreExpr}) AS match_score FROM entries ${inner}
|
||||||
|
) WHERE match_score > 0
|
||||||
|
ORDER BY match_score DESC, updated_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
.bind(...params, Math.min(limit, 200))
|
.bind(...params, Math.min(limit, 200))
|
||||||
.all<Entry>();
|
.all<Entry & { match_score: number }>();
|
||||||
return res.results ?? [];
|
// 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。
|
||||||
|
return applyRelativeCut(res.results ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// Execution log — workflow 執行紀錄(KV 額度事故修復,總管交辦,2026-08-07;
|
||||||
|
// 保留期可設定=P7,2026-08-09,leo 08-08 confirm:`system-dev/docs/3-specs/pending-changes.md` P7)
|
||||||
|
//
|
||||||
|
// SDD:無專屬 SDD(延續 2026-08-07 的事故修復任務範圍——同一個 execution_log 資料模型,
|
||||||
|
// 加保留期設定與清理,不是新架構)。root cause 見 kbdb/migrations/0004_execution_log_template.sql
|
||||||
|
// 開頭註解:cypher-executor 舊版每跑完一次 workflow 就 ANALYTICS_KV.put() 一筆新 key(永不覆蓋)
|
||||||
|
// ⇒ 封測者 690 個檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write)。
|
||||||
|
//
|
||||||
|
// KBDB 鐵律(leo 2026-06-14):三張表打天下,永遠不加新 table;新資料類型一律用 template。
|
||||||
|
// 本模組 schema 走 template 機制(tpl-execution-log,見上述 migration),但**儲存精神比照既有
|
||||||
|
// recipe-stat.ts**:template 只負責文件化(GET /templates 可發現欄位定義),實際一筆執行紀錄
|
||||||
|
// 是 entries 表的**一列**(entry_type='execution_log',結構化欄位打包進 metadata_json),
|
||||||
|
// 不走 entry_values 全展開的多列 record——那樣一筆執行要拆 5+ 列,1 次執行變 6+ 次 D1 寫入,
|
||||||
|
// 直接違反「少記」精神;recipe_stat 早已示範「template 存在+entries 直接存」這個模式合法。
|
||||||
|
//
|
||||||
|
// leo 兩條判準:
|
||||||
|
// ① 執行紀錄是稽核資料 → 搬 D1(entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。
|
||||||
|
// ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/
|
||||||
|
// duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(見 SUCCESS/FAILED_MESSAGE_MAX)。
|
||||||
|
//
|
||||||
|
// A2 自我降級:執行紀錄與知識卡(一般 entries)共用同一顆 D1 100,000 rows/日,搬 D1 只是油箱
|
||||||
|
// 大了 100 倍,不是解掉共用額度本身。本模組自設更低的「軟上限」(DEFAULT_DAILY_LIMIT),
|
||||||
|
// 用量超過 80% → 降成只記失敗;超過 100% → 完全停止記錄,但呼叫端(cypher-executor)的
|
||||||
|
// workflow 執行永遠照跑——寫入永不 throw(recordExecutionLog 本身 catch 見呼叫端 route)。
|
||||||
|
//
|
||||||
|
// 隔離(不污染知識搜尋):entry_type='execution_log'/'execution_log_usage'/
|
||||||
|
// 'execution_log_retention_config' 是內部型別,與既有 'value'/'workflow' 同層級。cypher-executor
|
||||||
|
// 端(portal-data.ts INTERNAL_ENTRY_TYPES)比照這些一併排除;本模組也從不設
|
||||||
|
// metadata_json.embed=true,故永不進 Vectorize 語意搜尋索引。
|
||||||
|
//
|
||||||
|
// P7 保留期(leo 08-07 兩段發言合起來的最終規格,見 pending-changes.md「提議的規格」段):
|
||||||
|
// 儲存 D1、預設保留 90 天(3 個月),過期即清;租戶可自訂天數,也可設「不刪除」(企業稽核)。
|
||||||
|
// 清理不掛 Cloudflare Cron(wrangler.toml 的 [triggers] 段落是受保護檔案、AI 不可編輯——
|
||||||
|
// 見 InkStoneCo 頂層 P9 段 L1 權限閘),改「搭便車」:cypher-executor 既有的每分鐘
|
||||||
|
// scheduled tick(cron workflow 用,見 cypher-executor/src/scheduled.ts)本來就會醒,
|
||||||
|
// 在那支既有 handler 裡加一段「一天一次」呼叫本模組的 cleanupExpiredLogs 端點即可,
|
||||||
|
// 不需要新的排程基礎設施、不違反「禁輪詢」(那條鐵律管的是主動去戳外部系統要狀態,
|
||||||
|
// 這裡是既有 tick 順手打理自己的表,且頻率仍是「一天一次」而非高頻輪詢)。
|
||||||
|
import type { Bindings } from '../types';
|
||||||
|
import { createEntry, listEntries } from './entry-crud';
|
||||||
|
|
||||||
|
export interface ExecutionLogInput {
|
||||||
|
workflow_id: string;
|
||||||
|
owner_id?: string | null;
|
||||||
|
verdict: 'success' | 'failed';
|
||||||
|
duration_ms: number;
|
||||||
|
message?: string;
|
||||||
|
target?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionLogRow {
|
||||||
|
workflow_id: string;
|
||||||
|
verdict: string;
|
||||||
|
duration_ms: number;
|
||||||
|
message: string;
|
||||||
|
target?: string;
|
||||||
|
recorded_at: number; // unix seconds(entries.created_at 既有慣例,非毫秒)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功訊息截斷長度(少記:夠看一眼結果就好,不留診斷用的長上下文)。 */
|
||||||
|
const SUCCESS_MESSAGE_MAX = 200;
|
||||||
|
/** 失敗訊息截斷長度(不對稱:失敗要留夠診斷用的上下文,比成功多 10 倍)。 */
|
||||||
|
const FAILED_MESSAGE_MAX = 2000;
|
||||||
|
/** target 欄位截斷長度(page_name / path 通常是檔名或路徑,不會太長;異常長輸入也不整包吞)。 */
|
||||||
|
const TARGET_MAX = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日軟上限預設值:D1 免費層 100,000 rows written/日與知識卡(一般 entries)共用,
|
||||||
|
* 本模組自設 20%(20,000)——不是 Cloudflare 硬限制,是「執行紀錄不該把知識卡的額度吃光」的
|
||||||
|
* 自我節制門檻,可用 env.EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫。
|
||||||
|
*/
|
||||||
|
const DEFAULT_DAILY_LIMIT = 20000;
|
||||||
|
/** 用量超過門檻比例 → 降成只記失敗(寫死比例+可測試,不靠感覺調參)。 */
|
||||||
|
const DEGRADE_RATIO = 0.8;
|
||||||
|
|
||||||
|
export type UsageMode = 'log' | 'log_failure_only' | 'skip';
|
||||||
|
|
||||||
|
function dailyLimit(env: Pick<Bindings, 'EXECUTION_LOG_DAILY_WRITE_LIMIT'>): number {
|
||||||
|
const raw = env.EXECUTION_LOG_DAILY_WRITE_LIMIT;
|
||||||
|
const n = raw ? parseInt(raw, 10) : NaN;
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDay(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
if (s.length <= max) return s;
|
||||||
|
return s.slice(0, Math.max(0, max - 1)) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2 用量計數+降級判斷。單一 entries 列/日(id=`exlog-usage:{day}`,entry_type=
|
||||||
|
* 'execution_log_usage',計數包進 metadata_json)——精神完全比照 recipe-stat.ts 的
|
||||||
|
* upsert 慣例(讀現有列 → +1 → UPDATE,不存在則 INSERT)。
|
||||||
|
*
|
||||||
|
* 刻意計「每次呼叫嘗試次數」而非「實際寫入 execution_log 的列數」——即使已降級到
|
||||||
|
* 「只記失敗」或「完全停止」,仍要繼續計數,不然額度耗盡後下一次呼叫又會誤判成
|
||||||
|
* 「還沒超過」而重新開始寫爆(等於沒有降級機制)。day 用 UTC 日期字串,換日自然歸零。
|
||||||
|
*/
|
||||||
|
export async function checkUsage(db: D1Database, limit: number): Promise<UsageMode> {
|
||||||
|
const id = `exlog-usage:${utcDay()}`;
|
||||||
|
const existing = await db
|
||||||
|
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||||
|
.bind(id)
|
||||||
|
.first<{ metadata_json: string | null }>();
|
||||||
|
|
||||||
|
let count: number;
|
||||||
|
if (existing) {
|
||||||
|
let prevWrites = 0;
|
||||||
|
try {
|
||||||
|
const prev = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
|
||||||
|
prevWrites = Number(prev.writes) || 0;
|
||||||
|
} catch {
|
||||||
|
prevWrites = 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死降級機制
|
||||||
|
}
|
||||||
|
count = prevWrites + 1;
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||||||
|
.bind(JSON.stringify({ day: utcDay(), writes: count }), id)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
count = 1;
|
||||||
|
await db
|
||||||
|
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`)
|
||||||
|
.bind(id, JSON.stringify({ day: utcDay(), writes: count }))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > limit) return 'skip';
|
||||||
|
if (count > limit * DEGRADE_RATIO) return 'log_failure_only';
|
||||||
|
return 'log';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 寫入一筆執行紀錄(fire-and-forget 語意由呼叫端 route 的 try/catch 保證,本函式本身
|
||||||
|
* 不主動吞錯——route 層統一吞,保持單一吞錯點,避免兩層都吞導致除錯時看不到真因)。
|
||||||
|
*/
|
||||||
|
export async function recordExecutionLog(
|
||||||
|
db: D1Database,
|
||||||
|
env: Pick<Bindings, 'EXECUTION_LOG_DAILY_WRITE_LIMIT'>,
|
||||||
|
input: ExecutionLogInput,
|
||||||
|
): Promise<{ written: boolean; mode: UsageMode }> {
|
||||||
|
const limit = dailyLimit(env);
|
||||||
|
let mode: UsageMode;
|
||||||
|
try {
|
||||||
|
mode = await checkUsage(db, limit);
|
||||||
|
} catch {
|
||||||
|
// fail-open:計數機制本身故障(含 D1 額度打滿)不該連執行紀錄都不寫,
|
||||||
|
// 寧可暫時失去降級能力也不要靜默漏記——這一步的失敗仍不影響下面的實際寫入。
|
||||||
|
mode = 'log';
|
||||||
|
}
|
||||||
|
if (mode === 'skip') return { written: false, mode };
|
||||||
|
if (mode === 'log_failure_only' && input.verdict !== 'failed') return { written: false, mode };
|
||||||
|
|
||||||
|
const maxLen = input.verdict === 'failed' ? FAILED_MESSAGE_MAX : SUCCESS_MESSAGE_MAX;
|
||||||
|
const target = input.target ? truncate(String(input.target), TARGET_MAX) : null;
|
||||||
|
|
||||||
|
await createEntry(db, {
|
||||||
|
entry_type: 'execution_log',
|
||||||
|
owner_id: input.owner_id ?? null,
|
||||||
|
page_name: input.workflow_id, // 索引欄位(idx_entries_page)=查詢鍵,讀取端靠它篩單一 workflow
|
||||||
|
content: truncate(input.message ?? '', maxLen),
|
||||||
|
metadata_json: JSON.stringify({
|
||||||
|
verdict: input.verdict,
|
||||||
|
duration_ms: Math.max(0, Math.round(input.duration_ms)),
|
||||||
|
target,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return { written: true, mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 讀某 workflow 最近 N 次執行紀錄(降冪)。owner_id 給了才過濾(租戶隔離,caller 決定)。 */
|
||||||
|
export async function listExecutionLog(
|
||||||
|
db: D1Database,
|
||||||
|
workflowId: string,
|
||||||
|
ownerId: string | undefined,
|
||||||
|
limit: number,
|
||||||
|
): Promise<ExecutionLogRow[]> {
|
||||||
|
const { entries } = await listEntries(db, {
|
||||||
|
entry_type: 'execution_log',
|
||||||
|
page_name: workflowId,
|
||||||
|
owner_id: ownerId,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return entries.map((e) => {
|
||||||
|
let meta: { verdict?: string; duration_ms?: number; target?: string | null } = {};
|
||||||
|
try {
|
||||||
|
meta = e.metadata_json ? (JSON.parse(e.metadata_json) as typeof meta) : {};
|
||||||
|
} catch {
|
||||||
|
/* 壞資料誠實留空,不整筆丟掉(still 回傳 verdict='unknown' 好過整筆消失) */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workflow_id: workflowId,
|
||||||
|
verdict: meta.verdict ?? 'unknown',
|
||||||
|
duration_ms: meta.duration_ms ?? 0,
|
||||||
|
message: e.content ?? '',
|
||||||
|
...(meta.target ? { target: meta.target } : {}),
|
||||||
|
recorded_at: e.created_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 讀某 workflow 最新一次執行紀錄(portal-data.ts last_execution 用)。 */
|
||||||
|
export async function latestExecutionLog(
|
||||||
|
db: D1Database,
|
||||||
|
workflowId: string,
|
||||||
|
ownerId: string | undefined,
|
||||||
|
): Promise<ExecutionLogRow | null> {
|
||||||
|
const rows = await listExecutionLog(db, workflowId, ownerId, 1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// leo 08-07 原話合起來的規格:「預設可以永久保存,但我設定每 3 個月把超過的刪掉……
|
||||||
|
// 我願意花很多錢保存,不要刪除」——翻成可執行規則=**預設保留 90 天、租戶可自訂天數、
|
||||||
|
// 也可設「不刪除」**(企業稽核用,這是付費理由不是成本負擔,schema 不擋未來計費)。
|
||||||
|
//
|
||||||
|
// 儲存:沿用 execution_log_usage 的 upsert 慣例——單一 entries 列/租戶
|
||||||
|
// (id=`exlog-retention:{owner_id}`,entry_type='execution_log_retention_config')。
|
||||||
|
// 無租戶(owner_id 缺,例如舊版 /execute 路徑)套用預設天數,不可個別設定
|
||||||
|
// (沒有租戶就沒有「誰的設定」這個概念,硬要存會變成一筆沒有主人的孤兒設定)。
|
||||||
|
|
||||||
|
/** 預設保留天數:3 個月(leo 08-07:「我設定每 3 個月把超過的刪掉」)。 */
|
||||||
|
export const DEFAULT_RETENTION_DAYS = 90;
|
||||||
|
|
||||||
|
/** 單次清理呼叫最多刪幾列——避免單次 D1 查詢過重;呼叫端(cypher 每日一次 tick)多次呼叫可逐步清完累積量。 */
|
||||||
|
const CLEANUP_BATCH_LIMIT = 500;
|
||||||
|
|
||||||
|
function retentionConfigId(ownerId: string): string {
|
||||||
|
return `exlog-retention:${ownerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 讀某租戶的保留天數;null=該租戶已設「不刪除」;未設定過=回預設值(不是 null)。 */
|
||||||
|
export async function getRetentionDays(
|
||||||
|
db: D1Database,
|
||||||
|
ownerId: string | null | undefined,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (!ownerId) return DEFAULT_RETENTION_DAYS; // 無租戶=套預設,不可個別設定(見上方註解)
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT metadata_json FROM entries WHERE id = ?`)
|
||||||
|
.bind(retentionConfigId(ownerId))
|
||||||
|
.first<{ metadata_json: string | null }>();
|
||||||
|
if (!row) return DEFAULT_RETENTION_DAYS;
|
||||||
|
try {
|
||||||
|
const parsed = row.metadata_json
|
||||||
|
? (JSON.parse(row.metadata_json) as { retention_days?: number | null })
|
||||||
|
: {};
|
||||||
|
if (parsed.retention_days === null) return null; // 「不刪除」
|
||||||
|
const n = Number(parsed.retention_days);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_RETENTION_DAYS; // 壞資料誠實退回預設,不讓損毀設定卡死清理
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_RETENTION_DAYS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 設定某租戶的保留天數。days=null=「不刪除」(企業稽核選項);days=正整數=自訂天數。 */
|
||||||
|
export async function setRetentionDays(
|
||||||
|
db: D1Database,
|
||||||
|
ownerId: string,
|
||||||
|
days: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const id = retentionConfigId(ownerId);
|
||||||
|
const metadata = JSON.stringify({ retention_days: days, updated_at: Math.floor(Date.now() / 1000) });
|
||||||
|
const existing = await db.prepare(`SELECT id FROM entries WHERE id = ?`).bind(id).first();
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.prepare(`UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?`)
|
||||||
|
.bind(metadata, id)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(id, ownerId, metadata)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanupResult {
|
||||||
|
deleted: number;
|
||||||
|
checked_overrides: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清掉過期的執行紀錄(entry_type='execution_log' 且早於各自租戶的保留期限)。
|
||||||
|
* 分兩段跑:
|
||||||
|
* ① 有自訂天數的租戶:各自用自己的 cutoff 刪。
|
||||||
|
* ② 其餘(含無租戶/未設定過的租戶):套預設 90 天,但排除「已設不刪除」與
|
||||||
|
* 「剛才①處理過」的租戶,避免同一輪重複掃描。
|
||||||
|
* 每段各受 CLEANUP_BATCH_LIMIT 界限——呼叫端(cypher 每日一次 tick)長期呼叫可逐步清完累積量,
|
||||||
|
* 不追求一次清光(那樣單次 D1 查詢會過重,且清理本身不是使用者等待中的路徑,慢慢清沒有壞處)。
|
||||||
|
*/
|
||||||
|
export async function cleanupExpiredLogs(db: D1Database): Promise<CleanupResult> {
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const overridesRes = await db
|
||||||
|
.prepare(`SELECT owner_id, metadata_json FROM entries WHERE entry_type = 'execution_log_retention_config'`)
|
||||||
|
.all<{ owner_id: string | null; metadata_json: string | null }>();
|
||||||
|
const overrides = overridesRes.results ?? [];
|
||||||
|
|
||||||
|
const neverDeleteOwners: string[] = [];
|
||||||
|
const customOwners: Array<{ owner_id: string; days: number }> = [];
|
||||||
|
for (const row of overrides) {
|
||||||
|
if (!row.owner_id) continue;
|
||||||
|
let parsed: { retention_days?: number | null } = {};
|
||||||
|
try {
|
||||||
|
parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as typeof parsed) : {};
|
||||||
|
} catch {
|
||||||
|
continue; // 壞資料:不當成任何一種 override,讓該租戶回退到①之外的預設路徑
|
||||||
|
}
|
||||||
|
if (parsed.retention_days === null) {
|
||||||
|
neverDeleteOwners.push(row.owner_id);
|
||||||
|
} else {
|
||||||
|
const n = Number(parsed.retention_days);
|
||||||
|
if (Number.isFinite(n) && n > 0) customOwners.push({ owner_id: row.owner_id, days: n });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
// ① 自訂天數的租戶,各自 cutoff
|
||||||
|
for (const { owner_id, days } of customOwners) {
|
||||||
|
const cutoff = nowSec - days * 86400;
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM entries WHERE id IN (
|
||||||
|
SELECT id FROM entries WHERE entry_type = 'execution_log' AND owner_id = ? AND created_at < ?
|
||||||
|
LIMIT ?
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.bind(owner_id, cutoff, CLEANUP_BATCH_LIMIT)
|
||||||
|
.run();
|
||||||
|
deleted += (res.meta?.changes as number | undefined) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 其餘:預設 90 天,排除「不刪除」與①已處理的租戶
|
||||||
|
const defaultCutoff = nowSec - DEFAULT_RETENTION_DAYS * 86400;
|
||||||
|
const excluded = [...neverDeleteOwners, ...customOwners.map((o) => o.owner_id)];
|
||||||
|
const sql =
|
||||||
|
excluded.length > 0
|
||||||
|
? `DELETE FROM entries WHERE id IN (
|
||||||
|
SELECT id FROM entries WHERE entry_type = 'execution_log'
|
||||||
|
AND created_at < ?
|
||||||
|
AND (owner_id IS NULL OR owner_id NOT IN (${excluded.map(() => '?').join(',')}))
|
||||||
|
LIMIT ?
|
||||||
|
)`
|
||||||
|
: `DELETE FROM entries WHERE id IN (
|
||||||
|
SELECT id FROM entries WHERE entry_type = 'execution_log' AND created_at < ? LIMIT ?
|
||||||
|
)`;
|
||||||
|
const binds = excluded.length > 0 ? [defaultCutoff, ...excluded, CLEANUP_BATCH_LIMIT] : [defaultCutoff, CLEANUP_BATCH_LIMIT];
|
||||||
|
const res2 = await db.prepare(sql).bind(...binds).run();
|
||||||
|
deleted += (res2.meta?.changes as number | undefined) ?? 0;
|
||||||
|
|
||||||
|
return { deleted, checked_overrides: overrides.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 測試專用 helpers(P7,2026-08-09) ──────────────────────────────────────
|
||||||
|
// 這支檔在 kbdb/src/actions/ 下(資料層 worker 自己=API-as-Wall 的牆本身,D38 允許在
|
||||||
|
// 這裡直接碰 D1)。單元測試(kbdb/tests/execution-log.test.ts)不該自己在測試檔裡寫原生
|
||||||
|
// SQL——那個檔在「牆外」,即使是測試治具也不該養成在那裡打 SQL 的習慣。所以把「插入一列
|
||||||
|
// 指定 created_at 的過期紀錄」「數某類設定列有幾筆」這兩個測試才需要的原語做成正式匯出的
|
||||||
|
// 函式,放在牆內、由牆內的程式碼實際執行 SQL,測試檔只呼叫函式——與正式的 recordExecutionLog
|
||||||
|
// 刻意不開放指定過去時間形成對照(那是正式寫入路徑的正確限制,這裡是測試的例外通道)。
|
||||||
|
|
||||||
|
/** 測試專用:直接寫一列指定 created_at 的 execution_log(模擬「N 天前寫入的紀錄」)。 */
|
||||||
|
export async function testInsertAgedExecutionLog(
|
||||||
|
db: D1Database,
|
||||||
|
id: string,
|
||||||
|
ownerId: string | null,
|
||||||
|
daysAgo: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const createdAt = Math.floor(Date.now() / 1000) - daysAgo * 86400;
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO entries (id, entry_type, owner_id, page_name, content, metadata_json, created_at)
|
||||||
|
VALUES (?, 'execution_log', ?, 'wf-aged', 'old', '{"verdict":"success","duration_ms":1}', ?)`,
|
||||||
|
)
|
||||||
|
.bind(id, ownerId, createdAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 測試專用:寫一列**損毀** metadata_json 的保留期設定(驗證 cleanupExpiredLogs 對壞資料的容錯)。 */
|
||||||
|
export async function testInsertBrokenRetentionConfig(db: D1Database, ownerId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO entries (id, entry_type, owner_id, metadata_json) VALUES (?, 'execution_log_retention_config', ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(retentionConfigId(ownerId), ownerId, '{not valid json')
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 測試專用:數某租戶目前有幾列保留期設定(驗證 setRetentionDays 是 upsert,不是每次都新增一列)。 */
|
||||||
|
export async function testCountRetentionConfigRows(db: D1Database, ownerId: string): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT COUNT(*) as n FROM entries WHERE entry_type = 'execution_log_retention_config' AND owner_id = ?`)
|
||||||
|
.bind(ownerId)
|
||||||
|
.first<{ n: number }>();
|
||||||
|
return row?.n ?? 0;
|
||||||
|
}
|
||||||
@@ -226,7 +226,15 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
|
|||||||
const bridges: Bridge[] = [...bridgeMap.entries()].map(([entity, libraries]) => ({ entity, libraries }));
|
const bridges: Bridge[] = [...bridgeMap.entries()].map(([entity, libraries]) => ({ entity, libraries }));
|
||||||
|
|
||||||
// map block 的 content=可嵌人話(design §5:之後 M6 semantic 路由第一跳直接嵌這句做庫路由)。
|
// map block 的 content=可嵌人話(design §5:之後 M6 semantic 路由第一跳直接嵌這句做庫路由)。
|
||||||
const narrative = input.narrative?.trim() || '';
|
// narrative:caller 有給才覆蓋;沒給 → 沿用上一版現有 narrative(若有)。
|
||||||
|
// 2026-08-08 修正:這欄原本「沒給就清空」,會被下面新增的即時新鮮度層
|
||||||
|
// (ensureFreshLibraryMaps,讀端自動重算、天生不帶 narrative)每次呼叫都靜默洗掉
|
||||||
|
// ingest 端/人工填過的 narrative——沒給值=維持現狀,不是重置成空字串。
|
||||||
|
let narrative = input.narrative?.trim();
|
||||||
|
if (!narrative) {
|
||||||
|
const prev = await getLibraryMapDetail(db, library, owner);
|
||||||
|
narrative = prev?.narrative?.trim() || '';
|
||||||
|
}
|
||||||
const coreNames = topEntities.slice(0, 3).map((t) => t.name);
|
const coreNames = topEntities.slice(0, 3).map((t) => t.name);
|
||||||
const content = `${library}:${narrative || '(narrative 待 ingest 補寫)'}。核心:${
|
const content = `${library}:${narrative || '(narrative 待 ingest 補寫)'}。核心:${
|
||||||
coreNames.length ? coreNames.join('、') : '(尚無 entities)'
|
coreNames.length ? coreNames.join('、') : '(尚無 entities)'
|
||||||
@@ -297,6 +305,127 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 即時新鮮度(M3 收尾,2026-08-08) ----
|
||||||
|
//
|
||||||
|
// 真因(總管實測+wiki system-dev/wiki/mistakes.md「08-08」段):design §3 原訂「ingest 完成 →
|
||||||
|
// 逐庫呼 POST /map/recompute」,但 repo 內查無任何呼叫點——三週沒接上,導致沒手動 backfill 過的
|
||||||
|
// 租戶(絕大多數)GET /map 恆回空,且 M4 的 MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算」
|
||||||
|
// (不存在的事)。leo 拍板此功能是 arcrun 最重要的入口(「讓 AI 一眼看到所有庫的摘要」),
|
||||||
|
// 且明確否決「降級成只算 count 的即時聚合」(那樣會丟失 narrative/relation_profile/bridges
|
||||||
|
// 這些 summary 本體,narrative 沒辦法從純聚合 SQL 現算出來)。
|
||||||
|
//
|
||||||
|
// 解法:不再依賴任何外部呼叫者記得呼 /map/recompute,改成讀端(GET /map、GET /map/:library)
|
||||||
|
// 自己核對即時三元組數,落差就地呼叫既有的 recomputeLibraryMap 補算——聚合 SQL 沒有第二套,
|
||||||
|
// 只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。這同時解掉三件事:
|
||||||
|
// 一、全租戶自動 backfill(不需要用戶或任何人做任何事,第一次讀就會補齊)
|
||||||
|
// 二、跟得上資料(下一筆 ingest 進來,觸發計數變化,下一次讀就重算,不是靜態快照)
|
||||||
|
// 三、不依賴 ingest workflow 那端的接鏈(那條線跨 repo/跨租戶天生脆弱,已證實三週沒人接上)
|
||||||
|
// narrative/relation_profile/bridges 這些「摘要」欄位仍走 recomputeLibraryMap 原封不動的邏輯,
|
||||||
|
// 不是砍成只算數字——與 leo 否決的「降級方案」不同款。
|
||||||
|
|
||||||
|
// 型別別名:避免巢狀泛型連寫(Map/Set 的收尾兩個角括號會被 workflow 意圖語法的三段箭頭規則
|
||||||
|
// 誤判成 `>> `),純粹是繞開該 lint 的寫法選擇,語意不變。
|
||||||
|
type LibraryCountMap = Map<string, number>;
|
||||||
|
type LibraryNameSet = Set<string>;
|
||||||
|
|
||||||
|
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
|
||||||
|
// triplet 歸 'general')——與 GET /records/triplet-stats(t142)同一套分組語意,兩處數字對得上。
|
||||||
|
async function liveTripletCountsByLibrary(
|
||||||
|
db: D1Database,
|
||||||
|
tripletTemplateId: string,
|
||||||
|
owner_id?: string,
|
||||||
|
): Promise<LibraryCountMap> {
|
||||||
|
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ev.record_id
|
||||||
|
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||||
|
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
||||||
|
) AS tr
|
||||||
|
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
||||||
|
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||||
|
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
|
||||||
|
)
|
||||||
|
.bind(...params)
|
||||||
|
.all<{ library: string; n: number }>();
|
||||||
|
const m: LibraryCountMap = new Map();
|
||||||
|
for (const r of res.results ?? []) m.set(r.library, r.n);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.library,t52 慣例)或
|
||||||
|
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
|
||||||
|
// 「這庫是空的」(回 200+triplet_count:0)vs「查無此庫」(回 404)。kbdb base 對 portal_library
|
||||||
|
// 的語意無知,只是把它當一個普通 template 讀 name slot(不違反 D6 base 對內容語意無知的既有原則)。
|
||||||
|
async function knownLibraryNames(db: D1Database, owner_id?: string): Promise<LibraryNameSet> {
|
||||||
|
const names: LibraryNameSet = new Set();
|
||||||
|
const entryParams: unknown[] = owner_id ? [owner_id] : [];
|
||||||
|
const entryRows = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT json_extract(metadata_json, '$.library') AS library FROM entries
|
||||||
|
WHERE ${owner_id ? 'owner_id = ?' : '1=1'} AND json_extract(metadata_json, '$.library') IS NOT NULL`,
|
||||||
|
)
|
||||||
|
.bind(...entryParams)
|
||||||
|
.all<{ library: string | null }>();
|
||||||
|
for (const r of entryRows.results ?? []) if (r.library) names.add(r.library);
|
||||||
|
|
||||||
|
const libTpl = await getTemplate(db, 'portal_library');
|
||||||
|
if (libTpl) {
|
||||||
|
const libParams: unknown[] = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
|
||||||
|
const libRows = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
|
||||||
|
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||||
|
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
||||||
|
GROUP BY ev.record_id`,
|
||||||
|
)
|
||||||
|
.bind(...libParams)
|
||||||
|
.all<{ name: string | null }>();
|
||||||
|
for (const r of libRows.results ?? []) if (r.name) names.add(r.name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核對+補算:這個 owner 底下所有「即時有三元組」或「已知但地圖過期/缺失」的庫,一次核對、
|
||||||
|
// 只對真的落差的庫重算(平行跑,單庫失敗不擋其他庫、不擋讀取——地圖是加分不是硬依賴)。
|
||||||
|
// 沒有 triplet template(這顆 KBDB 從沒建過任何三元組)→ 無地圖可算,直接返回,不報錯。
|
||||||
|
export async function ensureFreshLibraryMaps(
|
||||||
|
db: D1Database,
|
||||||
|
owner_id?: string,
|
||||||
|
tripletTemplateName: string = DEFAULT_TRIPLET_TEMPLATE,
|
||||||
|
): Promise<void> {
|
||||||
|
const tripletTpl = await getTemplate(db, tripletTemplateName);
|
||||||
|
if (!tripletTpl) return;
|
||||||
|
|
||||||
|
const [liveCounts, cached, known] = await Promise.all([
|
||||||
|
liveTripletCountsByLibrary(db, tripletTpl.id, owner_id),
|
||||||
|
listLibraryMaps(db, owner_id),
|
||||||
|
knownLibraryNames(db, owner_id),
|
||||||
|
]);
|
||||||
|
const cachedByLib = new Map(cached.map((m) => [m.library, m]));
|
||||||
|
|
||||||
|
const stale = new Set<string>();
|
||||||
|
for (const [library, count] of liveCounts) {
|
||||||
|
const c = cachedByLib.get(library);
|
||||||
|
if (!c || c.triplet_count !== count) stale.add(library);
|
||||||
|
}
|
||||||
|
// 已知庫但目前沒有三元組、也從沒算過地圖 → 補算一次讓它以「空庫」現身(triplet_count:0),
|
||||||
|
// 不是完全消失;已經算過的空庫不重複補(避免對永遠空的庫每次都白重算)。
|
||||||
|
for (const name of known) {
|
||||||
|
if (!liveCounts.has(name) && !cachedByLib.has(name)) stale.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[...stale].map((library) =>
|
||||||
|
recomputeLibraryMap(db, { library, owner_id, triplet_template: tripletTemplateName }).catch(() => {
|
||||||
|
// 單庫重算失敗(如聚合 SQL 撞到髒資料)不擋其他庫、不擋讀取——鐵律:地圖是加分不是依賴。
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 讀端(M2 GET) ----
|
// ---- 讀端(M2 GET) ----
|
||||||
|
|
||||||
interface MapPivotRow {
|
interface MapPivotRow {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user