Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f3af5d3631 |
@@ -32,6 +32,33 @@ SDD 協議要求:code 和 SDD 必須同步更新。
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── console-ui:對外網址上是不是還跑著舊世代?(2026-08-08)────────────────
|
||||
#
|
||||
# 病(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,你要確定不可再犯」):
|
||||
# 前端改完、commit 了、甚至 wiki 都寫了,但**沒有人把它推上去**——
|
||||
# 而線上不會報錯,只是繼續展示半個月前的介面。08-08 實測:三個對外網址的
|
||||
# apiBase/profile 全綠,跑的卻是 07-22 那一代。**組態對 ≠ 世代對。**
|
||||
#
|
||||
# 為什麼掛在 Stop:這裡正是 CC 要說「做完了」的那一刻。
|
||||
# 不連網(每回合都跑),只比對「手上這一代」與「最後一次**通過線上實測**的部署紀錄」
|
||||
# (.deploy-state.json 只在 deploy.mjs 驗過線上後才寫,不是跑過指令就寫)。
|
||||
# 要問線上真實現況:cd console-ui && npm run verify(那支才連網)。
|
||||
if [ -d console-ui/scripts ] && command -v node >/dev/null 2>&1; then
|
||||
LAG="$(cd console-ui && node scripts/verify-live.mjs --offline-lag 2>/dev/null)"
|
||||
if [ -n "$LAG" ]; then
|
||||
cat >&2 <<EOF
|
||||
|
||||
🕰️ console-ui:手上這一代**還沒送出去過**
|
||||
$(echo "$LAG" | sed 's/^/ · /')
|
||||
|
||||
對外網址不會因此報錯——它只會繼續展示舊介面,而所有只驗組態的檢查都會說它是綠的。
|
||||
要看線上現在真的在跑哪一代: cd console-ui && npm run verify
|
||||
要送出去(含推完自動回頭驗線上):cd console-ui && npm run deploy:personal
|
||||
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# 若有暫存的 tasks.md 變動,提醒 commit
|
||||
TASKS_DIFF=$(git -C "$(pwd)" status --porcelain -- 'docs/3-specs/**/tasks.md' 2>/dev/null | head -5)
|
||||
if [[ -n "$TASKS_DIFF" ]]; then
|
||||
|
||||
+16
@@ -52,3 +52,19 @@ backup-*.sql
|
||||
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
||||
.github-public/
|
||||
wrangler.leo21c.toml
|
||||
|
||||
# deploy-all.mjs 產的共用依賴(部署時 npm 安裝 wrangler 等,非 repo 內容)
|
||||
# 2026-08-07:每次本機跑部署都會冒出來吵未推警察,且含不該進版控的鎖檔
|
||||
/package.json
|
||||
/package-lock.json
|
||||
|
||||
# console-ui 部署產物(deploy.mjs 依 deploy.targets.json 即時產生,不是原始碼)
|
||||
console-ui/.staging/
|
||||
# 「上一次通過線上實測的部署」紀錄——本機事實,不隨 repo 走
|
||||
# (刻意不進版控:新 checkout 沒有紀錄 ⇒ 狀態未知 ⇒ 該被大聲提醒,而不是繼承別人的綠燈)
|
||||
console-ui/.deploy-state.json
|
||||
|
||||
# Wrangler 本機開發用的密鑰檔——絕不進版控(2026-08-09 補:原本沒被擋,
|
||||
# 而同目錄有 agent 在動工,一次 git add -A 就會把金鑰推上去)
|
||||
.dev.vars
|
||||
**/.dev.vars
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
**讓 AI 用的工作流軟體(目前只支援 Claude Code)**
|
||||
|
||||
> 想先看用它做出來的產品?**[Arcrun RAG](https://git.uncle6.me/Leo/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫),有[線上 demo](https://rag-demo.arcrun.dev/portal) 可直接玩。
|
||||
> 想先看用它做出來的產品?**[Arcrun RAG](https://github.com/youlinhsieh/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫)。
|
||||
>
|
||||
> 目前**沒有公開試玩站**(早期那個共用示範站已於 2026-08-08 退場)。想直接看產出長什麼樣,
|
||||
> 可以看示範知識庫的公開鏡像 [arcrun-rag-demo-knowledge](https://github.com/youlinhsieh/arcrun-rag-demo-knowledge)——純靜態、免登入。
|
||||
|
||||
AI 很會寫程式,就要除錯,過程浪費很多 Token 及時間,但絕大部分是重複內容,例如登入認證、存取資料庫等。
|
||||
|
||||
@@ -310,7 +313,7 @@ acr update self-hosted:拉新版零件/引擎並重新
|
||||
acr update --force 強制重部所有 worker(忽略未變動跳過快取)
|
||||
```
|
||||
|
||||
> 給 AI 操盤手:開始前讀 `.claude/rules/06-mindset.md`(或 arcrun-mindset Skill)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。
|
||||
> 給 AI 操盤手:開始前讀 [`llms.txt`](llms.txt)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。跑過 `acr install-harness` 之後,同一套規範會裝進你的專案(arcrun-mindset Skill)。
|
||||
|
||||
---
|
||||
|
||||
@@ -333,7 +336,7 @@ MIT
|
||||
|
||||
arcrun 的核心架構、WASM 零件、CLI 工具鏈與這份文件,由以下貢獻者共同打造:
|
||||
|
||||
- **[@richblack](https://github.com/richblack)** — 創始人,產品設計與架構決策
|
||||
- **[@youlinhsieh](https://github.com/youlinhsieh)** — 創始人,產品設計與架構決策
|
||||
- **Claude(Anthropic)** — 實作夥伴:零件開發、executor 架構、CLI 實作與程式碼審查
|
||||
|
||||
歡迎加入:[CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
想投稿零件?看 [CONTRIBUTING-components.md](CONTRIBUTING-components.md)。
|
||||
|
||||
@@ -17,9 +17,8 @@ fetch_rows >> 對每個 row >> notify
|
||||
```
|
||||
|
||||
- 第一個節點固定是 `input`(觸發時帶進來的資料)
|
||||
- **邊只有兩種**:`ON_SUCCESS` 與 `對每個 <變數>`(FOREACH)
|
||||
- **沒有** `ON_TRUE`/`ON_FALSE`/`ON_FAILURE`——引擎不支援條件分支。
|
||||
需要判斷就寫成一個獨立節點再接 `ON_SUCCESS`。
|
||||
- **邊有這些**:`ON_SUCCESS`、`對每個 <變數>`(FOREACH)、`ON_TRUE`/`ON_FALSE`(配 `if_control`)、`ON_BRANCH`+`branch:`(配 `switch`/`try_catch`)
|
||||
- **沒有** `ON_FAILURE`——要處理失敗用 `try_catch` + `ON_BRANCH(catch)`。
|
||||
|
||||
完整語法、四份實跑過的範本、節點命名慣例 → 讀 **arcrun-mindset** Skill。
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ fetch_rows >> 對每個 row >> notify
|
||||
```
|
||||
|
||||
- 第一個節點固定是 `input`
|
||||
- 邊只有 `ON_SUCCESS` 與 `對每個 <變數>`(**沒有** `ON_TRUE`/`ON_FALSE`/`ON_FAILURE`)
|
||||
- 需要判斷 → 寫成獨立節點(例 `check_amount`)再接 `ON_SUCCESS`
|
||||
- 邊有 `ON_SUCCESS`、`對每個 <變數>`(FOREACH)、`ON_TRUE`/`ON_FALSE`(配 `if_control`)、`ON_BRANCH`+`branch:`(配 `switch`/`try_catch`);**沒有** `ON_FAILURE`
|
||||
- 需要判斷 → 用條件邊(`if_control` 配 `ON_TRUE`/`ON_FALSE`),不要寫 code 判斷
|
||||
|
||||
語法細節、四份實跑過的範本、節點命名慣例 → **arcrun-mindset** Skill。
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ if [ "$TOOL" = "Write" ] || [ "$TOOL" = "Edit" ] || [ "$TOOL" = "MultiEdit" ]; t
|
||||
# 只在 code 內容看起來在做流程控制/取代零件時提醒(含 if/for/fetch),單純整形不吵
|
||||
if echo "$CONTENT" | grep -qE '\b(if[[:space:]]*\(|for[[:space:]]*\(|fetch\(|await[[:space:]]+fetch)'; then
|
||||
remind "workflow 裡的 \`code\` 節點含流程控制/HTTP 呼叫——這可能是「腹語術」(表面用 Arcrun、實際全寫 JS)。" \
|
||||
"\`code\` 只用於局部整形(例:剝掉 LLM 回應的雜訊、切段落)。缺外部 API → 寫 recipe(\`acr recipe push\`);缺計算能力 → 投稿零件 PR;要判斷 → 寫成獨立節點接 \`ON_SUCCESS\`(引擎沒有條件邊)。每個寫進 code 的 if 都是沒被測過的新 bug。見 arcrun-mindset Skill。"
|
||||
"\`code\` 只用於局部整形(例:剝掉 LLM 回應的雜訊、切段落)。缺外部 API → 寫 recipe(\`acr recipe push\`);缺計算能力 → 投稿零件 PR;要判斷 → 用條件邊(\`if_control\` 配 \`ON_TRUE\`/\`ON_FALSE\`,或 \`switch\`/\`try_catch\` 配 \`ON_BRANCH\`),不要寫 code 判斷。每個寫進 code 的 if 都是沒被測過的新 bug。見 arcrun-mindset Skill。"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -54,17 +54,55 @@ description: >-
|
||||
- **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名**
|
||||
- **邊**=什麼情況下往下走
|
||||
|
||||
## 2. 邊只有兩種(真範本裡出現過的)
|
||||
## 2. 邊有這些
|
||||
|
||||
| 邊 | 意思 | 真例 |
|
||||
|---|---|---|
|
||||
| `ON_SUCCESS` | 上一步成功就往下 | `input >> ON_SUCCESS >> prep` |
|
||||
| `對每個 <變數>` | 上一步產出清單,逐項處理(FOREACH)| `parse_card >> 對每個 block >> post_block` |
|
||||
| `ON_TRUE` / `ON_FALSE` | 條件成立/不成立各走一條(配 `if_control`)| `判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram` |
|
||||
| `ON_BRANCH`+`branch:` | 依標籤選路(配 `switch` 每個 case、`try_catch` 的 try/catch)| `my_switch >> ON_BRANCH(branch_active) >> 處理啟用` |
|
||||
|
||||
⚠️ **不要寫 `ON_FAILURE`/`ON_TRUE`/`ON_FALSE`**——引擎目前**沒有條件分支**
|
||||
(實測 `grep ON_TRUE|ON_FALSE` 於 cypher-executor = 0;見 Gitea Arcrun#5)。
|
||||
需要判斷時:**寫成一個獨立節點**(例 `check_amount`)再接 `ON_SUCCESS`,
|
||||
讓查詢告訴你有沒有零件可用。
|
||||
### 2.1 條件分支怎麼寫(2026-08-01 起引擎支援)
|
||||
|
||||
**需要判斷時,用分支邊,不要寫 `code` 判斷。**
|
||||
三顆流程控制零件都輸出 `data.branch` 標籤,引擎依標籤選路:
|
||||
|
||||
| 零件 | 輸出的標籤 | 接法 |
|
||||
|---|---|---|
|
||||
| `if_control` | `"true"` / `"false"` | `ON_TRUE`/`ON_FALSE` 各一條 |
|
||||
| `switch` | 你在 `cases[].branch` 取的名字(沒中則 `default_branch`)| 每條路一條 `ON_BRANCH`,邊上標 `branch` |
|
||||
| `try_catch` | `"try"`(沒錯)/`"catch"`(有錯)| 兩條 `ON_BRANCH`,標 `try` 與 `catch` |
|
||||
|
||||
```
|
||||
判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram
|
||||
判斷有沒有新資料 >> ON_FALSE >> 結束
|
||||
```
|
||||
中文語意詞亦可:「成立時」=`ON_TRUE`、「否則」=`ON_FALSE`。
|
||||
|
||||
💡 **不必背**:查零件時回應會附 `branch_hint`(有哪些標籤、用哪些邊型、可照抄的範例),
|
||||
照著接就對了。
|
||||
|
||||
⚠️ 仍然**不要寫 `ON_FAILURE`**(沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)`)。
|
||||
|
||||
### 2.2 怎麼確認分支真的走對了(**別看不懂就以為壞掉**)
|
||||
|
||||
分支工作流「有沒有成功」看兩件事,**不是看某條沒走的路沒有輸出**:
|
||||
|
||||
1. **`verdict`**:`GET /workflows/<name>/executions?limit=1`
|
||||
→ `data.executions[0].verdict === "success"` 就是成功了。
|
||||
2. **`trace` 裡有沒有出現該走的節點**:走 TRUE 路時 FALSE 路的節點**本來就不該出現**
|
||||
——**那是正確行為,不是失敗**。
|
||||
|
||||
```
|
||||
# 條件成立 → 只有 true 那條的節點在 trace
|
||||
{"amount": 5000} → if_control 回 branch="true" → 走 ON_TRUE 那條
|
||||
{"amount": 100} → if_control 回 branch="false" → 走 ON_FALSE 那條
|
||||
```
|
||||
|
||||
🔴 **實撞(2026-08-01 考試)**:有考生的分支工作流**其實完全正常**
|
||||
(`amount=5000`→true、`amount=100`→false 都對),但它以為「跑不通」而放棄改寫成 code。
|
||||
**看到只有一條路有輸出=分支正在正確運作**,不要因此判定失敗。
|
||||
|
||||
## 3. 第一個節點固定是 `input`
|
||||
|
||||
@@ -157,7 +195,9 @@ curl -s -X POST https://arcrun-cypher-executor.<subdomain>.workers.dev/cypher/se
|
||||
|
||||
## 7. 常犯的錯
|
||||
|
||||
1. **用不存在的邊**(`ON_FAILURE`/`ON_TRUE`)→ 只有 `ON_SUCCESS` 與 `對每個 X`
|
||||
1. **用不存在的邊**(`ON_FAILURE`)→ 沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)`
|
||||
⚠️ `ON_TRUE`/`ON_FALSE`/`ON_BRANCH` **是存在的**(2026-08-01 起),見 §2.1——
|
||||
本行以前寫「ON_TRUE 不存在」是舊世代,已更正
|
||||
2. **第一個節點不是 `input`**
|
||||
3. **把 recipe 當零件寫**——`telegram_send`/`gmail`/`kbdb_get` 是 **recipe** 不是零件
|
||||
→ 寫成 `http_request` + 該 recipe
|
||||
|
||||
+1
-1
@@ -44,6 +44,6 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/uncle6me-web/Arcrun.git"
|
||||
"url": "git+https://github.com/youlinhsieh/Arcrun.git"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,17 @@ const CHECKS = [
|
||||
['not_found', '現世代查詢狀態(舊版寫 missing/假 found)'],
|
||||
['腹語術', '缺件不准改寫成 code 的紅線'],
|
||||
['recipe', '零件 vs recipe 分型'],
|
||||
// 條件邊自 2026-08-01 起引擎已支援(cypher-executor/src/graph-executor.ts
|
||||
// case 'ON_TRUE'/'ON_FALSE'/'ON_BRANCH',31 個測試全過)。教材該教會怎麼用,
|
||||
// 不是教「不存在」——這條 must 同時防「哪天又被改回舊世代說法」的回歸。
|
||||
['ON_TRUE', '條件邊(配 if_control)自 2026-08-01 起引擎已支援,教材須教會用法'],
|
||||
],
|
||||
mustNot: [
|
||||
// ON_TRUE 只准出現在「教它不存在」的脈絡(否定詞/實測證據)。
|
||||
// 若哪天它出現在範本裡(正面示範),就是教材寫錯,該擋。
|
||||
['ON_TRUE', '引擎沒有條件邊,教材不該把它當可用的邊', /不要寫|不存在|沒有條件|❌|非法|grep|= 0/],
|
||||
// ON_FAILURE 才是真的不存在(VALID_EDGE_TYPES 只有 ON_FAIL,見
|
||||
// cypher-executor/src/lib/constants.ts)。只准出現在「教它不存在」的脈絡。
|
||||
// 2026-08-10 修正:這道閘原本擋的是 ON_TRUE——但 ON_TRUE/ON_FALSE/ON_BRANCH
|
||||
// 已是引擎現世代能力,正確教材反而被這道閘擋下,是閘的判準過時了,不是教材寫錯。
|
||||
['ON_FAILURE', '引擎沒有這種邊(只有 ON_FAIL);教材不該把它教成可用的邊', /不要寫|不存在|沒有這種|❌|非法/],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -230,15 +230,16 @@ async function initSelfHosted(
|
||||
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
|
||||
}
|
||||
|
||||
// 3.5 語義查詢開關(issue #7 / T2.4):問用戶要不要開(預設關,free-tier 友善)。
|
||||
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword,零花費。
|
||||
// 之後想開:跟 CC 說「幫我開語義查詢」或設 kbdb_embed:true + acr update(不必重 init)。
|
||||
// 3.5 語義查詢(issue #7 / T2.4):**預設開**(2026-08-09 翻轉,leo:「語義搜尋已經
|
||||
// 確定是一安裝就提供的功能」——預設關會產出一批「看起來裝好了、其實少一條腿」的
|
||||
// 實例,之後畫面上還被誤說成「沒開通」)。顯式回答 n 才關(極端省額度者自選)。
|
||||
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword。
|
||||
const embedAns = (await prompt(
|
||||
rl,
|
||||
'要開語義查詢嗎?(KBDB 加 AI 向量搜尋;用 CF Vectorize,可能多花費;預設關,之後可隨時開) [y/N]',
|
||||
'要開語義查詢嗎?(內建功能,建議保持開啟;用 CF Vectorize,有免費額度) [Y/n]',
|
||||
)).trim().toLowerCase();
|
||||
const kbdbEmbed = embedAns === 'y' || embedAns === 'yes';
|
||||
if (kbdbEmbed) console.log(chalk.gray(' → 已選開語義查詢:部署時會建 Vectorize index。'));
|
||||
const kbdbEmbed = !(embedAns === 'n' || embedAns === 'no');
|
||||
if (!kbdbEmbed) console.log(chalk.yellow(' → 已選關語義查詢:這台實例將只有關鍵字搜尋(之後可設 kbdb_embed:true + acr update 補開)。'));
|
||||
|
||||
// 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker
|
||||
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
|
||||
|
||||
@@ -84,9 +84,13 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
// self-hosted → 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
|
||||
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
|
||||
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
|
||||
// 語義查詢開關(issue #7):config.kbdb_embed:true → 部署建 Vectorize index + 注入 binding。
|
||||
// 這也是「CC 幫開」的落地路徑:CC 寫 kbdb_embed:true 進 config → acr update redeploy 即生效。
|
||||
kbdbEmbed: config.kbdb_embed === true,
|
||||
// 語義查詢(issue #7):預設**開**,只有 config 顯式寫 kbdb_embed:false 才關。
|
||||
// 🔴 2026-08-09 翻轉預設(leo:「語義搜尋已經確定是一安裝就提供的功能」)。
|
||||
// 舊判斷 `=== true` 的實害:config 沒這個欄位(舊 config / 一鍵安裝實例本機補跑 update)
|
||||
// 時 redeploy 會把 kbdb 的 [[vectorize]]+[ai] binding 靜默剝掉——一台**原本正常**的
|
||||
// 實例就這樣失去語意搜尋,畫面上還被說成「還沒開通」。wrangler deploy 是整份覆蓋,
|
||||
// binding 不在 toml 裡=直接消失,這正是「裝好的實例壞掉」的機制之一。
|
||||
kbdbEmbed: config.kbdb_embed !== false,
|
||||
};
|
||||
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
|
||||
|
||||
@@ -28,10 +28,12 @@ export interface ArcrunConfig {
|
||||
mcp_url?: string;
|
||||
multi_tenant?: boolean;
|
||||
// 語義查詢開關(issue #7 / SDD T2.4,self-hosted 從零做)。
|
||||
// true → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding;
|
||||
// 🔴 2026-08-09 預設翻轉(leo:「語義搜尋已經確定是一安裝就提供的功能」):
|
||||
// 未設 → **視同開**(init/update 皆以 `!== false` 判斷)。只有顯式 false 才關。
|
||||
// true/未設 → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding;
|
||||
// kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。
|
||||
// 未設/false → base 維持 LIKE keyword(free-tier 友善,不建 index、不花費)。
|
||||
// 開法:設 kbdb_embed:true → redeploy(acr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
|
||||
// false → base 維持 LIKE keyword(顯式選擇才有這個狀態;缺欄位不再等於關——
|
||||
// 舊語意會讓 acr update 把正常實例的 binding 靜默剝掉,畫面再謊稱「沒開通」)。
|
||||
kbdb_embed?: boolean;
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
|
||||
+65
-15
@@ -163,8 +163,27 @@ export interface DeployContext {
|
||||
kbdbEmbed?: boolean;
|
||||
}
|
||||
|
||||
/** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */
|
||||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed';
|
||||
/**
|
||||
* Vectorize index 名(kbdb embed 模組用)。**bge-m3 = 1024 維、cosine。**
|
||||
*
|
||||
* 🔴 2026-08-03 換代(leo 拍板;5 組中文測資實證:舊 `bge-base-en-v1.5` 排序 2/5、
|
||||
* margin −0.0413=**中文根本不能用**;`bge-m3` 5/5、+0.1410、959ms)。
|
||||
* leo 08-05:「換 embed model 當然要合併,當然要換 vectorize,原本的根本不能用」。
|
||||
*
|
||||
* **換模型必須換 index,且必須換「名字」**:
|
||||
* ① 維度 768→1024,舊 index 收不進新向量
|
||||
* ② 就算維度相同也不能沿用——不同模型的向量混在同一 index,比對出來是垃圾;
|
||||
* 而 #58(Vectorize vector delete 未接)代表舊向量刪不掉
|
||||
* ⇒ **開新名字的 index 反而順手繞開 #58**,且新舊並存可回滾。
|
||||
*
|
||||
* ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用(deploy.ts:426)
|
||||
* ⇒ t36 的四個 metadata index(owner_id/entry_type/source/library,Arcrun#11 根因修復)
|
||||
* 會自動建在新 index 上,**不會因為改名而遺失**(已查證,非假設)。
|
||||
*
|
||||
* 既有實例遷移:部署後 `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0,
|
||||
* 確認語意查詢正常後,舊的 `arcrun-kbdb-embed` 可自行刪除。
|
||||
*/
|
||||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3';
|
||||
|
||||
export interface DeployResult {
|
||||
implemented: boolean;
|
||||
@@ -317,20 +336,49 @@ export async function downloadAndDeploy(
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql(${migPath})`);
|
||||
}
|
||||
|
||||
// 3.6 credentials 目錄表(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at)。
|
||||
// 現行 credential 規範見 .claude/rules/01-tech-stack.md「Credential 儲存規範」。
|
||||
// 同一顆 D1(與 KBDB base 共用),冪等 IF NOT EXISTS,套用機制與 0001_base.sql 完全相同
|
||||
// (同一個 applyD1Migration helper,同一支 CF D1 query API)。D19:這張表不含密文,
|
||||
// 密文本體住在 Workers per-script Secrets(見 cypher-executor/src/routes/credentials.ts)。
|
||||
const credMigPath = join(root, 'kbdb', 'migrations', '0002_credentials.sql');
|
||||
if (existsSync(credMigPath)) {
|
||||
// 3.6 credential template seed(D38 圍牆修復,總管交辦,2026-08-07):credential 目錄改走
|
||||
// KBDB template 機制(entries 表 entry_type='credential',比照 recipe_stat/execution_log
|
||||
// 慣例),取代舊的獨立 credentials 表(0002,已退役,見該檔頭部說明)。冪等,套用機制
|
||||
// 與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets(見
|
||||
// cypher-executor/src/routes/credentials.ts),D19「擁有目錄不擁有內容物」不變。
|
||||
const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
|
||||
if (existsSync(credTplMigPath)) {
|
||||
try {
|
||||
await applyD1Migration(ctx, readFileSync(credMigPath, 'utf8'));
|
||||
await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0002_credentials (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0002_credentials.sql(${credMigPath})`);
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql(${credTplMigPath})`);
|
||||
}
|
||||
|
||||
// 3.6b 退役舊 credentials 表(D38,2026-08-07):把該表殘留資料(若有)搬進 entries 後
|
||||
// 拆表,讓 KBDB 回到「只有三張核心表」的狀態。冪等且對「從未跑過 0002」的全新實例
|
||||
// 無害(表不存在時本檔第一步先補空殼再立刻拆掉,詳見檔頭)。每次部署都會重跑,
|
||||
// 但真資料只搬一次(NOT EXISTS 判斷防重複)。
|
||||
const dropCredMigPath = join(root, 'kbdb', 'migrations', '0006_drop_credentials_table.sql');
|
||||
if (existsSync(dropCredMigPath)) {
|
||||
try {
|
||||
await applyD1Migration(ctx, readFileSync(dropCredMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0006_drop_credentials_table (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql(${dropCredMigPath})`);
|
||||
}
|
||||
|
||||
// 3.7 execution_log template seed(KV 額度事故修復,2026-08-07):workflow 執行紀錄改走
|
||||
// KBDB template 機制(entries 表 entry_type='execution_log',比照 recipe_stat 慣例;
|
||||
// schema 零異動,只 seed 一列 template 定義,同 0001_base.sql §3 手法,self-hosted 同步套用)。
|
||||
const execLogMigPath = join(root, 'kbdb', 'migrations', '0004_execution_log_template.sql');
|
||||
if (existsSync(execLogMigPath)) {
|
||||
try {
|
||||
await applyD1Migration(ctx, readFileSync(execLogMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0004_execution_log_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql(${execLogMigPath})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +436,9 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=768/metric=cosine,對齊 bge-base-en-v1.5)。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=1024 / metric=cosine,對齊 bge-m3)。
|
||||
* ⚠️ 這行別寫成 `**dimensions=1024**/metric`——`*` 緊接 `/` 會提早關掉 block comment(實撞 TS1127)。
|
||||
* 維度必須與 `kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 一致——不一致時 upsert 直接被 CF 拒絕。
|
||||
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
|
||||
*/
|
||||
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
@@ -398,8 +448,8 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: KBDB_VECTORIZE_INDEX,
|
||||
config: { dimensions: 768, metric: 'cosine' },
|
||||
description: 'arcrun KBDB optional embed module (issue #7)',
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
@@ -1,22 +1,68 @@
|
||||
{
|
||||
"_readme": [
|
||||
"部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名』。",
|
||||
"部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名+對外網址』。",
|
||||
"",
|
||||
"為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成 build 期參數。",
|
||||
"誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。今天實際踩到的:",
|
||||
"為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成部署期參數。",
|
||||
"誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。實際踩過的:",
|
||||
" · demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo 看到『Mira 介面』的真因)",
|
||||
" · 兩站都漏 ARCRUN_API_BASE → apiBase 空字串 → 前端打自己回 405 → 登不進去",
|
||||
" · 兩個帳號有同名 arcrun-console-ui 專案,wrangler 又登入在 uncle6",
|
||||
" → 不指定帳號直接 deploy 會部到 demo 站上(差點蓋掉)",
|
||||
"",
|
||||
"🔴 第四次(2026-08-08 發現,同一種病換了形式):",
|
||||
" 上面三次的『解』是 deploy.targets.json + build.mjs 在 build 時把 profile/apiBase",
|
||||
" 烤進產物。但 t160(e744ad1)為了清世代債把 build.mjs 整支刪掉、改成直接託管 public/,",
|
||||
" **沒有人把『把宣告值寫進產物』這件事接手過去** ⇒ deploy.mjs 照樣在終端機印",
|
||||
" 『profile:full / apiBase:…leo21c…』,推上去的卻是 public/config.js 裡凍住的",
|
||||
" cypher.arcrun.dev + 凍在 4 頁的 VIEWS。也就是說:",
|
||||
" **`npm run deploy:personal` 會把個人站的 API 打到企業 demo 的後端、頁面砍成 4 頁**,",
|
||||
" 而終端機從頭到尾顯示『成功』。(第三次的 accountId 是靠 env 傳的,倖存;前兩次的解等於被還原。)",
|
||||
"",
|
||||
" → 現在的規矩:**產物由 deploy.mjs 依本檔即時產生(.staging/<目標>),",
|
||||
" 推之前驗產物、推之後驗線上網址**。public/ 裡不再放任何跟目標有關的值。",
|
||||
" · public/config.js 已刪除——它是產物不是原始碼(自架站的 /config.js 由",
|
||||
" arcrun-rag 的 build-ui-bundle 動態產生,不吃這個檔)",
|
||||
" · public/console/index.html 的 VIEWS/HOME 只是本機 preview 的預設值,",
|
||||
" 部署時一律被 _profiles 覆寫,覆寫沒命中就中止部署",
|
||||
"",
|
||||
"🔴 第五次(2026-08-08 同日,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,",
|
||||
" 你要確定不可再犯」):**組態對 ≠ 世代對**。",
|
||||
" 當天實測:三個對外網址的 apiBase/views/home **三項全過**,",
|
||||
" 但它們跑的是 07-22 那一代的 portal(82,911 bytes、舊金色 serif 品牌、Songti 12 處),",
|
||||
" repo 已是 343,969 bytes 的新品牌世代。**組態全綠、介面落後半個月,沒有任何檢查會叫。**",
|
||||
" → 故 verify-live 加第二層「世代指紋」:逐一抓線上資產、遮掉本來就該隨目標不同的",
|
||||
" 那兩行(VIEWS/HOME),其餘按位元組比對 repo public/。",
|
||||
" 不用關鍵字清單——清單要人維護,而舊世代能無聲上線正是因為沒人記得維護它。",
|
||||
"",
|
||||
"版本差異(leo 2026-07-22 定調):頁面都存在,由 profile 決定顯示哪些。",
|
||||
" personal(full) 個人版:7 頁全開,落地駕駛艙",
|
||||
" enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁",
|
||||
" 未來擴充:個人版新用戶上限 1、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。",
|
||||
"",
|
||||
"用法:npm run deploy:personal / npm run deploy:enterprise"
|
||||
"🧊 frozen 欄位(2026-08-08 leo 立):標了 frozen 的目標=**這個帳號的資源不歸我們動**。",
|
||||
" deploy 拒絕部署它,verify 連抓都不抓(不 curl、不探測)。",
|
||||
" 它不是「壞掉所以跳過」,是刻意的邊界;要解凍是人的決定(拿掉欄位並說明理由)。",
|
||||
" 目標本身**保留不刪**——刪掉就變成下一個 AI 眼中「從來沒有過這個站」的失憶。",
|
||||
"",
|
||||
"用法:npm run deploy:personal",
|
||||
" npm run deploy:personal -- --dry-run (只產出並驗產物,不推)",
|
||||
" npm run verify (不部署,只驗線上:組態=宣告值、世代=當代)",
|
||||
" npm run verify -- --url <網址> (只問某個網址:它跑的是不是當代的)"
|
||||
],
|
||||
|
||||
"_profiles": {
|
||||
"full": {
|
||||
"description": "個人版:7 頁全開,落地駕駛艙",
|
||||
"views": ["cockpit", "search", "card", "workflows", "creds", "inbox", "settings"],
|
||||
"home": "cockpit"
|
||||
},
|
||||
"rag": {
|
||||
"description": "企業版:搜尋/card/工作流/設定,落地搜尋頁",
|
||||
"views": ["search", "card", "workflows", "settings"],
|
||||
"home": "search"
|
||||
}
|
||||
},
|
||||
|
||||
"personal": {
|
||||
"description": "leo 私人實例(原 Mira)。入口 mira.uncle6.me → leo21c worker。",
|
||||
"accountId": "51a01bfa2665bd7bc3fd080dc40cf3e1",
|
||||
@@ -24,6 +70,7 @@
|
||||
"profile": "full",
|
||||
"brand": "Arcrun",
|
||||
"apiBase": "https://arcrun-cypher-executor.leo21c.workers.dev",
|
||||
"verifyUrls": ["https://mira.uncle6.me", "https://arcrun-console-ui.pages.dev"],
|
||||
"limits": {
|
||||
"maxUsers": 1,
|
||||
"libraryPermissions": false
|
||||
@@ -31,12 +78,14 @@
|
||||
},
|
||||
|
||||
"enterprise": {
|
||||
"description": "企業版 demo 站。rag-demo.arcrun.dev → uncle6 帳號 cypher。",
|
||||
"frozen": "leo 2026-08-08:「要看範例只在 youlin 網站,不要去碰 uncle6」——這站是 uncle6 帳號的資源,已廢。不更新、不下架、不探測。要動它是 leo 的閘。",
|
||||
"description": "【已凍結・沿革】企業版 demo 站(uncle6 帳號)。保留紀錄用,不是現行部署對象。",
|
||||
"accountId": "58309bb90fd93ad6d0fe0aae99170e9d",
|
||||
"projectName": "arcrun-console-ui",
|
||||
"profile": "rag",
|
||||
"brand": "Arcrun",
|
||||
"apiBase": "https://cypher.arcrun.dev",
|
||||
"verifyUrls": ["https://rag-demo.arcrun.dev"],
|
||||
"limits": {
|
||||
"maxUsers": null,
|
||||
"libraryPermissions": true
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"name": "arcrun-console-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm,直接託管)",
|
||||
"description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm);部署時由 deploy.mjs 依 deploy.targets.json 產出 .staging/<目標> 再推",
|
||||
"scripts": {
|
||||
"deploy": "node scripts/deploy.mjs",
|
||||
"deploy:personal": "node scripts/deploy.mjs personal",
|
||||
"deploy:enterprise": "node scripts/deploy.mjs enterprise",
|
||||
"preview": "npx serve public"
|
||||
"verify": "node scripts/verify-live.mjs",
|
||||
"preview": "node scripts/deploy.mjs personal --dry-run && npx serve .staging/personal"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: "https://cypher.arcrun.dev" };
|
||||
@@ -445,6 +445,15 @@ window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">Portal 帳號密碼救援</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。</div>
|
||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<input type="email" id="st-portal-recover-email" class="txt" placeholder="Portal 帳號 Email">
|
||||
<button class="btn" id="st-portal-recover-btn">產生新密碼</button>
|
||||
<div id="st-portal-recover-status" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
|
||||
<div id="st-info"><div class="muted">載入中…</div></div>
|
||||
@@ -862,7 +871,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
var libs = x.d.libraries || [];
|
||||
if (!libs.length) {
|
||||
lmHonest('還沒有藏書地圖', '還沒有任何庫跑過重算——對 KBDB 呼 <code style="font-size:12.5px">POST /map/recompute?library=庫名</code> backfill 後,這裡會出現全館導覽。<br>不影響下方搜尋,可直接搜全庫。');
|
||||
lmHonest('還沒有藏書地圖', '這個租戶目前沒有任何三元組資料(地圖是查詢時即時核對重算的,不是要人手動 backfill——資料一進來下次載入就會出現)。<br>不影響下方搜尋,可直接搜全庫。');
|
||||
return;
|
||||
}
|
||||
LM.libs = libs; LM.details = {};
|
||||
@@ -970,7 +979,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + '</span>'; return; }
|
||||
var d = x.d;
|
||||
if (S.semantic && d.mode === 'keyword') {
|
||||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
|
||||
// 2026-08-09 leo:語意搜尋是安裝即提供的功能,降級=故障,不說「尚未啟用」。
|
||||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋目前故障</div><div class="b">' + esc(d.capability_hint || '語意搜尋目前故障(實例缺 Vectorize/AI 設定),以下先給關鍵字結果,不假裝是語意結果。') + '<br>維運資訊:' + esc(d.admin_hint || '(此版本後端未回報細節)') + '</div></div>';
|
||||
}
|
||||
var entries = d.entries || [];
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +
|
||||
@@ -1456,17 +1466,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
.then(function (d) {
|
||||
// t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示——
|
||||
// 沒有東西要用戶操作;未啟用才給一句人話與下一步。
|
||||
// 2026-08-09 leo:語意搜尋是安裝即提供的功能——探測到降級=這台實例壞了,
|
||||
// 照實標「故障」,不說「尚未啟用」(那會把 bug 說成沒提供的功能)。
|
||||
var on = d.mode === 'semantic';
|
||||
$('st-vec').textContent = on
|
||||
? '● 已啟用——搜尋頁切到「語意」就能用意思找資料。'
|
||||
: '○ 尚未啟用——目前用關鍵字搜尋,不會假裝有語意結果。';
|
||||
? '● 正常——搜尋頁切到「語意」就能用意思找資料。'
|
||||
: '○ 故障——語意搜尋是內建功能,這台實例現在少了它(系統端問題,不是操作問題)。';
|
||||
var hint = $('st-vec-hint');
|
||||
if (on) {
|
||||
hint.style.display = 'none';
|
||||
} else {
|
||||
hint.style.display = '';
|
||||
hint.innerHTML = '一鍵安裝的實例會在安裝時自動開通語意索引。'
|
||||
+ '如果你這個實例是較早裝的、或安裝當下開通沒成功,重新跑一次安裝流程即可補上(已建好的資料不會重來)。';
|
||||
hint.innerHTML = '修復方式:重新跑一次安裝流程(用原本的 Cloudflare 帳號),會把缺的語意索引設定補回來;已建好的資料不會重來。'
|
||||
+ (d.admin_hint ? '<br>維運資訊:' + esc(d.admin_hint) : '');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
@@ -1523,6 +1535,27 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
})
|
||||
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
|
||||
});
|
||||
// arcrun-rag#25:portal admin 密碼救援——只吃 console owner session(S.token,本頁登入用的
|
||||
// 那把),不吃 portal session,所以就算忘記 portal 密碼、進不去 portal 也走得通。
|
||||
$('st-portal-recover-btn').addEventListener('click', function () {
|
||||
var email = $('st-portal-recover-email').value.trim();
|
||||
var st = $('st-portal-recover-status');
|
||||
if (!email) { st.innerHTML = '<span class="err">請輸入 Email</span>'; return; }
|
||||
st.textContent = '處理中…';
|
||||
fetch(API_BASE + '/portal/admin/recover-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { st.innerHTML = '<span class="err">' + esc(x.d.error || '失敗') + '</span>'; return; }
|
||||
st.innerHTML = '<span class="ok">新密碼:<code style="font-size:15px;user-select:all">' + esc(x.d.password) + '</code>(只顯示這一次,請立刻抄下)</span>';
|
||||
$('st-portal-recover-email').value = '';
|
||||
toast('新密碼已產生,請立刻抄下');
|
||||
})
|
||||
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
|
||||
});
|
||||
// t36:原本這裡綁在那顆假開關上(點了只會 toast 一段 CLI 指示)。開關已移除,
|
||||
// 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener
|
||||
// 當場拋錯,把後面所有綁定(含登出)一起打斷。
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
根目錄直接導向搜尋 Portal。
|
||||
|
||||
為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到):
|
||||
這個網域(rag-demo.arcrun.dev)是給**客戶測試**用的入口,
|
||||
客戶測試指南寫的就是「一個網址、一組帳密」——多一層選擇=多一個困惑點,
|
||||
這份 UI 部署出去的網址是給**使用者**的入口(個人站 mira.uncle6.me,
|
||||
以及自架用戶自己的網址),進站就是要能用——多一層選擇=多一個困惑點,
|
||||
(2026-08-08 更正:原註解寫「這個網域=rag-demo.arcrun.dev 是客戶測試入口」,
|
||||
那是 uncle6 帳號那個已廢的 demo 站,leo 已定案不再拿它當範例;
|
||||
註解留著會把下一個人導向錯的環境,故改寫。理由本身仍然成立。)
|
||||
而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。
|
||||
|
||||
維運者要進 console 直接打 /console/ 即可。
|
||||
|
||||
@@ -220,6 +220,7 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
|
||||
<div style="font-size:13px;color:rgba(var(--ink-rgb),.4);line-height:1.7">你自己就是管理員?<a href="/console/" style="color:var(--amber)">用管理主控台密碼救援自己</a></div>
|
||||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,6 +402,21 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div style="margin-top:8px;font-size:13px;line-height:1.7;color:rgba(var(--ink-rgb),.5)">封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 08-09(arcrun-rag#7,封測者原話「說明叫我把 MCP 加進 claude.ai connector,但我找不到網址」):
|
||||
文件一直寫「登入 portal 設定頁直接複製」,但畫面上從沒真的顯示過這串網址——用戶照著文件的
|
||||
指示走到這裡,只會撲空。這裡補上:MCP 網址跟知識庫網址(apiBase)是同一顆自架帳號的
|
||||
workers.dev 子網域,只是 worker 名字從 arcrun-cypher-executor 換成 arcrun-mcp
|
||||
(CLI 部署當時就是這樣組出這兩個網址的,見 cli/src/lib/deploy.ts:386-392)——
|
||||
純前端字串轉換,不需要後端新端點、不需要安裝器多寫一份設定。 -->
|
||||
<div class="panel">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:13.5px">
|
||||
<span style="color:rgba(var(--ink-rgb),.6);white-space:nowrap">你的 MCP 網址(給你的 AI 連線用)</span>
|
||||
<code id="st-mcp-url" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)">—</code>
|
||||
<button class="btn3" id="st-copy-mcp-url" style="padding:5px 12px;font-size:13px;white-space:nowrap;flex:none">複製</button>
|
||||
</div>
|
||||
<div style="font-size:17px;font-weight:600">接上你的 AI(MCP)</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">把上面這串網址貼到 Claude、ChatGPT 等 AI 的「新增自訂連接器」欄位,就能讓你的 AI 直接查這個知識庫。</div>
|
||||
</div>
|
||||
<!-- t176(leo 08-03):AI 設定整塊移除。
|
||||
雲端聊天問答走 Workers AI(用戶自己 CF 帳號內建,**免金鑰、裝好就能用**);
|
||||
地端萃取用哪把金鑰改由「同步小幫手」自己設定(托盤選單「AI 設定…」)。
|
||||
@@ -415,6 +431,26 @@ if (!window.ARCRUN_API_BASE) {
|
||||
文件整理成知識卡的部分,請在<b style="color:var(--ink)">同步小幫手</b>(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。
|
||||
</div>
|
||||
</div>
|
||||
<!-- 檢修孔演進史:
|
||||
2026-08-07 leo 直接指令「一顆按鈕在設定裡,按鈕下載一個檔案,把檔案發給我」
|
||||
→「疑難排解」面板+#st-diag-export 按鈕誕生,打 GET /portal/data/diagnostics。
|
||||
|
||||
2026-08-08(t213,InkStoneCo 總管交辦)發現這顆按鈕在**封測者的瀏覽器**裡執行,
|
||||
跟他電腦上的 daemon 是兩個獨立行程,構不到本機資料(檔案總量/失敗分類/
|
||||
daemon 版本)——完整版改在 arcrun-app(同步小幫手)「版本與更新」頁本機端匯出
|
||||
(打新端點 GET /portal/daemon/diagnostics)。當時按鈕先保留當退路,文案改成
|
||||
誠實講清楚自己只有一半、導去完整版。
|
||||
|
||||
2026-08-09(leo 拍板拿掉):leo 08-08「雲端那個要刪掉?不刪用戶搞不清楚要去
|
||||
哪裏下載」,封測者已被通知去更新到有地端匯出的版本後,08-09 追認「通知完畢
|
||||
可以刪除」。⇒ 按鈕與 #st-diag-export/#st-diag-status 一併移除,面板改成純文字
|
||||
指路(同步小幫手才是唯一還按得到、也答得出完整診斷的地方)。
|
||||
GET /portal/data/diagnostics 端點本身留著未刪(無害、未被任何 UI 呼叫,
|
||||
純粹清路標,不動後端)。 -->
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">疑難排解</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">要回報問題,請到你電腦上的 <b style="color:var(--ink)">Arcrun</b>(同步小幫手)「版本與更新」頁——那裡的「疑難排解」按一下就能匯出完整診斷檔給我們(只有統計數字,不含你的任何文件內容)。</div>
|
||||
</div>
|
||||
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,6 +459,21 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div class="view page" id="v-admin">
|
||||
<div class="pagehead"><span class="t">管理</span><span class="m">帳號與知識庫授權</span></div>
|
||||
|
||||
<div class="sechead">執行紀錄保留期</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">保留天數</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.55);margin-bottom:12px">執行紀錄是稽核資料,超過保留天數會被每日自動清除;預設 90 天(3 個月),也可設為「不刪除」(企業稽核用途)。</div>
|
||||
<div class="formrow">
|
||||
<input type="number" id="ad-ret-days" class="txt" min="1" step="1" placeholder="天數(例:90)">
|
||||
<label style="display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:15px;padding:0 4px">
|
||||
<input type="checkbox" id="ad-ret-never"> 不刪除
|
||||
</label>
|
||||
<button class="btn" id="ad-ret-save" style="flex:none;padding:0 20px">儲存</button>
|
||||
</div>
|
||||
<div id="ad-ret-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
|
||||
<div id="ad-ret-current" class="muted" style="font-size:13px;margin-top:2px"></div>
|
||||
</div>
|
||||
|
||||
<div class="sechead">帳號管理</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">新增同仁帳號</div>
|
||||
@@ -635,12 +686,25 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
return src.slice(5).replace(/#[^#]*$/, '');
|
||||
}
|
||||
function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; }
|
||||
// 內部型別 → 人話標籤(2026-08-07:entry_type 原始值如 wiki_card / block / execution_log
|
||||
// 是資料庫內部分類,不是用戶該懂的詞——尤其 wiki_card 直接違背上傳頁自己講的「AI 整理後
|
||||
// 會以 wiki 卡形式出現」,若卡片上貼的標籤是英文 snake_case「wiki_card」,等於自打嘴巴。
|
||||
// 未知型別一律落地成中性的「筆記」,不吐原始英文字串給使用者。
|
||||
var ENTRY_TYPE_LABEL = {
|
||||
wiki_card: '知識卡', block: '知識卡', value: '記錄', workflow: '工作流',
|
||||
execution_log: '執行紀錄', todo: '待辦', inbox: '收件', user_template: '範本',
|
||||
recipe_submission: '投稿', agent_feedback: '回饋'
|
||||
};
|
||||
function entryTypeLabel(t) { return ENTRY_TYPE_LABEL[t] || '筆記'; }
|
||||
// 搜尋模式的原始值(keyword/semantic)是 API 參數,不是用戶詞彙——一律轉中文再顯示。
|
||||
var SEARCH_MODE_LABEL = { keyword: '關鍵字', semantic: '語意', graph: '圖譜' };
|
||||
function searchModeLabel(m) { return SEARCH_MODE_LABEL[m] || m; }
|
||||
function entryTitle(e) {
|
||||
if (e.page_name) return e.page_name;
|
||||
var first = String(e.content || '').split(/\r?\n/).find(function (l) { return l.trim(); }) || '';
|
||||
first = first.replace(/^#+\s*/, '').replace(/^[-*>]\s*/, '').trim();
|
||||
if (first.length > 60) first = first.slice(0, 60) + '…';
|
||||
return first || '(無標題・' + (e.entry_type || 'entry') + ')';
|
||||
return first || '(無標題・' + entryTypeLabel(e.entry_type) + ')';
|
||||
}
|
||||
function entrySnippet(e) {
|
||||
var lines = String(e.content || '').split(/\r?\n/).filter(function (l) { return l.trim(); });
|
||||
@@ -757,7 +821,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { dropSession(); return; }
|
||||
// 🔴 arcrun-rag#66:**只有 401 才算「你被登出了」**。
|
||||
// 舊版是 `if (!x.ok) dropSession()` = 任何非 2xx 都清掉 token——
|
||||
// 包含改完密碼後那幾十秒的 503(認證 secret 正在鋪開)與 502(KBDB 暫時不可達)。
|
||||
// 那正是 leo 08-10「改完密碼、重新整理就回不去」的最後一哩:
|
||||
// 後端就算不刪 KV,前端自己把鑰匙丟了,結果一樣。
|
||||
if (x.status === 401) { dropSession(); return; }
|
||||
if (!x.ok) {
|
||||
// 暫時性故障:留著 session,告訴他這是暫時的、下一步做什麼。
|
||||
showAuth();
|
||||
$('login-status').textContent = (x.d && x.d.code === 'auth_store_propagating')
|
||||
? '認證資料正在更新中(通常幾十秒),請稍候重新整理——你並沒有被登出。'
|
||||
: '服務暫時不可用,請稍後重新整理(你的登入沒有失效)。';
|
||||
return;
|
||||
}
|
||||
S.profile = x.d;
|
||||
showApp();
|
||||
})
|
||||
@@ -796,10 +873,28 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// 判不出 OS 時**兩個都給**,不替用戶猜;且無論判成哪個,頁面都留「不是這個系統?」的另一版連結
|
||||
// ——UA 會判錯,判錯時用戶要有路可走(施工圖 §3 注意事項 1、3)。
|
||||
var DAEMON_BASE_DEFAULT = 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/';
|
||||
var DAEMON_MAC = 'ArcrunRAG-mac-unsigned.zip';
|
||||
// 🔴 2026-08-05 leo:「Mac 強調要包裝成 application + dmg 的格式,拖進去就會放到
|
||||
// application,**解決之前直接在下載資料夾啟動造成更新問題**」
|
||||
// ⇒ Mac 一律給 **DMG**(開啟後是「把 Arcrun 拖進 Applications」的標準畫面),
|
||||
// 不再給 zip——zip 解開就是 .app,使用者很可能直接在「下載」資料夾雙擊啟動,
|
||||
// 而自更新會蓋錯位置(t184 Oscar 的病)。
|
||||
// ⚠️ 用**固定檔名**(不帶版號),否則每出一版都要改這裡的 code。
|
||||
//
|
||||
// 🔴 2026-08-06 升級(leo:「Portal 和 rag.arcrun.dev 應該顯示同步器的版本⋯⋯
|
||||
// **連我都沒辦法確認**,所以用戶到底是否最新版他自己也不知道」):
|
||||
// 下面兩個常數**降級為退路**,正常情況改向 `/api/latest` 取
|
||||
// `daemon.version` 與 `daemon.downloads`(真相源=bundles 的 manifest)。
|
||||
// ⚠️ 這**沒有違背**上面那條「不要每出一版就改 code」——網址現在是**取來的**,
|
||||
// 一樣不用改 code;而且順便解掉固定別名的兩個老問題:
|
||||
// ① 別名指向 @main,會吃到 CDN/ref 快取拿到舊檔(08-04 撞過)
|
||||
// ② 檔名不帶版號 ⇒ 頁面上無從顯示「這是哪一版」=leo 這次抱怨的正題
|
||||
// 取不到就退回這兩個固定檔名,按鈕不會變死連結。
|
||||
var DAEMON_MAC = 'ArcrunRAG-mac.dmg';
|
||||
var DAEMON_WIN = 'ArcrunRAG-win-unsigned.zip';
|
||||
// Mac 那顆 21MB > jsDelivr 單檔 20MB 上限(實測回 "File size exceeded...")→ 一律走 raw;
|
||||
// Windows 13MB 雖在限內,同走 raw 保持單一來源、少一個會壞的地方。
|
||||
// 由 /api/latest 填入(見下方 loadDaemonLatest);null=還沒取到或取不到。
|
||||
var DAEMON_LATEST = null;
|
||||
// Mac 那顆 > jsDelivr 單檔 20MB 上限(實測回 "File size exceeded...")→ 一律走 raw;
|
||||
// Windows 同走 raw 保持單一來源、少一個會壞的地方。
|
||||
function daemonBase() {
|
||||
var cfg = (window.ARCRUN_CONFIG || {});
|
||||
if (cfg.daemonBase) return String(cfg.daemonBase).replace(/\/?$/, '/');
|
||||
@@ -820,8 +915,11 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var isMobile = /iPhone|iPad|iPod|Android|Mobile/i.test(ua);
|
||||
var isWin = !isMobile && /Windows NT/i.test(ua);
|
||||
var isMac = !isMobile && /Macintosh|Mac OS X/i.test(ua) && !/Windows/i.test(ua);
|
||||
var mac = { os: 'mac', label: '下載 Mac 版', url: base + DAEMON_MAC };
|
||||
var win = { os: 'win', label: '下載 Windows 版', url: base + DAEMON_WIN };
|
||||
// 有取到真相源就用它的網址(帶版號、指向釘點 sha),否則退回固定別名。
|
||||
var dlm = (DAEMON_LATEST && DAEMON_LATEST.downloads && DAEMON_LATEST.downloads.mac) || (base + DAEMON_MAC);
|
||||
var dlw = (DAEMON_LATEST && DAEMON_LATEST.downloads && DAEMON_LATEST.downloads.win) || (base + DAEMON_WIN);
|
||||
var mac = { os: 'mac', label: '下載 Mac 版', url: dlm };
|
||||
var win = { os: 'win', label: '下載 Windows 版', url: dlw };
|
||||
if (isWin) return { pick: win, other: mac, sure: true };
|
||||
if (isMac) return { pick: mac, other: win, sure: true };
|
||||
return { pick: null, other: null, sure: false, mac: mac, win: win };
|
||||
@@ -829,34 +927,69 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// 封測期第一次開啟的擋關提示(未簽章)——Mac/Windows 攔法不同,話術也不同。
|
||||
function daemonHint(os) {
|
||||
if (os === 'win') return '(封測版未簽章,Windows 第一次會跳藍色視窗擋下來——點「更多資訊」→「仍要執行」就好)';
|
||||
return '(封測版未簽章,第一次請右鍵→打開)';
|
||||
// DMG 版的步驟與 zip 不同:先拖進「應用程式」再從那裡開,才不會在下載資料夾啟動。
|
||||
return '(開啟後把 Arcrun 拖進「應用程式」,再從啟動台開啟;封測版未簽章,第一次請右鍵→打開)';
|
||||
}
|
||||
// 07-27:設定頁的常駐入口(下載小幫手/換 AI 金鑰)——與一次性卡片同一組 API
|
||||
(function () {
|
||||
// 🔴 2026-08-06 改成「先用退路畫、取到真相源再重畫」:
|
||||
// fetch 是非同步的,若等它回來才畫,網路慢時使用者會看到一顆沒有網址的按鈕。
|
||||
// ⇒ 先用固定別名畫出可用的按鈕,取到 /api/latest 後再重畫成帶版號的網址+版本行。
|
||||
// renderDaemonDownload 必須**可重複呼叫**(第二次要清掉第一次插進去的兄弟節點),
|
||||
// 否則重畫會疊出兩份「不是這個系統?」。
|
||||
function renderDaemonDownload() {
|
||||
var dl = $('st-daemon-dl');
|
||||
if (dl) {
|
||||
var d = daemonPick();
|
||||
if (d.sure) {
|
||||
dl.setAttribute('href', d.pick.url);
|
||||
dl.textContent = d.pick.label;
|
||||
// 判對了也要留另一版的路(UA 會判錯)
|
||||
var alt = document.createElement('span');
|
||||
alt.className = 'muted';
|
||||
alt.style.cssText = 'font-size:12.5px;margin-left:8px';
|
||||
alt.innerHTML = '不是這個系統?<a href="' + d.other.url + '">' + d.other.label + '</a>';
|
||||
if (dl.parentNode) dl.parentNode.insertBefore(alt, dl.nextSibling);
|
||||
} else {
|
||||
// 判不出來=兩個都給,不預設 Mac
|
||||
dl.setAttribute('href', d.mac.url);
|
||||
dl.textContent = d.mac.label;
|
||||
var both = document.createElement('span');
|
||||
both.className = 'muted';
|
||||
both.style.cssText = 'font-size:12.5px;margin-left:8px';
|
||||
both.innerHTML = '或 <a href="' + d.win.url + '">' + d.win.label + '</a>';
|
||||
if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling);
|
||||
}
|
||||
if (!dl) return;
|
||||
// 清掉上一輪插入的節點(用 id 標記,才不會誤刪別人的東西)
|
||||
['st-daemon-alt', 'st-daemon-ver'].forEach(function (id) {
|
||||
var old = $(id); if (old && old.parentNode) old.parentNode.removeChild(old);
|
||||
});
|
||||
var d = daemonPick();
|
||||
var alt = document.createElement('span');
|
||||
alt.id = 'st-daemon-alt';
|
||||
alt.className = 'muted';
|
||||
alt.style.cssText = 'font-size:12.5px;margin-left:8px';
|
||||
if (d.sure) {
|
||||
dl.setAttribute('href', d.pick.url);
|
||||
dl.textContent = d.pick.label;
|
||||
// 判對了也要留另一版的路(UA 會判錯)
|
||||
alt.innerHTML = '不是這個系統?<a href="' + d.other.url + '">' + d.other.label + '</a>';
|
||||
} else {
|
||||
// 判不出來=兩個都給,不預設 Mac
|
||||
dl.setAttribute('href', d.mac.url);
|
||||
dl.textContent = d.mac.label;
|
||||
alt.innerHTML = '或 <a href="' + d.win.url + '">' + d.win.label + '</a>';
|
||||
}
|
||||
})();
|
||||
if (dl.parentNode) dl.parentNode.insertBefore(alt, dl.nextSibling);
|
||||
|
||||
// 版本行:leo 要的「用戶看得出自己是不是最新版」。
|
||||
// 取不到就**不顯示**,不要編一個數字(同 landing 的原則:寧可空著也不說謊)。
|
||||
if (DAEMON_LATEST && DAEMON_LATEST.version) {
|
||||
var ver = document.createElement('div');
|
||||
ver.id = 'st-daemon-ver';
|
||||
ver.className = 'muted';
|
||||
ver.style.cssText = 'font-size:12.5px;margin-top:6px';
|
||||
ver.innerHTML = '最新的同步器版本是 <strong>' + esc(DAEMON_LATEST.version) + '</strong>'
|
||||
+ '(你手上那支的版本,在同步器視窗左下角)'
|
||||
+ ' · <a href="https://rag.arcrun.dev/docs/help/changelog/" target="_blank" rel="noopener">這一版改了什麼</a>';
|
||||
if (alt.parentNode) alt.parentNode.insertBefore(ver, alt.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
function loadDaemonLatest() {
|
||||
// 不用 INSTALLER_ORIGIN:它宣告在本區塊之後(var 提升 ⇒ 這裡是 undefined)。
|
||||
fetch('https://install.arcrun.dev/api/latest')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) {
|
||||
if (j && j.daemon && j.daemon.version) {
|
||||
DAEMON_LATEST = j.daemon;
|
||||
renderDaemonDownload(); // 取到了才重畫
|
||||
}
|
||||
})
|
||||
.catch(function () { /* 取不到就維持退路的固定別名,按鈕仍可用 */ });
|
||||
}
|
||||
|
||||
renderDaemonDownload();
|
||||
loadDaemonLatest();
|
||||
|
||||
// t176(leo 08-03):AI 設定的前端邏輯整段移除。
|
||||
// 雲端聊天走 Workers AI(免金鑰);地端萃取金鑰改由同步小幫手托盤「AI 設定…」自己設。
|
||||
@@ -868,9 +1001,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
dropSession();
|
||||
});
|
||||
|
||||
// t87 07-28 leo:知識庫網址 helper(只有 origin,不含 /portal/# 後綴),兩處 UI 共用
|
||||
function copyOriginUrl(btn) {
|
||||
var url = location.origin;
|
||||
// 檢修孔前端已於 2026-08-09 移除(t213,leo 拍板「通知完畢可以刪除」)——
|
||||
// #st-diag-export 元素不再存在,匯出診斷檔改在 arcrun-app(同步小幫手)
|
||||
// 「版本與更新」頁本機端做。GET /portal/data/diagnostics 端點本身未刪(無害、
|
||||
// 已無任何 UI 呼叫),只是這裡不再掛按鈕去打它。
|
||||
|
||||
// t87 07-28 leo:複製任意網址到剪貼簿的共用 helper(08-09 從 copyOriginUrl 拆出
|
||||
// copyText,讓 MCP 網址這種「不是 location.origin」的字串也能共用同一套按鈕行為)。
|
||||
function copyText(btn, url) {
|
||||
var orig = btn.textContent;
|
||||
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||
alert('請手動選取並複製:' + url);
|
||||
@@ -883,6 +1021,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
alert('請手動選取並複製:' + url);
|
||||
});
|
||||
}
|
||||
function copyOriginUrl(btn) { copyText(btn, location.origin); }
|
||||
(function () {
|
||||
['st-origin-url', 'ad-origin-url'].forEach(function (id) {
|
||||
var el = $(id); if (el) el.textContent = location.origin;
|
||||
@@ -892,6 +1031,33 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
});
|
||||
})();
|
||||
|
||||
// 08-09(arcrun-rag#7):MCP 網址=apiBase 換一個 worker 名字(arcrun-cypher-executor →
|
||||
// arcrun-mcp),同一顆自架帳號的 workers.dev 子網域(deploy.ts:386-392 部署時就是這樣組的)。
|
||||
// 不是這個形狀(例如官方多租戶自訂網域)就誠實留空,不亂猜一個貼上去會連錯的網址。
|
||||
function mcpUrlFromApiBase() {
|
||||
try {
|
||||
var u = new URL(window.ARCRUN_API_BASE);
|
||||
if (u.hostname.indexOf('arcrun-cypher-executor.') === 0) {
|
||||
u.hostname = u.hostname.replace('arcrun-cypher-executor.', 'arcrun-mcp.');
|
||||
return u.origin + '/mcp';
|
||||
}
|
||||
} catch (e) { /* apiBase 空值或格式不符時不猜 */ }
|
||||
return '';
|
||||
}
|
||||
(function () {
|
||||
var mcpUrl = mcpUrlFromApiBase();
|
||||
var el = $('st-mcp-url');
|
||||
var btn = $('st-copy-mcp-url');
|
||||
if (el) el.textContent = mcpUrl || '(尚未偵測到,請確認安裝已完成)';
|
||||
if (btn) {
|
||||
if (mcpUrl) {
|
||||
btn.addEventListener('click', function () { copyText(this, mcpUrl); });
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
|
||||
function setupSteps() {
|
||||
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
|
||||
@@ -1032,6 +1198,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
|
||||
// 任何 data 請求收到 401 → session 失效 → 回登入殼
|
||||
//
|
||||
// 🔴 arcrun-rag#66 的前端那一半:後端在「認證資料正在鋪開」時改回 503
|
||||
// `auth_store_propagating`(不再回 401、也不再刪 KV 那筆 session)。
|
||||
// 這裡**必須跟著只認 401**——若前端把任何錯誤都當登出,後端不刪也沒用:
|
||||
// 使用者手上的 token 會被自己的瀏覽器丟掉,症狀跟被踢出去一模一樣。
|
||||
// (503 由各呼叫點自己顯示錯誤訊息,session 原封不動。)
|
||||
function guard401(status) {
|
||||
if (status === 401) { dropSession(); return true; }
|
||||
return false;
|
||||
@@ -1064,12 +1236,26 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + '</span>'; return; }
|
||||
var d = x.d;
|
||||
if (S.mode === 'semantic' && d.mode === 'keyword') {
|
||||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '系統尚未開啟語意索引——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
|
||||
// 🔴 2026-08-09 leo:「語義搜尋已經確定是一安裝就提供的功能⋯⋯我沒有不開通這個
|
||||
// 功能,是壞了,沒有人會把 bug 美化成沒提供沒開通。」
|
||||
// 走到這裡=這台實例的語意搜尋壞了(缺 binding 或向量化失敗),照實說是故障、
|
||||
// 是我們的問題,不要求使用者做任何事。文案優先用後端 capability_hint(已是人話,
|
||||
// 能分「暫時故障/部署故障」),舊版後端沒有就用底下的通用故障文案。
|
||||
var bhint = (d.capability_hint && !/開通|尚未啟用/.test(d.capability_hint))
|
||||
? d.capability_hint
|
||||
: '語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。';
|
||||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋目前故障</div><div class="b">' + esc(bhint) + '</div></div>';
|
||||
}
|
||||
var entries = d.entries || [];
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + (d.note ? '・' + d.note : '');
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + searchModeLabel(d.mode || 'keyword') + (d.note ? '・' + d.note : '');
|
||||
if (!entries.length) {
|
||||
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
|
||||
// 空結果不一律怪查詢字:語意模式的空結果,後端 capability_hint 會分
|
||||
// 「真的沒命中(換字)」與「索引故障/還沒有資料(不是用戶的問題)」,照實顯示。
|
||||
// 降級(mode=keyword)時故障說明已在上方橫幅,這裡不重複。
|
||||
var emptyMsg = (d.capability_hint && d.mode === 'semantic')
|
||||
? esc(d.capability_hint)
|
||||
: '找不到「' + esc(q) + '」——換個關鍵字試試。';
|
||||
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">' + emptyMsg + '</div>';
|
||||
return;
|
||||
}
|
||||
$('se-results').innerHTML = entries.map(function (e) {
|
||||
@@ -1078,7 +1264,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
'<div class="kt">' + esc(entryTitle(e)) + '</div>' +
|
||||
'<div class="ks">' + esc(entrySnippet(e)) + '</div>' +
|
||||
'<div class="km">' +
|
||||
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
|
||||
'<span class="tag">' + esc(entryTypeLabel(e.entry_type)) + '</span>' +
|
||||
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
|
||||
(src ? '<span class="dim" style="word-break:break-all">' + esc(src) + '</span>' : '') +
|
||||
'<span class="dim mono" style="margin-left:auto">' + esc(fmtDate(e.created_at)) + '</span></div></div>';
|
||||
@@ -1122,7 +1308,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var mode = (s && s.mode) || '';
|
||||
var href = s && s.source ? srcHref(s.source) : '';
|
||||
return '<div class="nbrow" data-aisrc="' + esc(page) + '">' +
|
||||
(mode ? '<span class="tag" style="flex:none">' + esc(mode) + '</span>' : '') +
|
||||
(mode ? '<span class="tag" style="flex:none">' + esc(searchModeLabel(mode)) + '</span>' : '') +
|
||||
'<span style="word-break:break-word">' + esc(page || '(無頁名)') + '</span>' +
|
||||
(href ? '<a href="' + esc(href) + '" target="_blank" rel="noopener" style="flex:none;color:var(--amber);font-size:13px" title="開啟來源檔">來源 ↗</a>' : '') +
|
||||
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none">›</span></div>';
|
||||
@@ -1222,7 +1408,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var src = entrySource(e);
|
||||
$('cd-main').innerHTML = '<div class="cardtitle">' + esc(entryTitle(e)) + '</div>' +
|
||||
'<div style="display:flex;gap:10px;align-items:center;margin-top:12px;font-size:13.5px;flex-wrap:wrap">' +
|
||||
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
|
||||
'<span class="tag">' + esc(entryTypeLabel(e.entry_type)) + '</span>' +
|
||||
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
|
||||
(e.page_name ? '<span class="tag dim">' + esc(e.page_name) + '</span>' : '') +
|
||||
'<span class="muted mono">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +
|
||||
@@ -1465,9 +1651,57 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
});
|
||||
}
|
||||
|
||||
// 執行紀錄保留期(P7):GET/PUT /portal/admin/execution-log-retention,role=admin 閘(server 端)。
|
||||
// retention_days: number=自訂天數;null=「不刪除」(企業稽核,leo 08-07:「我願意花很多錢
|
||||
// 保存,不要刪除」);未設定過的租戶也會回一個值(KBDB 端退回預設 90 天)。
|
||||
function loadRetention() {
|
||||
$('ad-ret-current').textContent = '載入中…';
|
||||
adminApi('GET', '/portal/admin/execution-log-retention')
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { $('ad-ret-current').textContent = ''; $('ad-ret-status').textContent = x.d.error || ('讀取失敗(HTTP ' + x.status + ')'); return; }
|
||||
var days = x.d.retention_days;
|
||||
var def = x.d.default_days || 90;
|
||||
$('ad-ret-never').checked = (days === null);
|
||||
$('ad-ret-days').value = (days === null) ? '' : (days != null ? days : def);
|
||||
$('ad-ret-days').disabled = (days === null);
|
||||
$('ad-ret-current').textContent = (days === null)
|
||||
? '目前設定:不刪除(企業稽核)'
|
||||
: '目前設定:保留 ' + (days != null ? days : def) + ' 天' + (days == null ? '(沿用預設值,尚未自訂)' : '');
|
||||
})
|
||||
.catch(function (e) { $('ad-ret-current').textContent = ''; $('ad-ret-status').textContent = friendlyErr(e); });
|
||||
}
|
||||
$('ad-ret-never').addEventListener('change', function () {
|
||||
$('ad-ret-days').disabled = this.checked;
|
||||
});
|
||||
$('ad-ret-save').addEventListener('click', function () {
|
||||
var st = $('ad-ret-status');
|
||||
st.textContent = '';
|
||||
var never = $('ad-ret-never').checked;
|
||||
var body;
|
||||
if (never) {
|
||||
body = { retention_days: null };
|
||||
} else {
|
||||
var n = parseInt($('ad-ret-days').value, 10);
|
||||
if (!n || n <= 0) { st.textContent = '請輸入大於 0 的天數,或勾「不刪除」'; return; }
|
||||
body = { retention_days: n };
|
||||
}
|
||||
$('ad-ret-save').disabled = true;
|
||||
adminApi('PUT', '/portal/admin/execution-log-retention', body)
|
||||
.then(function (x) {
|
||||
$('ad-ret-save').disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = x.d.error || ('儲存失敗(HTTP ' + x.status + ')'); return; }
|
||||
toast('保留期已更新');
|
||||
loadRetention();
|
||||
})
|
||||
.catch(function (e) { $('ad-ret-save').disabled = false; st.textContent = friendlyErr(e); });
|
||||
});
|
||||
|
||||
function loadAdmin() {
|
||||
$('ad-users').innerHTML = '<div class="muted">載入中…</div>';
|
||||
$('ad-libs').innerHTML = '<div class="muted">載入中…</div>';
|
||||
loadRetention();
|
||||
adminApi('GET', '/portal/admin/libraries')
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
|
||||
// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)
|
||||
//
|
||||
// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語
|
||||
// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。
|
||||
// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。
|
||||
const start = html.indexOf('var DAEMON_BASE_DEFAULT');
|
||||
const endMark = "return '(封測版未簽章,第一次請右鍵→打開)';\n }";
|
||||
const end = html.indexOf(endMark) + endMark.length;
|
||||
if (start < 0 || end < start) throw new Error('抽不到函式區塊');
|
||||
const hintAt = html.indexOf('function daemonHint', start);
|
||||
const endMark = '\n }';
|
||||
const end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;
|
||||
if (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');
|
||||
const src = html.slice(start, end);
|
||||
|
||||
const cases = [
|
||||
@@ -26,11 +31,13 @@ for (const [name, ua] of cases) {
|
||||
console.log(` url: ${url}`);
|
||||
if (name==='Windows') {
|
||||
chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);
|
||||
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('mac-unsigned.zip'));
|
||||
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));
|
||||
chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));
|
||||
}
|
||||
if (name==='Mac') {
|
||||
chk('Mac 給 mac zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac-unsigned.zip'));
|
||||
// 2026-08-05:Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip
|
||||
// ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。
|
||||
chk('Mac 給 dmg(不是 zip)', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));
|
||||
chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));
|
||||
chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));
|
||||
}
|
||||
|
||||
@@ -1,60 +1,108 @@
|
||||
/**
|
||||
* deploy.mjs — 依具名目標部署 console-ui 到 Cloudflare Pages
|
||||
*
|
||||
* 用法:npm run deploy:personal / npm run deploy:enterprise
|
||||
* 用法:npm run deploy:personal
|
||||
* npm run deploy:personal -- --dry-run (只產出並驗產物,不推)
|
||||
*
|
||||
* 為什麼不直接用 `wrangler pages deploy`(2026-07-22 leo 立,實際踩到才補):
|
||||
* **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案**
|
||||
* · leo21c → arcrun-console-ui.pages.dev(個人版 console)
|
||||
* · uncle6 → 綁 rag-demo.arcrun.dev(企業版 demo 站)
|
||||
* wrangler 若 OAuth 登入在 uncle6,`--project-name arcrun-console-ui` 會部到 demo 站上。
|
||||
* wrangler 若 OAuth 登入在別的帳號,`--project-name arcrun-console-ui` 會部到別人的站上。
|
||||
* 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。
|
||||
*
|
||||
* 同時把 profile/apiBase 綁進目標(deploy.targets.json),不再靠部署者記得帶環境變數——
|
||||
* 帶漏過三次:demo 站漏 profile=rag 顯示成個人版、兩站漏 apiBase 導致登入 405。
|
||||
* 帶漏過三次:漏 profile 顯示成錯的版本、漏 apiBase 導致登入 405。
|
||||
*
|
||||
* 🔴 三道閘,全部**讀磁碟上真的要被推的那份**,不看本腳本自己印了什麼
|
||||
* (2026-08-08 事故的形狀正是「印的是 A、推的是 B」):
|
||||
* ① 產物閘 :宣告值有沒有真的寫進產物(apiBase / VIEWS / HOME)
|
||||
* ② 世代閘 :產物是不是當代(指紋+t160 的文字指紋)
|
||||
* ③ 線上閘 :推完回頭抓線上,組態+世代都要對上,否則本次部署算失敗
|
||||
* 三閘都過才寫 .deploy-state.json(那份紀錄是「經過線上實測」的意思,不是「我跑過指令」)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import { ROOT, assertArtifact, buildArtifact, loadTargets, resolveTarget, writeState } from './targets.mjs';
|
||||
import { printReport, verifyTarget } from './verify-live.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
|
||||
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const name = args.find((a) => !a.startsWith('--'));
|
||||
|
||||
const name = process.argv[2];
|
||||
if (!name || !targets[name]) {
|
||||
console.error(`用法:npm run deploy:<target>\n可用目標:${names.join(' / ')}`);
|
||||
if (name) console.error(`(收到未知目標:"${name}")`);
|
||||
let t;
|
||||
try {
|
||||
if (!name) throw Object.assign(new Error('沒有指定部署目標'), { usage: true });
|
||||
t = resolveTarget(name);
|
||||
} catch (e) {
|
||||
console.error(`✘ ${e.message}`);
|
||||
if (e.usage) console.error(`用法:npm run deploy:<target>\n可用目標:${loadTargets().active.join(' / ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (t.frozen) {
|
||||
console.error(`✘ 目標 ${name} 已凍結,拒絕部署。\n ${t.frozen}`);
|
||||
console.error(' (要解凍是人的決定:改 deploy.targets.json 拿掉 frozen 欄位,並說明理由。)');
|
||||
process.exit(1);
|
||||
}
|
||||
const t = targets[name];
|
||||
|
||||
console.log(`\n部署目標:${name}`);
|
||||
console.log(` 說明 :${t.description}`);
|
||||
console.log(` 帳號 :${t.accountId}`);
|
||||
console.log(` 專案 :${t.projectName}`);
|
||||
console.log(` profile :${t.profile}`);
|
||||
console.log(` apiBase :${t.apiBase}\n`);
|
||||
console.log(` apiBase :${t.apiBase}`);
|
||||
|
||||
const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
|
||||
|
||||
// t160(leo 07-31:「如果你會搞不清楚,就把錯的東西刪掉」):build 步驟已隨舊世代
|
||||
// src/ 一起 git rm——public/ 是唯一世代真身(手改演進),deploy=直接託管它。
|
||||
// 病史:src/(舊代 renderer 快照)與 public/(新代真身)並存,deploy 自動跑 build
|
||||
// 從舊 src 重產 public ⇒ 任何一次部署都可能把 UI 打回舊世代(07-27 記帳、07-31 引爆:
|
||||
// t159 重打包用了舊 public 的分支副本,leo 刷新看到被淘汰的「登記新庫」表單)。
|
||||
// 世代閘:部署前驗 public 指紋,舊世代(缺新文案/含人工建庫表單)直接拒部。
|
||||
const portalHtml = readFileSync(join(ROOT, 'public', 'portal', 'index.html'), 'utf8');
|
||||
if (!portalHtml.includes('不需要人工新增') || portalHtml.includes('登記新庫')) {
|
||||
console.error('✘ 世代閘:public/portal/index.html 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕部署舊 UI。');
|
||||
// ── ①② 產出 + 驗產物 ────────────────────────────────────────────────
|
||||
const outDir = join(ROOT, '.staging', name);
|
||||
try {
|
||||
buildArtifact(t, outDir);
|
||||
} catch (e) {
|
||||
console.error(`\n✘ 產出失敗:${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const gate = assertArtifact(t, outDir);
|
||||
console.log(`\n產物:${outDir}`);
|
||||
console.log(` 世代指紋:${gate.generation.slice(0, 12)}`);
|
||||
if (!gate.ok) {
|
||||
console.error('\n✘ 產物閘不通過——推上去的會跟宣告的不一樣,拒絕部署:');
|
||||
for (const p of gate.problems) console.error(` · ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' ✅ 產物閘:宣告值確實寫進產物,且是當代。');
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n(--dry-run:到此為止,沒有推任何東西。)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── 推 ───────────────────────────────────────────────────────────────
|
||||
const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
|
||||
// --commit-dirty:本地部署常有未提交變更,不因此中斷
|
||||
const deploy = spawnSync(
|
||||
'npx',
|
||||
['wrangler', 'pages', 'deploy', 'public', '--project-name', t.projectName, '--commit-dirty=true'],
|
||||
['wrangler', 'pages', 'deploy', outDir, '--project-name', t.projectName, '--commit-dirty=true'],
|
||||
{ stdio: 'inherit', cwd: ROOT, env },
|
||||
);
|
||||
process.exit(deploy.status ?? 1);
|
||||
if (deploy.status !== 0) {
|
||||
console.error('\n✘ wrangler 部署失敗。');
|
||||
process.exit(deploy.status ?? 1);
|
||||
}
|
||||
|
||||
// ── ③ 線上閘 ─────────────────────────────────────────────────────────
|
||||
console.log('\n── 回頭驗線上(組態+世代)──');
|
||||
const report = await verifyTarget(name, { wait: true });
|
||||
printReport([report]);
|
||||
if (!report.ok) {
|
||||
console.error('\n✘ 推上去了,但線上跑的 ≠ 我們手上這一份。**本次部署視為失敗**。');
|
||||
console.error(' (wrangler 說成功不代表對外網址就對——這正是要被擋掉的那個病。)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeState(name, {
|
||||
generation: gate.generation,
|
||||
apiBase: t.apiBase,
|
||||
profile: t.profile,
|
||||
urls: t.verifyUrls,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
});
|
||||
console.log('\n✅ 部署完成,且線上實測=宣告值+當代世代。已記入 .deploy-state.json。');
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* targets.mjs — 部署目標的唯一讀取點(deploy.mjs 與 verify-live.mjs 共用)。
|
||||
*
|
||||
* 存在的理由:宣告值(deploy.targets.json)只准被解讀一次。
|
||||
* 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算,
|
||||
* 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。
|
||||
* 這支把「一個目標展開成期望的產物長相」定死成一個函式,三邊共用同一個答案。
|
||||
*
|
||||
* 🔴 2026-08-08 第二層(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
|
||||
* 你要確定不可再犯」):組態對 ≠ 世代對。
|
||||
* 一個網址可以 apiBase/profile 全部正確,卻對外展示一套早就被淘汰的介面,
|
||||
* 而所有只驗組態的檢查都說它綠。故本檔另外定義「世代指紋」(見下半段):
|
||||
* 把「線上這一份是不是當代的」變成一個可機械比對的值。
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const PUBLIC_DIR = join(ROOT, 'public');
|
||||
|
||||
export function loadTargets() {
|
||||
const raw = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
|
||||
const profiles = raw._profiles;
|
||||
if (!profiles) throw new Error('deploy.targets.json 缺 _profiles(profile → views/home 對照)');
|
||||
const names = Object.keys(raw).filter((k) => !k.startsWith('_'));
|
||||
const active = names.filter((n) => !raw[n].frozen);
|
||||
return { raw, profiles, names, active };
|
||||
}
|
||||
|
||||
export function resolveTarget(name) {
|
||||
const { raw, profiles, names } = loadTargets();
|
||||
const t = raw[name];
|
||||
if (!t) {
|
||||
const err = new Error(`未知的部署目標:"${name}"。可用:${names.join(' / ')}`);
|
||||
err.usage = true;
|
||||
throw err;
|
||||
}
|
||||
// 凍結目標:連讀都不准碰(frozen.reason 說明是誰、何時、為什麼)。
|
||||
// 這不是「壞掉所以跳過」,是「這個帳號的資源不歸我們動」——工具自己守,不靠人記得。
|
||||
if (t.frozen) return { name, ...t, frozen: t.frozen, views: profiles[t.profile]?.views, home: profiles[t.profile]?.home };
|
||||
const p = profiles[t.profile];
|
||||
if (!p) {
|
||||
throw new Error(
|
||||
`目標 ${name} 的 profile="${t.profile}" 在 _profiles 裡沒有定義(可用:${Object.keys(profiles).join(' / ')})。` +
|
||||
'\n宣告了一個沒人知道怎麼落地的 profile ⇒ 拒絕部署,不要猜。',
|
||||
);
|
||||
}
|
||||
if (!t.apiBase) throw new Error(`目標 ${name} 沒有 apiBase——空值會讓前端安靜地連不上,拒絕部署。`);
|
||||
if (!t.accountId) throw new Error(`目標 ${name} 沒有 accountId——不指定帳號可能部到別人的站上,拒絕部署。`);
|
||||
if (!Array.isArray(t.verifyUrls) || t.verifyUrls.length === 0) {
|
||||
throw new Error(`目標 ${name} 沒有 verifyUrls——沒有對外網址就無法驗「站上跑的=宣告的」,拒絕部署。`);
|
||||
}
|
||||
return { name, ...t, views: p.views, home: p.home };
|
||||
}
|
||||
|
||||
/** 這個目標「應該長成什麼樣」——產物閘與線上閘都比對這一份。 */
|
||||
export function expected(t) {
|
||||
return {
|
||||
configJs: configJsFor(t),
|
||||
apiBase: t.apiBase,
|
||||
viewsLine: ` var VIEWS = ${JSON.stringify(t.views)};`,
|
||||
homeLine: ` var HOME = ${JSON.stringify(t.home)};`,
|
||||
};
|
||||
}
|
||||
|
||||
export function configJsFor(t) {
|
||||
return (
|
||||
'// 由 console-ui/scripts/deploy.mjs 於部署時依 deploy.targets.json 產生——請勿手改,也不進 git。\n' +
|
||||
`// 目標:${t.name}(${t.description})\n` +
|
||||
`window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(t.apiBase)} };\n`
|
||||
);
|
||||
}
|
||||
|
||||
/** 從 config.js 的文字裡取出 apiBase(線上/產物共用同一個解析法)。 */
|
||||
export function parseApiBase(text) {
|
||||
const m = text.match(/apiBase\s*:\s*"([^"]*)"/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 世代指紋(2026-08-08 第二層)
|
||||
//
|
||||
// 問題:verify-live 原本只驗組態(apiBase / VIEWS / HOME)。實測當天三個對外網址
|
||||
// 這三項全綠,但線上跑的是 2026-07-22 那一代的 portal(82,911 bytes、
|
||||
// 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的
|
||||
// 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。
|
||||
//
|
||||
// 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶——
|
||||
// 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。
|
||||
//
|
||||
// 為什麼是位元組而不是「找幾個關鍵字」:
|
||||
// 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線,
|
||||
// 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事:
|
||||
// repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。
|
||||
//
|
||||
// 誠實的 trade-off(mindset §7,不假裝完美):
|
||||
// ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**,
|
||||
// 因為那時線上確實不是當代的。它會吵,但吵的是真的。
|
||||
// ② 若哪天 CF 邊緣開始改寫 HTML(Rocket Loader 之類),會出現假 ❌。
|
||||
// 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致),
|
||||
// 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查——
|
||||
// **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 納入世代指紋的資產:file=public/ 底下的路徑,urlPath=線上要抓的位址。 */
|
||||
export const GENERATION_ASSETS = [
|
||||
{ file: 'index.html', urlPath: '/' },
|
||||
{ file: 'portal/index.html', urlPath: '/portal/' },
|
||||
{ file: 'console/index.html', urlPath: '/console/' },
|
||||
{ file: 'favicon.svg', urlPath: '/favicon.svg' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 「本來就該隨部署目標不同」的行——比世代時遮掉,否則個人版與企業版永遠指紋不同。
|
||||
* 遮的只有這兩行;其餘全部按原樣比對。
|
||||
* config.js 整支不納入世代(它是純產物,由 apiBase 那一項單獨驗)。
|
||||
*/
|
||||
const TARGET_DEPENDENT_LINES = [
|
||||
{ file: 'console/index.html', re: /^[ \t]*var VIEWS = .*$/m, tag: '«VIEWS:由部署目標決定»' },
|
||||
{ file: 'console/index.html', re: /^[ \t]*var HOME = .*$/m, tag: '«HOME:由部署目標決定»' },
|
||||
];
|
||||
|
||||
/** 遮掉目標相依的行。抓不到就原樣回傳(線上是舊世代時本來就可能沒有那幾行 → 該判 ❌)。 */
|
||||
export function maskTargetValues(file, bytes) {
|
||||
const rules = TARGET_DEPENDENT_LINES.filter((r) => r.file === file);
|
||||
if (!rules.length) return bytes;
|
||||
let text = Buffer.from(bytes).toString('utf8');
|
||||
for (const r of rules) text = text.replace(r.re, r.tag);
|
||||
return Buffer.from(text, 'utf8');
|
||||
}
|
||||
|
||||
export function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 由「檔名 → 位元組(抓不到給 null)」算出世代指紋。
|
||||
* @param {Array<{file:string, bytes:Buffer|null}>} entries
|
||||
*/
|
||||
export function fingerprintOf(entries) {
|
||||
const assets = {};
|
||||
const lines = [];
|
||||
for (const { file, bytes } of entries) {
|
||||
if (bytes == null) {
|
||||
assets[file] = { sha: null, size: null, missing: true };
|
||||
lines.push(`${file}\tMISSING`);
|
||||
continue;
|
||||
}
|
||||
const masked = maskTargetValues(file, bytes);
|
||||
const sha = sha256(masked);
|
||||
assets[file] = { sha, size: Buffer.from(bytes).length, missing: false };
|
||||
lines.push(`${file}\t${sha}`);
|
||||
}
|
||||
return { assets, digest: sha256(Buffer.from(lines.join('\n'), 'utf8')) };
|
||||
}
|
||||
|
||||
/** repo(或某個產物目錄)現在這一代長什麼樣。這就是「當代」的定義。 */
|
||||
export function generationOfDir(dir = PUBLIC_DIR) {
|
||||
return fingerprintOf(
|
||||
GENERATION_ASSETS.map(({ file }) => {
|
||||
let bytes = null;
|
||||
try {
|
||||
bytes = readFileSync(join(dir, file));
|
||||
} catch {
|
||||
bytes = null;
|
||||
}
|
||||
return { file, bytes };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 產物:把宣告值真的寫進去(e730b3f 標的 WIP,本次收掉)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 依目標把 public/ 展開成「要推上去的那一份」。
|
||||
* 🔴 覆寫沒命中就中止——宣告了卻沒寫進產物,正是這串事故的根。
|
||||
*/
|
||||
export function buildArtifact(t, outDir) {
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
cpSync(PUBLIC_DIR, outDir, { recursive: true });
|
||||
|
||||
const exp = expected(t);
|
||||
|
||||
// ① config.js:產物,不是原始碼(public/ 裡不留)
|
||||
writeFileSync(join(outDir, 'config.js'), exp.configJs, 'utf8');
|
||||
|
||||
// ② console 的 VIEWS/HOME:public/ 裡那兩行只是本機 preview 的預設值
|
||||
const consolePath = join(outDir, 'console', 'index.html');
|
||||
let html = readFileSync(consolePath, 'utf8');
|
||||
for (const [re, line, what] of [
|
||||
[/^[ \t]*var VIEWS = .*$/m, exp.viewsLine, 'VIEWS'],
|
||||
[/^[ \t]*var HOME = .*$/m, exp.homeLine, 'HOME'],
|
||||
]) {
|
||||
if (!re.test(html)) {
|
||||
throw new Error(
|
||||
`產物覆寫沒命中:console/index.html 找不到 ${what} 那一行 ⇒ 中止部署。\n` +
|
||||
'(前端改版把那行換了寫法時會發生。宣告值寫不進去就不准推——這正是 2026-08-08 事故的形狀。)',
|
||||
);
|
||||
}
|
||||
html = html.replace(re, line);
|
||||
}
|
||||
writeFileSync(consolePath, html, 'utf8');
|
||||
|
||||
return outDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產物閘:推之前,回頭讀「真的要被推上去的那些檔案」,確認=宣告值。
|
||||
* 不看 deploy.mjs 自己印了什麼——只看磁碟上那份。
|
||||
*/
|
||||
export function assertArtifact(t, outDir) {
|
||||
const exp = expected(t);
|
||||
const problems = [];
|
||||
|
||||
const cfg = readFileSync(join(outDir, 'config.js'), 'utf8');
|
||||
const gotApiBase = parseApiBase(cfg);
|
||||
if (gotApiBase !== t.apiBase) problems.push(`config.js 的 apiBase:宣告 ${t.apiBase},產物 ${gotApiBase}`);
|
||||
|
||||
const html = readFileSync(join(outDir, 'console', 'index.html'), 'utf8');
|
||||
const gotViews = html.match(/^[ \t]*var VIEWS = .*$/m)?.[0];
|
||||
const gotHome = html.match(/^[ \t]*var HOME = .*$/m)?.[0];
|
||||
if (gotViews !== exp.viewsLine) problems.push(`console VIEWS:宣告 ${exp.viewsLine.trim()},產物 ${gotViews?.trim()}`);
|
||||
if (gotHome !== exp.homeLine) problems.push(`console HOME:宣告 ${exp.homeLine.trim()},產物 ${gotHome?.trim()}`);
|
||||
|
||||
// 世代閘(產物側):注入不得改動世代相關位元組
|
||||
const src = generationOfDir(PUBLIC_DIR);
|
||||
const art = generationOfDir(outDir);
|
||||
if (src.digest !== art.digest) {
|
||||
problems.push(`產物世代指紋 ${art.digest.slice(0, 12)} ≠ public/ 的 ${src.digest.slice(0, 12)}(注入改到了不該改的位元組)`);
|
||||
}
|
||||
|
||||
// 世代閘(內容側,沿用 t160 的文字指紋——擋「整份 public 被換成舊代」)
|
||||
//
|
||||
// 🔴 只看「使用者看得到的內容」,比對前先剝掉 HTML 註解。
|
||||
// 2026-08-08 實撞:原版直接對全文比對「登記新庫」,而 66f1b59(08-03)在 portal 裡
|
||||
// 加了一則**說明「已經把登記新庫拿掉了」的註解** ⇒ 這道閘從那天起每次都誤判,
|
||||
// `npm run deploy:personal` 連續五天推不出去、而錯誤訊息說的是「你的 UI 是舊代」。
|
||||
// ⇒ 手工維護的關鍵字清單會腐爛,這就是實例;世代的主判準因此改用位元組指紋,
|
||||
// 這道文字閘只留來擋「整份 public 被換成舊代」,且必須剝註解才不會自傷。
|
||||
const portalRaw = readFileSync(join(outDir, 'portal', 'index.html'), 'utf8');
|
||||
const portal = portalRaw.replace(/<!--[\s\S]*?-->/g, '');
|
||||
if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) {
|
||||
problems.push('portal/index.html 不是現行世代(可見內容缺「不需要人工新增」或仍有「登記新庫」)');
|
||||
}
|
||||
|
||||
return { ok: problems.length === 0, problems, generation: art.digest };
|
||||
}
|
||||
|
||||
/** 部署狀態記錄檔(只在「線上實測通過」之後才寫,見 deploy.mjs)。 */
|
||||
export const STATE_FILE = join(ROOT, '.deploy-state.json');
|
||||
|
||||
export function readState() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeState(name, record) {
|
||||
const state = readState();
|
||||
state[name] = record;
|
||||
writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* verify-live.mjs — 驗「線上網址現在真的在跑的那一份」=「我們手上這一份」。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/verify-live.mjs 驗全部服役中目標的全部對外網址
|
||||
* node scripts/verify-live.mjs personal 只驗某個目標
|
||||
* node scripts/verify-live.mjs --wait 容忍 CF Pages 生效延遲(重試)
|
||||
* node scripts/verify-live.mjs --url <網址> 只對某個網址驗世代(不需要是宣告目標)
|
||||
* npm run verify
|
||||
*
|
||||
* 兩層,缺一不可:
|
||||
* ① 組態層:apiBase/profile 的 views/home = deploy.targets.json 宣告值
|
||||
* ② 世代層:線上資產的位元組指紋 = repo public/ 的指紋
|
||||
*
|
||||
* 為什麼要第二層(2026-08-08,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
|
||||
* 你要確定不可再犯」):當天實測三個對外網址,第一層**三項全過**,
|
||||
* 而它們跑的是 07-22 那一代的 portal(82,911 bytes、金色 serif 舊品牌),
|
||||
* repo 是 343,969 bytes 的新品牌世代。
|
||||
* ⇒ **組態可以完全正確,同時展示一套早就被淘汰的介面,而機械檢查一片綠。**
|
||||
* 第二層就是為了讓這個狀態不可能無聲存在。
|
||||
*
|
||||
* 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.js/VIEWS/HOME
|
||||
* 與世代指紋都是**純文字資產比對**,抓原始碼比對是這幾項的正確驗法;
|
||||
* 「頁面真的能用」另外走瀏覽器實載。
|
||||
* 🔴 frozen 目標(見 deploy.targets.json)連抓都不抓——不是我們的帳號,不碰。
|
||||
*/
|
||||
import {
|
||||
GENERATION_ASSETS,
|
||||
fingerprintOf,
|
||||
generationOfDir,
|
||||
loadTargets,
|
||||
parseApiBase,
|
||||
readState,
|
||||
resolveTarget,
|
||||
} from './targets.mjs';
|
||||
|
||||
const NOCACHE = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' };
|
||||
|
||||
async function get(url) {
|
||||
const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}_nc=${Date.now()}`, {
|
||||
headers: NOCACHE,
|
||||
cache: 'no-store',
|
||||
redirect: 'follow',
|
||||
});
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return { status: res.status, bytes: buf, text: buf.toString('utf8') };
|
||||
}
|
||||
|
||||
/** 抓線上的世代資產,算指紋。抓不到的當 MISSING(照樣算,缺檔本來就是另一代)。 */
|
||||
async function liveGeneration(base) {
|
||||
const entries = [];
|
||||
const detail = {};
|
||||
for (const { file, urlPath } of GENERATION_ASSETS) {
|
||||
try {
|
||||
const r = await get(`${base.replace(/\/$/, '')}${urlPath}`);
|
||||
const ok = r.status === 200;
|
||||
entries.push({ file, bytes: ok ? r.bytes : null });
|
||||
detail[file] = { status: r.status, text: ok ? r.text : null };
|
||||
} catch (e) {
|
||||
entries.push({ file, bytes: null });
|
||||
detail[file] = { status: `連線失敗:${e.message}`, text: null };
|
||||
}
|
||||
}
|
||||
return { ...fingerprintOf(entries), detail };
|
||||
}
|
||||
|
||||
/** 驗一個網址。t 給 null=只驗世代(ad-hoc 模式)。 */
|
||||
export async function verifyUrl(t, url, want) {
|
||||
const checks = [];
|
||||
const base = url.replace(/\/$/, '');
|
||||
const live = await liveGeneration(base);
|
||||
|
||||
// ── 世代層 ──────────────────────────────────────────────
|
||||
const genOk = live.digest === want.digest;
|
||||
const diffs = Object.entries(want.assets)
|
||||
.filter(([f, a]) => live.assets[f]?.sha !== a.sha)
|
||||
.map(([f, a]) => {
|
||||
const l = live.assets[f] ?? {};
|
||||
const st = live.detail[f]?.status;
|
||||
return `${f}:repo ${a.size ?? '缺'} bytes / 線上 ${l.missing ? `抓不到(${st})` : `${l.size} bytes`}`;
|
||||
});
|
||||
checks.push({
|
||||
name: '世代',
|
||||
ok: genOk,
|
||||
want: `${want.digest.slice(0, 12)}(repo public/)`,
|
||||
got: genOk
|
||||
? `${live.digest.slice(0, 12)}`
|
||||
: `${live.digest.slice(0, 12)}\n 不同的資產:\n ${diffs.join('\n ')}`,
|
||||
});
|
||||
|
||||
if (!t) return { url, ok: genOk, checks };
|
||||
|
||||
// ── 組態層 ──────────────────────────────────────────────
|
||||
try {
|
||||
const cfg = await get(`${base}/config.js`);
|
||||
const got = cfg.status === 200 ? parseApiBase(cfg.text) : `HTTP ${cfg.status}`;
|
||||
checks.push({ name: 'apiBase', ok: got === t.apiBase, want: t.apiBase, got: got ?? '(config.js 裡找不到 apiBase)' });
|
||||
} catch (e) {
|
||||
checks.push({ name: 'apiBase', ok: false, want: t.apiBase, got: `連線失敗:${e.message}` });
|
||||
}
|
||||
|
||||
const con = live.detail['console/index.html'];
|
||||
const conText = con?.text;
|
||||
const views = conText?.match(/var VIEWS = (\[[^\]]*\]);/);
|
||||
const home = conText?.match(/var HOME = "([^"]*)";/);
|
||||
const gotViews = conText ? (views ? views[1] : '(找不到 VIEWS)') : `HTTP ${con?.status}`;
|
||||
const gotHome = conText ? (home ? home[1] : '(找不到 HOME)') : `HTTP ${con?.status}`;
|
||||
checks.push({
|
||||
name: `profile(${t.profile}).views`,
|
||||
ok: gotViews === JSON.stringify(t.views),
|
||||
want: JSON.stringify(t.views),
|
||||
got: gotViews,
|
||||
});
|
||||
checks.push({ name: `profile(${t.profile}).home`, ok: gotHome === t.home, want: t.home, got: gotHome });
|
||||
|
||||
return { url, ok: checks.every((c) => c.ok), checks };
|
||||
}
|
||||
|
||||
export async function verifyTarget(name, { wait = false } = {}) {
|
||||
const t = resolveTarget(name);
|
||||
if (t.frozen) return { name, target: t, skipped: true, ok: true, results: [] };
|
||||
const want = generationOfDir();
|
||||
const attempts = wait ? 8 : 1;
|
||||
let results = [];
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
results = [];
|
||||
for (const url of t.verifyUrls) results.push(await verifyUrl(t, url, want));
|
||||
if (results.every((r) => r.ok) || i === attempts) break;
|
||||
process.stdout.write(` … 尚未生效,5s 後重試(${i}/${attempts - 1})\n`);
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
return { name, target: t, ok: results.every((r) => r.ok), results };
|
||||
}
|
||||
|
||||
export function printReport(reports) {
|
||||
for (const r of reports) {
|
||||
console.log(`\n【${r.name}】${r.target.description}`);
|
||||
if (r.skipped) {
|
||||
console.log(` ⏸️ 已凍結,不抓不驗:${r.target.frozen}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` 宣告:profile=${r.target.profile} apiBase=${r.target.apiBase}`);
|
||||
for (const u of r.results) {
|
||||
console.log(` ${u.ok ? '✅' : '❌'} ${u.url}`);
|
||||
for (const c of u.checks) {
|
||||
if (c.ok) console.log(` ✓ ${c.name} = ${c.got}`);
|
||||
else console.log(` ✗ ${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyAll(names, opts) {
|
||||
const reports = [];
|
||||
for (const n of names) reports.push(await verifyTarget(n, opts));
|
||||
return reports;
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isCli) {
|
||||
const args = process.argv.slice(2);
|
||||
const wait = args.includes('--wait');
|
||||
const urlIdx = args.indexOf('--url');
|
||||
|
||||
if (args.includes('--offline-lag')) {
|
||||
// 不連網,只問一句:「我手上這一代,有沒有真的送出去過?」
|
||||
// 給 Stop hook 用(每回合都跑,所以不准連網、不准慢)。
|
||||
// 唯一的事實來源是 .deploy-state.json,而它**只在線上實測通過後**才被寫(見 deploy.mjs)
|
||||
// ⇒ 它說綠就是真的有人驗過線上,不是「我跑過部署指令」。
|
||||
const here = generationOfDir().digest;
|
||||
const state = readState();
|
||||
const stale = [];
|
||||
for (const n of loadTargets().active) {
|
||||
const s = state[n];
|
||||
if (!s) stale.push(`${n}:沒有任何一次通過線上實測的部署紀錄(線上是哪一代,現在沒人知道)`);
|
||||
else if (s.generation !== here) {
|
||||
stale.push(`${n}:最後一次驗過的是 ${s.generation.slice(0, 12)}(${s.verifiedAt.slice(0, 10)}),現在手上是 ${here.slice(0, 12)}`);
|
||||
}
|
||||
}
|
||||
if (stale.length) {
|
||||
console.log(stale.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (urlIdx !== -1) {
|
||||
// ad-hoc:只問「這個網址上跑的是不是當代的」——不需要它是宣告過的目標。
|
||||
const url = args[urlIdx + 1];
|
||||
if (!url) {
|
||||
console.error('用法:node scripts/verify-live.mjs --url <網址>');
|
||||
process.exit(2);
|
||||
}
|
||||
const want = generationOfDir();
|
||||
const r = await verifyUrl(null, url, want);
|
||||
console.log(`\n【世代檢查】${url}`);
|
||||
for (const c of r.checks) {
|
||||
if (c.ok) console.log(` ✅ ${c.name} = ${c.got}`);
|
||||
else console.log(` ❌ ${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`);
|
||||
}
|
||||
if (!r.ok) {
|
||||
console.error('\n❌ 這個網址上跑的不是當代的前端——它展示的是一套已經被淘汰的介面。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✅ 這個網址上跑的=我們手上這一份。');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const picked = args.filter((a) => !a.startsWith('--'));
|
||||
const names = picked.length ? picked : loadTargets().names;
|
||||
const reports = await verifyAll(names, { wait });
|
||||
printReport(reports);
|
||||
const bad = reports.filter((r) => !r.ok);
|
||||
if (bad.length) {
|
||||
console.error(`\n❌ ${bad.length} 個目標與宣告/當代不符:${bad.map((b) => b.name).join('、')}`);
|
||||
console.error(' (線上實際在跑的 ≠ 我們手上這一份——這正是要被擋掉的那個病)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✅ 所有服役中目標:線上組態=宣告值,線上世代=repo 當代。');
|
||||
}
|
||||
@@ -21,98 +21,73 @@ import type { Bindings } from '../types';
|
||||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
import { createArcrunHostFunctions } from '../lib/wasi-shim';
|
||||
import { getCredentialSecretRefs, touchLastUsed } from '../routes/credentials';
|
||||
|
||||
// ── credential-store 遷移 T6/T7(方案 A,D19)────────────────────────────────
|
||||
// ── credential-store 遷移 T6/T7(方案 A,D19)+ D38 圍牆修復(2026-08-07)───────────
|
||||
//
|
||||
// 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的
|
||||
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
|
||||
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref],T4)取明文
|
||||
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
|
||||
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||||
// 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref)→ 用 secret_get(ref)
|
||||
// (即 env[ref],T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
|
||||
// WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||||
//
|
||||
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
|
||||
// D38(leo 2026-06-14 立、2026-08-07 擴大):目錄不再直連 D1,改走 KBDB HTTP API
|
||||
// (`credentials.ts` 的 `getCredentialSecretRefs`,內建 60 秒租戶級快取——這是熱路徑,
|
||||
// 每次 workflow 執行都會呼叫,映射「幾乎不變」故快取後多數命中零網路呼叫,效能不因改走
|
||||
// API 而變差,見 credentials.ts 檔頭「效能」段的實測數字)。
|
||||
//
|
||||
// 嚴格邊界(rule 02 §2.2):本檔只做「查目錄拿 ref → secret_get 取值 → 當字串塞 payload」。
|
||||
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
|
||||
// 內,解密/注入邏輯仍全在 WASM 零件。
|
||||
|
||||
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
|
||||
interface CredentialRefRow {
|
||||
name: string;
|
||||
secret_ref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
|
||||
*
|
||||
* 流程:查 D1 `credentials`(api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
|
||||
* (host function,實作 = env[ref])取值。
|
||||
* 流程:查 KBDB credential 目錄(api_key + name,快取命中零網路呼叫)拿 `secret_ref`
|
||||
* → 用 `secret_get(ref)`(host function,實作 = env[ref])取值。
|
||||
*
|
||||
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||||
* ⚠️ 只把「目錄有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||||
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
|
||||
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
|
||||
*
|
||||
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
|
||||
* 取到值的 name 順手更新 last_used_at(§2.5 治理面 last_used,見 touchLastUsed——
|
||||
* fire-and-forget、非同步、不阻塞本函式回傳,失敗吞掉)。
|
||||
*
|
||||
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
|
||||
* KBDB 不可達 / 這個租戶還沒有任何 credential → 回空 map(整組走 fallback),
|
||||
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
|
||||
*/
|
||||
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
|
||||
type ResolvedSecretMap = Record<string, string>;
|
||||
|
||||
export async function resolveSecretsFromNewHome(
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
names: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const resolved: Record<string, string> = {};
|
||||
): Promise<ResolvedSecretMap> {
|
||||
const resolved: ResolvedSecretMap = {};
|
||||
if (names.length === 0) return resolved;
|
||||
|
||||
const db = env.CREDENTIALS_DB;
|
||||
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
|
||||
|
||||
// 1. 查 D1 拿每個 name 的 secret_ref
|
||||
let rows: CredentialRefRow[];
|
||||
try {
|
||||
const placeholders = names.map(() => '?').join(', ');
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT name, secret_ref FROM credentials
|
||||
WHERE api_key = ? AND name IN (${placeholders})`,
|
||||
)
|
||||
.bind(apiKey, ...names)
|
||||
.all<CredentialRefRow>();
|
||||
rows = result.results ?? [];
|
||||
} catch {
|
||||
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
|
||||
return resolved;
|
||||
}
|
||||
if (rows.length === 0) return resolved;
|
||||
// 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts)
|
||||
const refs = await getCredentialSecretRefs(env, apiKey);
|
||||
if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 fallback
|
||||
|
||||
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref])
|
||||
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
|
||||
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
|
||||
|
||||
const resolvedNames: string[] = [];
|
||||
for (const row of rows) {
|
||||
const value = await secretGet(row.secret_ref);
|
||||
for (const name of names) {
|
||||
const ref = refs[name];
|
||||
if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback
|
||||
const value = await secretGet(ref);
|
||||
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
|
||||
if (value === null) continue;
|
||||
resolved[row.name] = value;
|
||||
resolvedNames.push(row.name);
|
||||
resolved[name] = value;
|
||||
resolvedNames.push(name);
|
||||
}
|
||||
|
||||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
|
||||
if (resolvedNames.length > 0) {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const placeholders = resolvedNames.map(() => '?').join(', ');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE credentials SET last_used_at = ?
|
||||
WHERE api_key = ? AND name IN (${placeholders})`,
|
||||
)
|
||||
.bind(now, apiKey, ...resolvedNames)
|
||||
.run();
|
||||
} catch {
|
||||
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
|
||||
}
|
||||
}
|
||||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name;fire-and-forget,非關鍵路徑)
|
||||
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
/**
|
||||
* Execution Logger — 執行結果寫入 ANALYTICS_KV(fire-and-forget)
|
||||
* Execution Logger — 執行結果寫入 KBDB(fire-and-forget)
|
||||
*
|
||||
* 設計:每次 workflow 執行後,將統計數據寫入 ANALYTICS_KV(key = stats:{workflowId})。
|
||||
* Phase 7 可升級為 POST 至 registry.arcrun.dev/analytics/record。
|
||||
* KV 額度事故修復(總管交辦,2026-08-07):舊版寫 ANALYTICS_KV(Workers KV),
|
||||
* key = stats:{workflowId}:{timestamp}(註解寫「避免覆蓋」)⇒ 只增不減、永不覆蓋。
|
||||
* 封測者 Evan 處理約 690 個檔案,KV 免費層 write 上限 1,000/日被打爆(實測 1,070 write)。
|
||||
*
|
||||
* KBDB 鐵律(leo 2026-06-14):KBDB=API-as-Wall,零 SQL——任何存取一律走 KBDB 的 HTTP API,
|
||||
* 不准直接對它的 D1 下 SQL。本檔因此**不直連任何 D1**,改 fire-and-forget POST
|
||||
* `{KBDB_BASE_URL}/execution-log/record`(連法/認證頭完全比照既有 recordRecipeStats
|
||||
* 慣例,見 webhook-handlers.ts;儲存/降級實作在 kbdb/src/actions/execution-log.ts)。
|
||||
*
|
||||
* leo 兩條判準:
|
||||
* ① 執行紀錄是稽核資料 → 搬去 D1(entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。
|
||||
* ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/
|
||||
* duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(截斷長度不對稱,見 KBDB 端)。
|
||||
*
|
||||
* A2 自我降級(門檻與降級邏輯全在 KBDB 端,見 execution-log.ts):D1 額度仍與知識卡共用,
|
||||
* 超過門檻 KBDB 會回報 mode='skip'/'log_failure_only',但**這件事對呼叫端透明**——
|
||||
* 本函式不管 KBDB 決定寫或不寫,一律 fire-and-forget、永不 throw,workflow 執行不受影響。
|
||||
*/
|
||||
|
||||
import type { Bindings, GraphNode } from '../types';
|
||||
import { kbdbBase } from '../routes/kbdb-proxy';
|
||||
|
||||
export interface ExecutionVerdict {
|
||||
workflow_id: string;
|
||||
component_ids: string[];
|
||||
verdict: 'success' | 'failed';
|
||||
duration_ms: number;
|
||||
message: string;
|
||||
recorded_at: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入執行結果至 ANALYTICS_KV(fire-and-forget,不阻擋主流程)
|
||||
* 由 c.executionCtx.waitUntil() 包裹呼叫
|
||||
* 從觸發時的 trigger context 擷取這次處理的目標(page_name / path),供「哪些檔沒進去」
|
||||
* 這種問題答得出來。只認這兩個 key(少記,不做窮舉式欄位挖掘/猜測)。
|
||||
*/
|
||||
function extractTarget(input?: Record<string, unknown>): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const raw = input.page_name ?? input.path;
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
return typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入執行結果至 KBDB(fire-and-forget,不阻擋主流程)。
|
||||
* 由 c.executionCtx.waitUntil() 包裹呼叫。
|
||||
*
|
||||
* @param nodes 保留參數相容既有呼叫端簽名(原本用來算 component_ids);「不記每節點」
|
||||
* 是本次修復的明確要求(少記),此參數現不使用。
|
||||
* @param input 觸發時的 trigger context(可選)——只用來抓 page_name / path 當 target,
|
||||
* 不整包送出(少記:不留每節點輸入輸出,這裡也不例外)。
|
||||
* @param apiKey 觸發者的租戶(可選,/execute 舊路徑無租戶概念)。
|
||||
*/
|
||||
export async function writeExecutionVerdict(
|
||||
env: Bindings,
|
||||
@@ -27,27 +59,25 @@ export async function writeExecutionVerdict(
|
||||
verdict: 'success' | 'failed',
|
||||
durationMs: number,
|
||||
message: string,
|
||||
input?: Record<string, unknown>,
|
||||
apiKey?: string,
|
||||
): Promise<void> {
|
||||
void nodes; // 少記:不再從節點算 component_ids,保留參數只為呼叫端相容
|
||||
try {
|
||||
const componentIds = nodes
|
||||
.filter(n => n.type === 'Component' && n.componentId)
|
||||
.map(n => n.componentId!);
|
||||
|
||||
const record: ExecutionVerdict = {
|
||||
workflow_id: workflowId,
|
||||
component_ids: componentIds,
|
||||
verdict,
|
||||
duration_ms: durationMs,
|
||||
message,
|
||||
recorded_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// ANALYTICS_KV key = stats:{workflowId}:{timestamp}(避免覆蓋)
|
||||
const key = `stats:${workflowId}:${Date.now()}`;
|
||||
await env.ANALYTICS_KV.put(key, JSON.stringify(record), {
|
||||
expirationTtl: 60 * 60 * 24 * 90, // 保留 90 天
|
||||
const { base, headers } = kbdbBase(env);
|
||||
await fetch(`${base}/execution-log/record`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
workflow_id: workflowId,
|
||||
owner_id: apiKey ?? null,
|
||||
verdict,
|
||||
duration_ms: Math.max(0, Math.round(durationMs)),
|
||||
message: message ?? '',
|
||||
target: extractTarget(input) ?? null,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// fire-and-forget:不拋錯,不影響主流程
|
||||
// fire-and-forget:任何錯誤(含 KBDB 端額度打滿、網路失敗)都吞掉、不影響主流程
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +348,15 @@ export class GraphExecutor {
|
||||
|
||||
// BUILD-006:將節點 output 存入 KV(key = {run_id}:node:{node_id})
|
||||
// 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突
|
||||
if (kvStore && result !== null && result !== undefined) {
|
||||
//
|
||||
// P8 短板齊平(2026-08-09,任務層小改記 portal-auth/tasks.md):只在「下游真的會讀」
|
||||
// 時才寫。全 codebase 唯一的讀點是 PIPE 邊處理(本檔下方 kvGetNodeOutput 呼叫處)——
|
||||
// 沒有 PIPE 出邊的節點,這筆寫入沒有任何讀者,卻每個節點(含 FOREACH 每一圈)
|
||||
// 都燒一次 KV write。實測 rag_ingest_card 一張卡燒 15 次(4 固定節點+5 blocks
|
||||
// +6 triplets),把免費層 KV 1,000 write/日壓成約 66 檔/日的最短板——全是白燒。
|
||||
// 有 PIPE 出邊(含「完成後」與未知語意詞的預設)的節點行為完全不變。
|
||||
if (kvStore && result !== null && result !== undefined
|
||||
&& graph.edges.some((e) => e.from === node.id && (e.type as EdgeType) === 'PIPE')) {
|
||||
await kvSetNodeOutput(kvStore, node.id, result);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,28 @@ app.use('*', cors({
|
||||
extra = String((c.env as Record<string, unknown>).UI_ORIGINS || '')
|
||||
.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
} catch { /* UI_ORIGINS 未設定=只用靜態白名單 */ }
|
||||
return [...STATIC_ORIGINS, ...extra].includes(origin) ? origin : null;
|
||||
|
||||
// 🔴 2026-08-08 事故根因修復:**同一台實例的 portal 一律自動放行,不再依賴注入**。
|
||||
//
|
||||
// 那天發生什麼:leo 的 youlin 實例 portal 整個不能用——先是畫面頂端紅字
|
||||
// 「設定檔沒載入(config.js)」(UI worker 缺 WORKER_SUBDOMAIN),修好之後**登入仍然失敗**。
|
||||
// 瀏覽器 console 實證:
|
||||
// Access to fetch at '…/portal/login' … blocked by CORS policy:
|
||||
// No 'Access-Control-Allow-Origin' header is present
|
||||
// 真因=這台的 `UI_ORIGINS` 沒被設。
|
||||
//
|
||||
// 兩次同一個病:**這些變數只有安裝器那條路會注入,任何人手動 `wrangler deploy` 就會漏掉——
|
||||
// 而漏掉時系統看起來完全正常**(worker 上線、HTTP 200、版本號還是對的),
|
||||
// 只有真人點下去才會發現。leo:「這麼危險的問題已經發生 2 次,不可以再有一次。」
|
||||
//
|
||||
// ⇒ 治法不是「記得要注入」,是**讓它不需要被注入**:
|
||||
// portal 與本 worker 是同一個 workers.dev 子網域下的兄弟,位址推導得出來。
|
||||
// **少一個必須注入的變數,就少一個會被漏掉的東西。**
|
||||
// `UI_ORIGINS` 仍然有效(自訂網域/額外前端還是靠它),只是不再是「登得進去」的前提。
|
||||
const sub = String((c.env as Record<string, unknown>).WORKER_SUBDOMAIN || '').trim();
|
||||
const sibling = sub ? [`https://arcrun-rag-ui.${sub}.workers.dev`] : [];
|
||||
|
||||
return [...STATIC_ORIGINS, ...sibling, ...extra].includes(origin) ? origin : null;
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 認證儲存(D61:認證與資料分離)— 門鎖不住在知識資料庫裡
|
||||
*
|
||||
* leo 2026-08-10 下令(ADR D61 / Leo/arcrun-rag#55):
|
||||
* 「登入認證資料要分離⋯⋯**就算只有我一個人存在單獨的 json 檔也好**,
|
||||
* 它不能被改資料庫的連結導致無法登入。」
|
||||
*
|
||||
* 不變量(整份檔案只為這一句存在):
|
||||
* **登入所需要的一切,不得存放在任何「會被安裝/遷移重新指向」的地方。**
|
||||
*
|
||||
* 為什麼家選在 CF Workers per-script Secrets(判斷過程留著,方便日後推翻):
|
||||
* - D1 / KV / R2 / Vectorize 全靠 **binding** 指過去,安裝器每次都會重新指一次
|
||||
* ⇒ 換家=換鎖。所以「搬到另一顆資料庫」根本不解問題。
|
||||
* - Workers Secret **掛在 script 本身**,與 bindings 是兩套資源:
|
||||
* `wrangler deploy` 帶新 bindings 重部不會洗掉它(journeys/gemini-key-lost-on-reinstall.md
|
||||
* 在 stage 完整重裝 24/24 顆 worker 後 secret 仍在;installer worker.js:1148 亦有同款實證)。
|
||||
* - 它是**自足**的:讀出來就是完整的一份 JSON,裡面沒有任何「再去某顆 D1/KV 查一次」的指標。
|
||||
* 自足是重點——只要還要回頭查一次,就又被綁回去了。
|
||||
* - 不開新 D1(P9:leo 2026-08-07「你建一顆新的 D1,以後就會偷偷溜去那裡建表」)。
|
||||
* - 不牴觸 D38「KBDB 三張核心表永不加新的」:本檔是把東西**搬出去**,KBDB 表數不增不減。
|
||||
*
|
||||
* 容量(2026-08-10 查官方 developers.cloudflare.com/workers/platform/limits/,不是憑記憶):
|
||||
* - 每個變數(secret + text 合計)上限 **5 KB**
|
||||
* - 每顆 worker 變數數量上限 **64(Free)/ 128(Paid)**,與 CRED_* 共用同一份額度
|
||||
* ⇒ 故採「單一 store + 溢位分片」:`ARCRUN_AUTH_STORE`、`ARCRUN_AUTH_STORE_1`、`_2`…
|
||||
* 一份 ~4.5 KB 大約裝得下 12–15 個帳號;超過就自動長出下一片。
|
||||
* 這是刻意的取捨:**不**做「一個帳號一顆 secret」,因為那會用同一份 64 格的額度去跟
|
||||
* workflow credential 搶位子,且沒有任何實例接近這個量級。
|
||||
*
|
||||
* 寫入路徑:CF Workers Scripts secrets 管理 API(唯寫,讀不回值)。
|
||||
* 與 routes/credentials.ts 走**同一支** putWorkerSecret/deleteWorkerSecret,不另造第二套
|
||||
* (D36 教訓:AI 天生偏向新增一種做法而非沿用既有的,兩套並存必然漂移)。
|
||||
*
|
||||
* 讀取路徑:`env` 直接讀——**零網路呼叫**。這正是它比 KBDB 可靠的原因:
|
||||
* 登入不再依賴任何外部系統活著。
|
||||
*
|
||||
* ⚠️ 傳播延遲(誠實限制,mindset §7):更新 secret 會產生 worker 的新版本,
|
||||
* **既有 isolate 讀到的仍是舊 env**,要等新版本鋪開。故本檔帶一層 per-isolate 的
|
||||
* write-through overlay(AUTH_OVERLAY_TTL_MS),讓「剛改完密碼立刻登入」在同一顆 isolate 上
|
||||
* 立即生效;跨 isolate 仍可能有數十秒的落差,這是平台特性,不假裝沒有。
|
||||
*/
|
||||
import type { Bindings } from '../types';
|
||||
import { putWorkerSecret, deleteWorkerSecret } from '../routes/credentials';
|
||||
|
||||
/** 主分片名;溢位分片為 `${AUTH_STORE_PREFIX}_1`、`_2`… */
|
||||
export const AUTH_STORE_PREFIX = 'ARCRUN_AUTH_STORE';
|
||||
/** 單片安全上限(官方 5 KB,留 ~10% 給 JSON 結構與 UTF-8 膨脹)。 */
|
||||
const SHARD_MAX_BYTES = 4600;
|
||||
/** 剛寫完的資料在本 isolate 內優先採信多久(跨 isolate 傳播用)。 */
|
||||
const AUTH_OVERLAY_TTL_MS = 180_000;
|
||||
/**
|
||||
* 「剛寫完」加速器的 KV key 與存活時間。
|
||||
*
|
||||
* 🔴 為什麼需要它(2026-08-10 stage 演練**實測撞到**,不是預防性設計):
|
||||
* 更新 secret 會產生 worker 新版本,**既有 isolate 讀到的還是舊 env**。實測「建好帳號 →
|
||||
* 立刻登入」有 **15 秒以上**登不進去,而且那幾次失敗**會被算進 5 次鎖定**
|
||||
* ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。**這正是本案要根治的病的變種。**
|
||||
*
|
||||
* 🔑 它**不是**認證的家,只是「新版本還沒鋪開時的臨時快遞」:
|
||||
* - 讀取順序永遠是 **secret 優先**;secret 裡查不到/密碼對不上,才回頭問加速器一次
|
||||
* - KV 被重裝指到新的空的 → 加速器空 → 退回 secret ⇒ **D61 的不變量不受影響**
|
||||
* - 短 TTL:密碼雜湊不長期躺在 KV 裡(舊設計是永久躺著,這比舊的嚴格)
|
||||
*/
|
||||
const ACCEL_KEY = 'auth_store_recent';
|
||||
const ACCEL_TTL_SECONDS = 600;
|
||||
/** store 內 user id 前綴——呼叫端據此分辨「這筆住新家還是舊家(KBDB)」。 */
|
||||
export const AUTH_ID_PREFIX = 'auth:';
|
||||
|
||||
export interface AuthUserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
role: string;
|
||||
libraries: string[];
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** console 管理員那一組(原本住 SESSIONS_KV `console:credentials`,重裝就跟著蒸發)。 */
|
||||
export interface AuthConsoleRecord {
|
||||
email: string;
|
||||
salt: string;
|
||||
hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthStoreData {
|
||||
version: number;
|
||||
console: AuthConsoleRecord | null;
|
||||
users: AuthUserRecord[];
|
||||
}
|
||||
|
||||
interface ShardPayload {
|
||||
v: number;
|
||||
console?: AuthConsoleRecord | null;
|
||||
users?: AuthUserRecord[];
|
||||
}
|
||||
|
||||
/** 寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID,或 CF API 回錯)。 */
|
||||
export class AuthStoreWriteError extends Error {}
|
||||
|
||||
// ── per-isolate overlay(見檔頭「傳播延遲」)─────────────────────────────────────
|
||||
let overlay: AuthStoreData | null = null;
|
||||
let overlayAt = 0;
|
||||
|
||||
function emptyStore(): AuthStoreData {
|
||||
return { version: 1, console: null, users: [] };
|
||||
}
|
||||
|
||||
function shardNames(env: Bindings): string[] {
|
||||
const bag = env as unknown as Record<string, unknown>;
|
||||
return Object.keys(bag)
|
||||
.filter((k) => k === AUTH_STORE_PREFIX || /^ARCRUN_AUTH_STORE_\d+$/.test(k))
|
||||
.filter((k) => typeof bag[k] === 'string' && (bag[k] as string).length > 0)
|
||||
.sort((a, b) => shardIndex(a) - shardIndex(b));
|
||||
}
|
||||
|
||||
function shardIndex(name: string): number {
|
||||
if (name === AUTH_STORE_PREFIX) return 0;
|
||||
return Number.parseInt(name.slice(AUTH_STORE_PREFIX.length + 1), 10) || 0;
|
||||
}
|
||||
|
||||
function shardNameOf(index: number): string {
|
||||
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
|
||||
}
|
||||
|
||||
/** 這台實例的 env 裡有沒有認證儲存(不論裡面有沒有帳號)。 */
|
||||
export function authStorePresent(env: Bindings): boolean {
|
||||
return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
|
||||
}
|
||||
|
||||
/** 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 */
|
||||
export function authStoreWritable(env: Bindings): boolean {
|
||||
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀出完整認證資料。**同步、零網路呼叫**——這就是分離的意義:
|
||||
* 登入不依賴 KBDB / D1 / KV 任何一個活著。
|
||||
* 壞掉的分片(JSON parse 失敗)誠實跳過,不讓一片損毀鎖死整台實例。
|
||||
*/
|
||||
export function readAuthStore(env: Bindings): AuthStoreData {
|
||||
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
||||
return readAuthStoreFromEnv(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只讀 `env` 那一版(**跳過 overlay**)。
|
||||
*
|
||||
* 為什麼要分出這一支(#66 修補的一半):read-modify-write 時,overlay 與 env 兩份都可能
|
||||
* 各自「有對方沒有的帳號」——overlay 可能來自加速器(別台 isolate 剛寫的),
|
||||
* env 可能是**比加速器更新**的一版(加速器過期、或這顆 isolate 已經吃到新版本)。
|
||||
* 只採信其中一份就會把另一份獨有的帳號寫掉,而 secret 是唯一真相源 ⇒ **永久消失**。
|
||||
*/
|
||||
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
|
||||
const bag = env as unknown as Record<string, unknown>;
|
||||
const out = emptyStore();
|
||||
for (const name of shardNames(env)) {
|
||||
let parsed: ShardPayload | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(bag[name] as string) as ShardPayload;
|
||||
} catch {
|
||||
continue; // 損毀的分片跳過(其餘帳號仍登得進去)
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') continue;
|
||||
if (parsed.console && !out.console) out.console = parsed.console;
|
||||
if (Array.isArray(parsed.users)) {
|
||||
for (const u of parsed.users) {
|
||||
if (u && typeof u.email === 'string' && typeof u.id === 'string') out.users.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 找一筆帳號(email 比對,大小寫不敏感)。 */
|
||||
export function findAuthUserByEmail(env: Bindings, email: string): AuthUserRecord | null {
|
||||
const needle = email.trim().toLowerCase();
|
||||
return readAuthStore(env).users.find((u) => u.email.toLowerCase() === needle) ?? null;
|
||||
}
|
||||
|
||||
export function findAuthUserById(env: Bindings, id: string): AuthUserRecord | null {
|
||||
return readAuthStore(env).users.find((u) => u.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** 判斷一個 record_id 是不是住新家(呼叫端據此決定打 store 還是打 KBDB)。 */
|
||||
export function isAuthStoreId(recordId: string): boolean {
|
||||
return recordId.startsWith(AUTH_ID_PREFIX);
|
||||
}
|
||||
|
||||
export function newAuthUserId(): string {
|
||||
const arr = new Uint8Array(12);
|
||||
crypto.getRandomValues(arr);
|
||||
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把整份認證資料切片後寫回 Workers Secrets。
|
||||
* 分片規則:console 一定放第 0 片;users 依序塞,塞不下就開下一片。
|
||||
* 多出來的舊分片會被刪掉(避免「刪了帳號卻還留在舊分片裡復活」)。
|
||||
*/
|
||||
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
|
||||
if (!authStoreWritable(env)) {
|
||||
throw new AuthStoreWriteError(
|
||||
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
|
||||
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
|
||||
);
|
||||
}
|
||||
|
||||
const shards: string[] = [];
|
||||
let current: ShardPayload = { v: 1, console: data.console ?? null, users: [] };
|
||||
for (const u of data.users) {
|
||||
const trial: ShardPayload = { ...current, users: [...(current.users ?? []), u] };
|
||||
const size = new TextEncoder().encode(JSON.stringify(trial)).length;
|
||||
if (size > SHARD_MAX_BYTES && (current.users ?? []).length > 0) {
|
||||
shards.push(JSON.stringify(current));
|
||||
current = { v: 1, users: [u] };
|
||||
} else {
|
||||
current = trial;
|
||||
}
|
||||
}
|
||||
shards.push(JSON.stringify(current));
|
||||
|
||||
// 單筆帳號本身就超過一片=真的塞不下,誠實擋下(不靜默丟資料)
|
||||
for (const s of shards) {
|
||||
if (new TextEncoder().encode(s).length > 5000) {
|
||||
throw new AuthStoreWriteError('單筆認證資料超過 Cloudflare 變數 5 KB 上限,無法寫入。');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = shardNames(env);
|
||||
for (let i = 0; i < shards.length; i++) {
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i]);
|
||||
}
|
||||
for (const name of existing) {
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
|
||||
}
|
||||
|
||||
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
|
||||
overlayAt = Date.now();
|
||||
|
||||
// 加速器(非真相源,見 ACCEL_KEY 註解):讓別的 isolate 在新版本鋪開前也讀得到剛寫的東西。
|
||||
// 寫失敗完全不影響正確性——最多就是回到「等 secret 傳播」的狀態,故吞掉例外。
|
||||
try {
|
||||
await env.SESSIONS_KV.put(
|
||||
ACCEL_KEY,
|
||||
JSON.stringify({ written_at: Date.now(), data: overlay }),
|
||||
{ expirationTtl: ACCEL_TTL_SECONDS },
|
||||
);
|
||||
} catch {
|
||||
/* 加速器是加分項,不是必要條件 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「secret 裡查不到/密碼對不上」時再問一次加速器(見 ACCEL_KEY)。
|
||||
* 命中就把它放進本 isolate 的 overlay,呼叫端重跑一次同樣的查找即可。
|
||||
* 回傳是否真的拿到比較新的資料(沒有就不必重跑)。
|
||||
*/
|
||||
export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = await env.SESSIONS_KV.get(ACCEL_KEY);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { written_at?: number; data?: AuthStoreData };
|
||||
if (!parsed?.data || !Array.isArray(parsed.data.users)) return false;
|
||||
if (overlay && overlayAt >= (parsed.written_at ?? 0)) return false; // 本地的更新
|
||||
overlay = { version: 1, console: parsed.data.console ?? null, users: parsed.data.users };
|
||||
overlayAt = parsed.written_at ?? Date.now();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
|
||||
*
|
||||
* 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事:
|
||||
* - 「查不到這個帳號」= 帳號真的被刪了 → 該擋(401)
|
||||
* - 「查不到這個帳號」= secret 新版本還沒鋪到這顆 isolate → **不該擋,更不該刪 session**
|
||||
* 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`,它存在就代表「最近有人動過認證儲存」。
|
||||
* 讀不到(KV 掛了/沒設)⇒ 回 false,退回舊行為,不會比現在更糟。
|
||||
*/
|
||||
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
|
||||
try {
|
||||
return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 兩份 store 取聯集:同一個 id 以 `updated_at` 新者為準;只在一邊出現的一律保留。 */
|
||||
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
|
||||
const byId = new Map<string, AuthUserRecord>();
|
||||
for (const u of [...a.users, ...b.users]) {
|
||||
const prev = byId.get(u.id);
|
||||
if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
|
||||
}
|
||||
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。
|
||||
*
|
||||
* 🔴 #66:**改之前先把手上這份補齊**。舊版直接 `readAuthStore(env)` 當底稿,而 `writeAuthStore`
|
||||
* 會把整份重切分片並刪掉多出來的舊分片 ⇒ 若底稿是「某個帳號被建立之前」的版本,
|
||||
* 那個帳號會在這次寫入中**被抹掉,且再也回不來**(secret 是唯一真相源,沒有第二份可還原)。
|
||||
* 這正是「改一次密碼=有人被鎖在門外」的另一半病因。
|
||||
*
|
||||
* 補法:先問一次加速器,再把 env 版與 overlay 版**取聯集**當底稿——
|
||||
* 兩邊獨有的帳號都留下來;刪除仍然有效,因為 `fn()` 是在聯集**之後**才跑。
|
||||
*/
|
||||
export async function mutateAuthStore(
|
||||
env: Bindings,
|
||||
fn: (data: AuthStoreData) => void | Promise<void>,
|
||||
): Promise<AuthStoreData> {
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 診斷用(/health、/console/auth-status、daemon diagnostics 共用同一份判讀)。 */
|
||||
export function authStoreStatus(env: Bindings): {
|
||||
present: boolean;
|
||||
writable: boolean;
|
||||
users: number;
|
||||
console_configured: boolean;
|
||||
shards: number;
|
||||
} {
|
||||
const data = readAuthStore(env);
|
||||
return {
|
||||
present: authStorePresent(env),
|
||||
writable: authStoreWritable(env),
|
||||
users: data.users.length,
|
||||
console_configured: Boolean(data.console),
|
||||
shards: shardNames(env).length,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,19 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV(`console:credentials`,
|
||||
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
|
||||
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
|
||||
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
|
||||
// 讀到就順手搬過去(見 loadCredentials)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreStatus,
|
||||
hydrateFromAccelerator,
|
||||
mutateAuthStore,
|
||||
readAuthStore,
|
||||
type AuthConsoleRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -70,16 +83,72 @@ function tenantOf(c: { env: Bindings }): string {
|
||||
return c.env.CONSOLE_TENANT || 'leo';
|
||||
}
|
||||
|
||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(KV),
|
||||
* 且一旦從舊家讀到就順手搬過去(best-effort,搬不動不影響本次登入)。
|
||||
*/
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && (await hydrateFromAccelerator(env))) {
|
||||
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
|
||||
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: 'secrets' };
|
||||
|
||||
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return { creds: null, source: 'none' };
|
||||
let legacy: StoredCredentials | null = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw) as StoredCredentials;
|
||||
} catch {
|
||||
return { creds: null, source: 'none' };
|
||||
}
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy as AuthConsoleRecord;
|
||||
});
|
||||
} catch {
|
||||
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store) */
|
||||
}
|
||||
return { creds: legacy, source: 'legacy-kv' };
|
||||
}
|
||||
|
||||
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
});
|
||||
}
|
||||
|
||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
return c.json({ configured: !!existing });
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
});
|
||||
|
||||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||||
consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (existing) return c.json({ error: '已設定過帳密,請改用登入;要換帳密請用 /console/setup/reset(需舊密碼)' }, 409);
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (existing) {
|
||||
// D61 明顯失敗:舊版只說「已設定過」,**沒說剛才填的那組密碼被整個丟掉了**——
|
||||
// 用戶(含安裝精靈裡的 leo)以為自己剛設好了新密碼,其實從頭到尾沒有被採用過。
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'這台實例已經有管理員帳密了,**你剛才輸入的密碼沒有被採用**,目前的密碼仍是當初設定的那一組。' +
|
||||
'要用舊密碼登入,或用 /console/setup/reset(需要舊密碼)換一組。',
|
||||
code: 'already_configured',
|
||||
password_applied: false,
|
||||
reset_path: '/console/setup/reset',
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim();
|
||||
@@ -90,7 +159,13 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
@@ -101,9 +176,8 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
|
||||
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
|
||||
consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const currentPassword = body?.current_password ?? '';
|
||||
@@ -118,23 +192,48 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。
|
||||
consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定帳密,請先完成首次設定' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) {
|
||||
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
|
||||
return c.json(
|
||||
{
|
||||
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim().toLowerCase();
|
||||
const password = body?.password ?? '';
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,24 +7,41 @@
|
||||
* 寫入(POST 建立 / PUT 覆寫):
|
||||
* 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫,
|
||||
* arcrun 自己也讀不回值——D19「不持有內容物」)。
|
||||
* 2. D1 `credentials` 表只寫「目錄」(api_key/name/service/sensitivity/secret_ref/
|
||||
* created_at),**不含密文**。
|
||||
* 2. 目錄(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,
|
||||
* **不含密文**)走 KBDB HTTP API 寫,不再直連任何 D1。
|
||||
* 不再寫 KV / 不再寫明文密文到 D1。
|
||||
*
|
||||
* 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體
|
||||
* 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03
|
||||
* 定案並已落地的做法,取代更早的 `{name, encrypted, iv}` 格式;rule 01 已同步。)
|
||||
*
|
||||
* D38 圍牆修復(總管交辦,2026-08-07;leo「任何東西禁止用 SQL 語句存取資料,一律 API」):
|
||||
* 目錄舊家是 KBDB 裡多開的一張獨立 credentials 表(0002_credentials.sql,違規),現改走
|
||||
* KBDB 三張核心表——entries 表一列(entry_type='credential',page_name=name 當冪等鍵,
|
||||
* owner_id=api_key 隔離租戶,其餘欄位打包進 metadata_json),template 定義見
|
||||
* kbdb/migrations/0005_credential_template.sql,舊表資料遷移+拆表見 0006。連法比照既有
|
||||
* execution-logger.ts / portal.ts 慣例:kbdbBase(env) 組 base+headers,直接 fetch KBDB
|
||||
* HTTP API,不經自己的 /kbdb/* proxy route(那支是給 CLI 用的,server 端直連 base 更省一跳)。
|
||||
*
|
||||
* 效能(D38 評估要求「帶數字」,見 system-dev/wiki/decisions-summary.md D38 段):
|
||||
* 熱路徑(auth-dispatcher.ts resolveSecretsFromNewHome,每次 workflow 執行都會查一次)原本
|
||||
* 直連 D1、零快取;改走 HTTP 後若一樣「每次查一次」延遲只會變差(多一趟公網往返)。這份
|
||||
* name→secret_ref 映射「幾乎不變」(D38 評估原話),故本檔加一個租戶級記憶體快取
|
||||
* (dirCache,per-isolate,TTL 60 秒),寫入(POST/PUT/DELETE)時主動失效,讓熱路徑多數
|
||||
* 命中零網路呼叫。見下方 getCredentialDirectory / invalidateCredentialCache。
|
||||
*
|
||||
* 治理端點:
|
||||
* - `GET /credentials`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留
|
||||
* `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||
* - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row;
|
||||
* 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。
|
||||
* - `GET /credentials`:改讀 KBDB entries(與 `/credentials/catalog` 共用同一份查詢,同時
|
||||
* 保留 `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||
* - `DELETE /credentials/:name`:先查 KBDB 拿 secret_ref → 有則刪 Workers Secret + entries
|
||||
* row;沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的
|
||||
* 孤兒資料。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { sha256Prefix } from '../lib/hash';
|
||||
import { kbdbBase } from './kbdb-proxy';
|
||||
|
||||
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -61,7 +78,7 @@ export async function storeCredential(
|
||||
): Promise<void> {
|
||||
const secretRef = await deriveSecretRef(apiKey, name);
|
||||
await putWorkerSecret(env, secretRef, value);
|
||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service, 'standard', secretRef);
|
||||
await upsertCredentialEntry(env, apiKey, name, service, 'standard', secretRef);
|
||||
}
|
||||
|
||||
function validateName(name: unknown): name is string {
|
||||
@@ -76,7 +93,7 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
|
||||
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
|
||||
*/
|
||||
async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error(
|
||||
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
|
||||
@@ -105,7 +122,7 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
||||
*/
|
||||
async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
||||
}
|
||||
@@ -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(不含密文)。
|
||||
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新
|
||||
* service/sensitivity/secret_ref(secret_ref 是純函式衍生自 api_key+name,理論上覆寫時
|
||||
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
|
||||
* 給熱路徑(auth-dispatcher.ts)用:回這個租戶所有 credential 的 name→secret_ref 對照。
|
||||
* 快取命中=零網路呼叫;未命中打一次 KBDB list(見 getCredentialDirectory)。
|
||||
*/
|
||||
async function upsertCredentialRow(
|
||||
db: D1Database,
|
||||
export async function getCredentialSecretRefs(env: Bindings, apiKey: string): Promise<CredentialRefMap> {
|
||||
const rows = await getCredentialDirectory(env, apiKey);
|
||||
const out: CredentialRefMap = {};
|
||||
for (const r of rows) {
|
||||
if (r.secret_ref) out[r.name] = r.secret_ref;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 治理面 last_used_at 更新(非關鍵路徑,best-effort,不阻塞呼叫端)。
|
||||
* 直接用快取裡已知的 id/其餘欄位組 PATCH,不額外多打一次查詢。找不到快取(代表這個租戶
|
||||
* 本次請求根本沒查到目錄,不太可能發生——resolveSecretsFromNewHome 只在有 secret_ref 命中時
|
||||
* 才會呼叫本函式)就跳過,不為了治理欄位額外多打一輪 KBDB。
|
||||
* 呼叫端刻意不 await 本函式的內部 fetch(fire-and-forget,見 auth-dispatcher.ts),失敗吞掉。
|
||||
*/
|
||||
export function touchLastUsed(env: Bindings, apiKey: string, names: string[]): void {
|
||||
const cached = dirCache[apiKey];
|
||||
if (!cached || names.length === 0) return;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const r of cached.rows) {
|
||||
if (!names.includes(r.name)) continue;
|
||||
const meta: CredentialMeta = {
|
||||
service: r.service, sensitivity: r.sensitivity, secret_ref: r.secret_ref, last_used_at: now,
|
||||
};
|
||||
kbdbCredFetch(env, `/entries/${encodeURIComponent(r.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
|
||||
}).catch(() => { /* 治理面欄位,非關鍵路徑,失敗不影響任何主流程 */ });
|
||||
r.last_used_at = now; // 快取內同步更新,避免同一 TTL 視窗內下一次讀到舊值
|
||||
}
|
||||
}
|
||||
|
||||
/** 找某租戶某 credential 的 entry(page_name=name 精確比對,entry_type=credential 隔離)。 */
|
||||
async function findCredentialEntry(env: Bindings, apiKey: string, name: string): Promise<KbdbEntryRow | null> {
|
||||
const qs = new URLSearchParams({
|
||||
owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, page_name: name, limit: '1',
|
||||
});
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) throw new Error(`KBDB /entries 查詢失敗:HTTP ${res.status}`);
|
||||
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||
return body?.entries?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* upsert credential 目錄列(不含密文)。
|
||||
* created_at 只在首次建立時寫入(entries 表自帶 created_at,PATCH 不會動它);
|
||||
* last_used_at 覆寫時保留原值——secret_ref 是純函式衍生自 api_key+name,理論上覆寫時值不會
|
||||
* 變,這裡仍走同一條寫入路徑以求同時支援「首次建立」與「覆寫」兩種呼叫路徑(比照舊 D1 版本)。
|
||||
*/
|
||||
async function upsertCredentialEntry(
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
name: string,
|
||||
service: string | null,
|
||||
sensitivity: 'standard' | 'high',
|
||||
secretRef: string,
|
||||
): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(api_key, name) DO UPDATE SET
|
||||
service = excluded.service,
|
||||
sensitivity = excluded.sensitivity,
|
||||
secret_ref = excluded.secret_ref`,
|
||||
)
|
||||
.bind(apiKey, name, service, sensitivity, secretRef, now)
|
||||
.run();
|
||||
const existing = await findCredentialEntry(env, apiKey, name);
|
||||
const meta: CredentialMeta = {
|
||||
service, sensitivity, secret_ref: secretRef,
|
||||
last_used_at: existing ? parseMeta(existing).last_used_at : null,
|
||||
};
|
||||
if (existing) {
|
||||
const res = await kbdbCredFetch(env, `/entries/${encodeURIComponent(existing.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`credential 目錄更新失敗:HTTP ${res.status}`);
|
||||
} else {
|
||||
const res = await kbdbCredFetch(env, `/entries`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entry_type: CREDENTIAL_ENTRY_TYPE, owner_id: apiKey, page_name: name,
|
||||
metadata_json: JSON.stringify(meta),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`credential 目錄建立失敗:HTTP ${res.status}`);
|
||||
}
|
||||
invalidateCredentialCache(apiKey);
|
||||
}
|
||||
|
||||
interface CredentialRow {
|
||||
@@ -160,25 +342,26 @@ interface CredentialRow {
|
||||
last_used_at: number | null;
|
||||
}
|
||||
|
||||
/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||
async function listCredentialRows(db: D1Database, apiKey: string): Promise<CredentialRow[]> {
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT name, service, sensitivity, created_at, last_used_at
|
||||
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
|
||||
)
|
||||
.bind(apiKey)
|
||||
.all<CredentialRow>();
|
||||
return rows.results ?? [];
|
||||
/** KBDB 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||
async function listCredentialRows(env: Bindings, apiKey: string): Promise<CredentialRow[]> {
|
||||
const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
|
||||
const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
|
||||
if (!res.ok) throw new Error(`credential 目錄查詢失敗:HTTP ${res.status}`);
|
||||
const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
|
||||
const rows = (body?.entries ?? [])
|
||||
.filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
|
||||
.map((e) => {
|
||||
const meta = parseMeta(e);
|
||||
return { name: e.page_name, service: meta.service, sensitivity: meta.sensitivity, created_at: e.created_at, last_used_at: meta.last_used_at };
|
||||
});
|
||||
// entries API 已用 created_at DESC 排序,這裡不重排(保持與舊版 D1 query 相同排序語意)
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */
|
||||
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`)
|
||||
.bind(apiKey, name)
|
||||
.first<{ secret_ref: string }>();
|
||||
return row?.secret_ref ?? null;
|
||||
/** 給 `GET /portal/admin/ai` 之類「只要知道有沒有存過、不要值」的呼叫端用。 */
|
||||
export async function hasCredential(env: Bindings, apiKey: string, name: string): Promise<boolean> {
|
||||
const entry = await findCredentialEntry(env, apiKey, name);
|
||||
return entry !== null;
|
||||
}
|
||||
|
||||
interface CredentialWriteBody {
|
||||
@@ -203,13 +386,13 @@ async function writeCredential(
|
||||
// 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回)
|
||||
await putWorkerSecret(env, secretRef, value);
|
||||
|
||||
// 2. D1 目錄(不含密文)
|
||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
// 2. KBDB 目錄(不含密文)
|
||||
await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
|
||||
return { secretRef, sensitivity };
|
||||
}
|
||||
|
||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄)
|
||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + KBDB entries 目錄)
|
||||
credentialsRouter.post('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
@@ -272,17 +455,17 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
|
||||
const name = c.req.param('name');
|
||||
try {
|
||||
const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
|
||||
if (secretRef) {
|
||||
await deleteWorkerSecret(c.env, secretRef);
|
||||
await c.env.CREDENTIALS_DB
|
||||
.prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`)
|
||||
.bind(apiKey, name)
|
||||
.run();
|
||||
const entry = await findCredentialEntry(c.env, apiKey, name);
|
||||
if (entry) {
|
||||
const meta = parseMeta(entry);
|
||||
if (meta.secret_ref) await deleteWorkerSecret(c.env, meta.secret_ref);
|
||||
const res = await kbdbCredFetch(c.env, `/entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`credential 目錄刪除失敗:HTTP ${res.status}`);
|
||||
invalidateCredentialCache(apiKey);
|
||||
return c.json({ success: true, name, source: 'workers-secrets' });
|
||||
}
|
||||
// D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||
// 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||
// KBDB 沒有這筆 entry:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||
// 避免「GET 改讀新家看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||
await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`);
|
||||
return c.json({ success: true, name, source: 'legacy-kv' });
|
||||
} catch (e) {
|
||||
@@ -290,8 +473,8 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||
// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑;
|
||||
// GET /credentials/catalog — 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||
// 與 GET /credentials(下方,改讀同一份 KBDB 查詢)是同一份資料的兩個路徑;
|
||||
// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。
|
||||
credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
@@ -299,22 +482,22 @@ credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
const rows = await listCredentialRows(c.env, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
|
||||
// 誠實回報:KBDB 不可達 / 回錯(不假綠回空陣列裝沒事)
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials — 列出 credential 目錄(T9:改讀 D1,只回 metadata,絕不含值/secret_ref)
|
||||
// GET /credentials — 列出 credential 目錄(改讀 KBDB,只回 metadata,絕不含值/secret_ref)
|
||||
credentialsRouter.get('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
const rows = await listCredentialRows(c.env, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
|
||||
@@ -27,14 +27,14 @@ executeRouter.post('/execute', async (c) => {
|
||||
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
|
||||
const duration_ms = Date.now() - start;
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成', context, apiKey)
|
||||
);
|
||||
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100), context, apiKey)
|
||||
);
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { listPausedRunsByApiKey } from '../lib/paused-runs';
|
||||
import { kbdbBase } from './kbdb-proxy';
|
||||
|
||||
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -132,11 +133,13 @@ executionsRouter.get('/executions/:task_id', async (c) => {
|
||||
/**
|
||||
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
|
||||
*
|
||||
* 走 ANALYTICS_KV `stats:{workflowId}:*` prefix scan。
|
||||
* KV 額度事故修復(2026-08-07):改打 KBDB `GET /execution-log`(原走 ANALYTICS_KV
|
||||
* `stats:{workflowId}:*` prefix scan,免費層 list 也是 1,000/日,裝十幾支 workflow
|
||||
* 的實例刷 90 次 portal 就見底)。KBDB=API-as-Wall(leo 2026-06-14):本路由**不直連
|
||||
* 任何 D1**,一律走 HTTP,連法比照既有 kbdbBase() 慣例(kbdb-proxy.ts)。
|
||||
*
|
||||
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name)。
|
||||
*
|
||||
* 限制:ANALYTICS_KV list 沒辦法依 timestamp 排序,只能拿 key 後段 timestamp 排。
|
||||
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name,與舊 KV
|
||||
* key 同語意,沿用既有限制不在這次修復裡處理)。
|
||||
*/
|
||||
executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
@@ -164,30 +167,21 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||
}, 404);
|
||||
}
|
||||
|
||||
// 撈 stats:{name}:* 全 list(每個 key 含 timestamp 後綴)
|
||||
const list = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const params = new URLSearchParams({ workflow_id: name, owner_id: apiKey, limit: String(limit) });
|
||||
const kbdbRes = await fetch(`${base}/execution-log?${params.toString()}`, { headers });
|
||||
const kbdbBody = await kbdbRes.json().catch(() => null) as { success?: boolean; executions?: Array<{
|
||||
verdict: string; duration_ms: number; message: string; target?: string; recorded_at: number;
|
||||
}> } | null;
|
||||
|
||||
// 按 timestamp 降序(key suffix 是 unix ms)
|
||||
const sorted = [...list.keys].sort((a, b) => {
|
||||
const ta = parseInt(a.name.split(':').pop() ?? '0', 10);
|
||||
const tb = parseInt(b.name.split(':').pop() ?? '0', 10);
|
||||
return tb - ta;
|
||||
}).slice(0, limit);
|
||||
|
||||
const executions = [];
|
||||
for (const key of sorted) {
|
||||
const raw = await c.env.ANALYTICS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
executions.push({
|
||||
timestamp: key.name.split(':').pop(),
|
||||
...record,
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
const executions = (kbdbRes.ok && kbdbBody?.success ? kbdbBody.executions ?? [] : []).map((r) => ({
|
||||
timestamp: String(r.recorded_at),
|
||||
workflow_id: name,
|
||||
verdict: r.verdict,
|
||||
duration_ms: r.duration_ms,
|
||||
message: r.message ?? '',
|
||||
...(r.target ? { target: r.target } : {}),
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
@@ -197,7 +191,7 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||
executions,
|
||||
},
|
||||
hints: executions.length === 0
|
||||
? ['尚未有任何執行紀錄(或都過了 90d TTL)。先 call /webhooks/named/:name/trigger 跑一次']
|
||||
? ['尚未有任何執行紀錄。先 call /webhooks/named/:name/trigger 跑一次']
|
||||
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { authStoreStatus } from '../lib/portal-auth-store';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -10,11 +11,17 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
// 只是沒有人把它吐出來)。修=誠實回報本實例的 bundle 版本。
|
||||
// 未注入(本地 dev/很舊的實例)就省略該欄——daemon 對空字串仍判 stale,
|
||||
// 那是**正確的**(真的是老實例,該更新)。
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):多吐一個 `auth_store`——「認證住哪、寫不寫得進去」
|
||||
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
|
||||
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
|
||||
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
|
||||
healthRouter.get('/health', (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
return c.json(
|
||||
bundleVersion ? { ok: true, bundle_version: bundleVersion } : { ok: true },
|
||||
);
|
||||
return c.json({
|
||||
ok: true,
|
||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||
auth_store: authStoreStatus(c.env),
|
||||
});
|
||||
});
|
||||
|
||||
healthRouter.get('/', (c) =>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||
import { graphBase } from './kbdb-proxy';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
|
||||
@@ -129,7 +129,10 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
|
||||
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
|
||||
* 純函式(單測用 export)。
|
||||
*/
|
||||
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);
|
||||
// execution_log/execution_log_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 }>(
|
||||
entries: T[],
|
||||
@@ -240,10 +243,16 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
||||
// 斷崖落在 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'));
|
||||
const minScore = Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1 ? msRaw : 0.75;
|
||||
params.set('min_score', String(minScore));
|
||||
if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw));
|
||||
}
|
||||
const entryType = c.req.query('entry_type');
|
||||
if (entryType) params.set('entry_type', entryType);
|
||||
@@ -554,23 +563,20 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
/* 壞 record 誠實留空 */
|
||||
}
|
||||
}
|
||||
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp,
|
||||
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
|
||||
// 最近一次執行:KV 額度事故修復(2026-08-07)改打 KBDB GET /execution-log/latest
|
||||
// (原走 ANALYTICS_KV stats:{name}:* list,免費層 list 也是 1,000/日)。KBDB=
|
||||
// API-as-Wall:不直連 D1,走既有 kbdbFetch(本檔已在用,見上方 import)。
|
||||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||||
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
||||
if (stats.keys.length > 0) {
|
||||
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
|
||||
const ts = latest.name.split(':').pop() ?? '';
|
||||
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
|
||||
let verdict: string | undefined;
|
||||
if (rawStat) {
|
||||
try {
|
||||
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
|
||||
} catch {
|
||||
/* 壞 record 誠實留空 */
|
||||
}
|
||||
}
|
||||
last_execution = { timestamp: ts, verdict };
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null) as {
|
||||
success?: boolean;
|
||||
execution?: { verdict: string; recorded_at: number } | null;
|
||||
} | null;
|
||||
if (execRes.ok && execBody?.success && execBody.execution) {
|
||||
last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict };
|
||||
}
|
||||
return { name, description, created_at, cron_expr, last_execution };
|
||||
}),
|
||||
@@ -578,3 +584,36 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||||
//
|
||||
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||||
//
|
||||
// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。
|
||||
//
|
||||
// 🔴 t213(2026-08-08,InkStoneCo 總管交辦):leo 實測拿真檔驗四個真實問題,只答得出一題
|
||||
// (雲端這半的 bundle_version)——其餘三題(本機檔案總量、失敗分類統計、daemon 版本/
|
||||
// 自我更新狀態)需要本機資料,雲端這支端點天生構不到(封測者的瀏覽器與他電腦上的
|
||||
// daemon 是兩個獨立行程)。核准方案:本機那半改由 arcrun-app(daemon 桌面殼)匯出時
|
||||
// 直接讀本機檔案,並改打**新增的** `GET /portal/daemon/diagnostics`(X-Arcrun-API-Key
|
||||
// 認證,免帳密)取雲端這半,兩者合併成一份完整診斷檔——arcrun-app 那半見
|
||||
// products/arcrun-rag repo t213 phase 2。本端點(portal 網頁版)保留當退路(daemon
|
||||
// 完全掛掉時仍按得到),文案需誠實講清楚自己只有一半,完整診斷請去 daemon 匯出
|
||||
// (portal 前端文案改動不在本次 matrix/arcrun 範圍內,由 arcrun-rag 那邊處理)。
|
||||
//
|
||||
// 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變,核心邏輯已抽成 buildDiagnostics()
|
||||
// (portal.ts)——與新的 daemon 版共用同一份查詢邏輯(薄殼原則)。
|
||||
portalDataRouter.get('/portal/data/diagnostics', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const tenant = portalTenant(c.env);
|
||||
const core = await buildDiagnostics(c.env, tenant);
|
||||
return c.json({
|
||||
generated_at: new Date().toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
|
||||
...core,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -27,7 +27,23 @@ import { hashPassword, verifyPassword, randomHex, generatePassword } from '../li
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
import { storeCredential } from './credentials';
|
||||
import { storeCredential, hasCredential } from './credentials';
|
||||
// D61(Leo/arcrun-rag#55,ADR D61):**帳號不再住知識資料庫**。
|
||||
// 讀寫一律先走 lib/portal-auth-store(CF Workers Secrets,不靠任何 binding),
|
||||
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreRecentlyWritten,
|
||||
authStoreStatus,
|
||||
findAuthUserByEmail,
|
||||
findAuthUserById,
|
||||
hydrateFromAccelerator,
|
||||
isAuthStoreId,
|
||||
mutateAuthStore,
|
||||
newAuthUserId,
|
||||
readAuthStore,
|
||||
type AuthUserRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
|
||||
export const portalRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -38,7 +54,7 @@ const LOCK_TTL_SECONDS = 15 * 60; // 鎖 15 分鐘(KV TTL 自然過期)
|
||||
const DEFAULT_SESSION_TTL = 604800; // 7 天(design §4.3,比 console 30 天緊)
|
||||
|
||||
const USER_TEMPLATE = 'portal_user';
|
||||
const LIBRARY_TEMPLATE = 'portal_library';
|
||||
export const LIBRARY_TEMPLATE = 'portal_library';
|
||||
|
||||
// ── 基礎 helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -82,6 +98,10 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// D61:認證儲存寫不進去要**看得出來是這件事**(不是 KBDB 的錯,也不是密碼的錯)
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
|
||||
throw e;
|
||||
}
|
||||
@@ -152,8 +172,67 @@ export async function ensurePortalTemplates(
|
||||
return { created, existing, errors };
|
||||
}
|
||||
|
||||
/** email → user record_id(design §2.3 head entry O(1) 查找:page_name=email 走 index)。 */
|
||||
// ── D61 認證儲存 ⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)─────
|
||||
|
||||
function authUserToRecord(u: AuthUserRecord): PortalRecord {
|
||||
return {
|
||||
record_id: u.id,
|
||||
template_id: USER_TEMPLATE,
|
||||
values: {
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
status: u.status,
|
||||
role: u.role,
|
||||
password_hash: u.password_hash,
|
||||
libraries: JSON.stringify(u.libraries ?? []),
|
||||
created_at: u.created_at,
|
||||
updated_at: u.updated_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUserRecord {
|
||||
return {
|
||||
id,
|
||||
email: (v.email ?? '').toLowerCase(),
|
||||
display_name: v.display_name ?? '',
|
||||
status: v.status ?? 'active',
|
||||
role: v.role ?? 'user',
|
||||
libraries: parseLibraries(v.libraries),
|
||||
password_hash: v.password_hash ?? '',
|
||||
created_at: v.created_at ?? new Date().toISOString(),
|
||||
updated_at: v.updated_at ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 舊實例自癒:在 KBDB 找到的既有帳號,原樣搬進認證儲存。
|
||||
* best-effort——搬不動(寫入路徑未就緒)不影響這次登入,只是下次還會再走一次舊路。
|
||||
* 這就是 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
|
||||
*/
|
||||
async function promoteLegacyUser(env: Bindings, rec: PortalRecord): Promise<void> {
|
||||
try {
|
||||
const email = (rec.values.email ?? '').toLowerCase();
|
||||
if (!email) return;
|
||||
if (findAuthUserByEmail(env, email)) return;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (data.users.some((u) => u.email === email)) return;
|
||||
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
|
||||
});
|
||||
} catch {
|
||||
/* 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來) */
|
||||
}
|
||||
}
|
||||
|
||||
/** email → user record_id。**新家優先**;找不到才回退舊家(KBDB),並順手搬過去。 */
|
||||
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
const inStore = findAuthUserByEmail(env, email);
|
||||
if (inStore) return inStore.id;
|
||||
return findLegacyUserRecordId(env, email);
|
||||
}
|
||||
|
||||
/** 舊家(KBDB)的 email → record_id(design §2.3 head entry O(1) 查找)。 */
|
||||
async function findLegacyUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
const ns = portalNamespace(env);
|
||||
const params = new URLSearchParams({
|
||||
page_name: email,
|
||||
@@ -169,6 +248,11 @@ async function findUserRecordId(env: Bindings, email: string): Promise<string |
|
||||
}
|
||||
|
||||
async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
|
||||
// D61:住新家的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到)
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const u = findAuthUserById(env, recordId);
|
||||
return u ? authUserToRecord(u) : null;
|
||||
}
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new KbdbError(`GET /records/${recordId} → ${res.status}`);
|
||||
@@ -177,6 +261,19 @@ async function getRecordById(env: Bindings, recordId: string): Promise<PortalRec
|
||||
}
|
||||
|
||||
async function patchRecordValues(env: Bindings, recordId: string, values: Record<string, string>): Promise<PortalRecord> {
|
||||
// D61:住新家的帳號改寫進 Workers Secrets(改密碼/停用/改權限都在這條路上)
|
||||
if (isAuthStoreId(recordId)) {
|
||||
let updated: AuthUserRecord | null = null;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
const idx = data.users.findIndex((u) => u.id === recordId);
|
||||
if (idx < 0) throw new KbdbError(`認證儲存找不到帳號 ${recordId}`);
|
||||
const merged = { ...authUserToRecord(data.users[idx]).values, ...values };
|
||||
updated = recordValuesToAuthUser(recordId, merged);
|
||||
data.users[idx] = updated;
|
||||
});
|
||||
if (!updated) throw new KbdbError(`認證儲存更新失敗 ${recordId}`);
|
||||
return authUserToRecord(updated);
|
||||
}
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ values }),
|
||||
@@ -188,6 +285,17 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
|
||||
}
|
||||
|
||||
async function deleteKbdbRecord(env: Bindings, recordId: string): Promise<boolean> {
|
||||
if (isAuthStoreId(recordId)) {
|
||||
let found = false;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
const idx = data.users.findIndex((u) => u.id === recordId);
|
||||
if (idx >= 0) {
|
||||
data.users.splice(idx, 1);
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
|
||||
if (res.status === 404) return false;
|
||||
if (!res.ok) throw new KbdbError(`DELETE /records/${recordId} → ${res.status}`);
|
||||
@@ -200,6 +308,24 @@ function daemonActiveKey(env: Bindings): string {
|
||||
}
|
||||
|
||||
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
// D61:帳號清單=新家為主,舊家(KBDB)尚未搬走的補在後面(同 email 以新家為準)。
|
||||
// 舊家讀不到不算失敗——認證已經不靠它了,這裡只是把還沒搬完的人也列出來。
|
||||
if (template === USER_TEMPLATE) {
|
||||
const fromStore = readAuthStore(env).users.map(authUserToRecord);
|
||||
const seen = new Set(fromStore.map((r) => (r.values.email ?? '').toLowerCase()));
|
||||
let legacy: PortalRecord[] = [];
|
||||
try {
|
||||
legacy = await listLegacyRecordsByTemplate(env, template);
|
||||
} catch {
|
||||
legacy = [];
|
||||
}
|
||||
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()))];
|
||||
}
|
||||
return listLegacyRecordsByTemplate(env, template);
|
||||
}
|
||||
|
||||
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」仍走這條,那些本來就該住知識庫)。 */
|
||||
async function listLegacyRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} → ${res.status}`);
|
||||
@@ -215,44 +341,28 @@ interface CreateUserInput {
|
||||
password_hash: string;
|
||||
}
|
||||
|
||||
/** 建 portal_user record(子 namespace)+ email head entry(§2.3)。 */
|
||||
/**
|
||||
* 建帳號。**D61 起一律建在認證儲存(Workers Secrets),不再寫進 KBDB。**
|
||||
* 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家——
|
||||
* 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。
|
||||
*/
|
||||
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
|
||||
const ns = portalNamespace(env);
|
||||
const now = new Date().toISOString();
|
||||
const res = await kbdbFetch(env, '/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
template: USER_TEMPLATE,
|
||||
owner_id: ns,
|
||||
values: {
|
||||
email: input.email,
|
||||
display_name: input.display_name,
|
||||
status: 'active',
|
||||
role: input.role,
|
||||
password_hash: input.password_hash,
|
||||
libraries: JSON.stringify(input.libraries),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
}),
|
||||
const id = newAuthUserId();
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.users.push({
|
||||
id,
|
||||
email: input.email.toLowerCase(),
|
||||
display_name: input.display_name,
|
||||
status: 'active',
|
||||
role: input.role,
|
||||
libraries: input.libraries,
|
||||
password_hash: input.password_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records(portal_user)→ ${res.status}`);
|
||||
const body = (await res.json()) as { record?: { record_id: string } };
|
||||
const recordId = body.record?.record_id;
|
||||
if (!recordId) throw new KbdbError('POST /records 回應缺 record_id');
|
||||
|
||||
// head entry:page_name=email(indexed)→ content=record_id,O(1) 登入查找
|
||||
const head = await kbdbFetch(env, '/entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
entry_type: USER_TEMPLATE,
|
||||
page_name: input.email,
|
||||
content: recordId,
|
||||
owner_id: ns,
|
||||
}),
|
||||
});
|
||||
if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`);
|
||||
return recordId;
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── user 值域 helpers ──────────────────────────────────────────────────────
|
||||
@@ -304,6 +414,23 @@ export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Resp
|
||||
/**
|
||||
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
|
||||
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。
|
||||
*
|
||||
* 🔴 `Leo/arcrun-rag#66`(2026-08-10,leo 本人被鎖在 stage 外面的那條):
|
||||
* **「這一瞬間讀不到」不可以觸發不可逆的動作。**
|
||||
* 認證的家是 CF Workers Secret,改它(改密碼/建帳號/停用)會產生 worker 新版本,
|
||||
* **既有 isolate 讀到的還是舊 env**(`#55` 實測 ≥15 秒)。舊版在那個空窗裡:
|
||||
* ① `getRecordById` 讀不到 → ② **直接把 session 從 KV 刪掉** → ③ 回 401
|
||||
* ⇒ 帶著一個**完全有效的 token**,登入狀態被當場銷毀,等 secret 鋪開也回不來。
|
||||
* `#55` 補的「讀不到就再問一次加速器」只加在登入路徑(`findAndVerifyUser`),這道門沒有。
|
||||
*
|
||||
* 三段修法(缺一不可):
|
||||
* 1. **先問一次加速器再判定**——與登入路徑同一招,同一支 `hydrateFromAccelerator`。
|
||||
* 2. **永不因「讀不到」刪 session**。刪是 best-effort 清潔工,而它清掉的是使用者唯一的
|
||||
* 憑據;KV 的 TTL 本來就會回收,這件事沒有非做不可的理由。
|
||||
* 3. 仍然讀不到且**正在傳播空窗**(加速器 key 還在)→ 回 **503 `auth_store_propagating`**,
|
||||
* 不是 401。理由在前端:portal 的 `guard401()` 一看到 401 就清 localStorage 踢回登入頁
|
||||
* ⇒ 就算 KV 那筆還在,使用者手上的 token 也被自己的瀏覽器丟掉了。
|
||||
* **後端不刪、前端不丟,這件事才算真的修好。**
|
||||
*/
|
||||
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
|
||||
const token = bearerToken(c);
|
||||
@@ -317,15 +444,33 @@ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Pro
|
||||
/* fallthrough */
|
||||
}
|
||||
if (!recordId) {
|
||||
// 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
||||
}
|
||||
const rec = await getRecordById(c.env, recordId);
|
||||
let rec = await getRecordById(c.env, recordId);
|
||||
if (!rec && (await hydrateFromAccelerator(c.env))) {
|
||||
rec = await getRecordById(c.env, recordId); // ①:與登入路徑同一招,再問一次加速器
|
||||
}
|
||||
if (!rec) {
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
// ③:分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。
|
||||
if (await authStoreRecentlyWritten(c.env)) {
|
||||
return {
|
||||
ok: false,
|
||||
res: c.json(
|
||||
{
|
||||
error: '認證資料正在更新中(Cloudflare 正在鋪開新版本),請稍候幾秒再試——你並沒有被登出。',
|
||||
code: 'auth_store_propagating',
|
||||
},
|
||||
503,
|
||||
),
|
||||
};
|
||||
}
|
||||
// ②:查無此帳號(可能真的被刪了)→ 擋下即可,**不刪 session**(KV TTL 自己會回收)。
|
||||
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
||||
}
|
||||
if ((rec.values.status ?? '') !== 'active') {
|
||||
// 讀得到 record = 確定的事實,停用要即時生效,刪 session 是對的。
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
|
||||
}
|
||||
@@ -429,6 +574,49 @@ async function clearLoginFail(env: Bindings, email: string): Promise<void> {
|
||||
await env.SESSIONS_KV.delete(`${LOCKFAIL_PREFIX}${email}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:這台實例是不是「一個帳號都沒有」(新家空、舊家也空/讀不到)。
|
||||
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑。
|
||||
*/
|
||||
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
|
||||
if (readAuthStore(env).users.length > 0) return false;
|
||||
try {
|
||||
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
} catch {
|
||||
return true; // 舊家讀不到 + 新家空 = 這台實例確實沒有可用的登入資料
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:查帳號+驗密碼的**唯一**入口(portal 登入與兩支 daemon 端點共用,避免三份走樣)。
|
||||
*
|
||||
* 🔴 為什麼要「失敗後再問一次加速器」(2026-08-10 stage 演練實測撞到的坑):
|
||||
* 認證的家是 CF Workers Secret,改它會產生 worker 新版本,**既有 isolate 讀到的還是舊 env**。
|
||||
* 實測「建好帳號 → 立刻登入」有 15 秒以上是 401,而且那幾次還被算進 5 次鎖定
|
||||
* ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。
|
||||
* ⇒ 所以查不到/密碼對不上時,**先問一次加速器再判定失敗**(見 lib 的 ACCEL_KEY 註解)。
|
||||
* ⇒ 加速器讀不到也沒關係,只是回到「等傳播」;它不是真相源,換 KV 不影響 D61 的不變量。
|
||||
*/
|
||||
async function findAndVerifyUser(
|
||||
env: Bindings,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ recordId: string | null; rec: PortalRecord | null; ok: boolean }> {
|
||||
const attempt = async () => {
|
||||
const recordId = await findUserRecordId(env, email);
|
||||
const rec = recordId ? await getRecordById(env, recordId) : null;
|
||||
const ok = rec ? await verifyPassword(password, rec.values.password_hash ?? '') : false;
|
||||
return { recordId, rec, ok };
|
||||
};
|
||||
const first = await attempt();
|
||||
if (first.ok) return first;
|
||||
if (await hydrateFromAccelerator(env)) {
|
||||
const second = await attempt();
|
||||
if (second.ok || second.rec) return second;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════ 認證端點 ═══════════════════════════════════
|
||||
|
||||
// POST /portal/login — body {email, password}。成功發 portal session token。
|
||||
@@ -444,25 +632,39 @@ portalRouter.post('/portal/login', (c) =>
|
||||
return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429);
|
||||
}
|
||||
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
if (!recordId) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
const rec = await getRecordById(c.env, recordId);
|
||||
if (!rec) {
|
||||
const { recordId, rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!recordId || !rec) {
|
||||
// D61 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
|
||||
// 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事,不准混成同一句話——
|
||||
// 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘,而他的密碼從頭到尾都是對的。
|
||||
// ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。
|
||||
if (await instanceHasNoAuthData(c.env)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的,' +
|
||||
'請重新執行安裝/更新以重新建立管理員帳號。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
if ((rec.values.status ?? '') !== 'active') {
|
||||
return c.json({ error: '帳號已停用' }, 403);
|
||||
}
|
||||
const ok = await verifyPassword(password, rec.values.password_hash ?? '');
|
||||
if (!ok) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
// D61 自癒:這次是拿舊家(KBDB)的帳號登進來的 → 順手搬進認證儲存,
|
||||
// 下次換庫/換租戶代號就不會再把他鎖在門外。
|
||||
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
|
||||
|
||||
await clearLoginFail(c.env, email);
|
||||
const token = randomHex(32);
|
||||
// session 值只存 record_id(design §4.3)——權限/狀態每請求回讀 record,不快取進 session
|
||||
@@ -575,6 +777,42 @@ portalRouter.post('/portal/admin/bootstrap', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/admin/recover-password — 管理員自救援出口(arcrun-rag#25:唯一 admin 忘記
|
||||
// portal 密碼就進不去,登入頁只會叫他「聯絡管理員」=叫他聯絡自己,沒有下一步)。
|
||||
//
|
||||
// 根因:`/portal/admin/users/:id/reset-password`(上面)與其他 admin 端點全部要求
|
||||
// `requirePortalAdmin`=**要先有一個有效的 portal admin session**——雞生蛋問題:admin
|
||||
// 密碼忘了就進不去 portal,進不去 portal 就沒有 session 去重設密碼。`bootstrap` 能繞過這關
|
||||
// 是因為它吃的是**另一道獨立的閘**(console owner session,design D-7);但 bootstrap
|
||||
// 只能跑一次(已有 admin 就 409),事後沒有對應的「用同一道閘做救援」端點。
|
||||
//
|
||||
// 修法:開一個**只認 console owner session、不認 portal session**的救援端點,直接複用
|
||||
// bootstrap 已經在用的 `validateConsoleSession`。console 帳密(`/console/setup` 首次設定時
|
||||
// 建立)與 portal 帳密是完全分開存放的兩組(見 console-auth.ts),只要 console 密碼沒有一起忘記,
|
||||
// 這條路就走得通——不必問人、不必讀原始碼,畫面上(/console → 設定 → Portal 帳號密碼救援)
|
||||
// 就有完整入口。找不到該 email 的帳號 → 404(誠實,不誤導成別的錯誤)。
|
||||
portalRouter.post('/portal/admin/recover-password', (c) =>
|
||||
run(c, async () => {
|
||||
const consoleOk = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||
if (!consoleOk) return c.json({ error: '需要 console owner session(先登入 /console)' }, 401);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||||
if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
|
||||
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
if (!recordId || !rec) return c.json({ error: `找不到 email=${email} 的 portal 帳號` }, 404);
|
||||
|
||||
const password = generatePassword();
|
||||
await patchRecordValues(c.env, recordId, {
|
||||
password_hash: await hashPassword(password),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
return c.json({ success: true, email, password }); // 一次性回傳,server 不留明碼
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/admin/users — 同仁列表(role=admin 閘)。**回應剝除 password_hash**。
|
||||
portalRouter.get('/portal/admin/users', (c) =>
|
||||
run(c, async () => {
|
||||
@@ -808,10 +1046,9 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
|
||||
const password = String(body?.password ?? '');
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
if (!rec || (rec.values.status ?? '') !== 'active'
|
||||
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||||
// D61:與 /portal/login 共用同一支查找+驗證(含「剛建好還沒傳播」的加速器重試)
|
||||
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!rec || (rec.values.status ?? '') !== 'active' || !ok) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
@@ -874,14 +1111,14 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
||||
if (await isLocked(c.env, email)) {
|
||||
return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429);
|
||||
}
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
// D61:同上,共用 findAndVerifyUser
|
||||
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!rec) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
|
||||
if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||||
if (!ok) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
@@ -1069,10 +1306,9 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
|
||||
|
||||
// 帳密驗證(沿用 /portal/session 的鎖定與驗證機制)
|
||||
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
if (!rec || (rec.values.status ?? '') !== 'active'
|
||||
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||||
// D61:與 /portal/login 共用同一支查找+驗證(含「剛建好還沒傳播」的加速器重試)
|
||||
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!rec || (rec.values.status ?? '') !== 'active' || !ok) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
@@ -1170,13 +1406,10 @@ portalRouter.get('/portal/admin/ai', (c) =>
|
||||
const tenantSlug = portalTenant(c.env);
|
||||
let hasKey = false;
|
||||
try {
|
||||
const row = await c.env.CREDENTIALS_DB
|
||||
.prepare('SELECT 1 FROM credentials WHERE api_key = ? AND name = ? LIMIT 1')
|
||||
.bind(tenantSlug, 'gemini_api_key')
|
||||
.first();
|
||||
hasKey = !!row;
|
||||
// D38 圍牆修復(2026-08-07):改走 credentials.ts 的 KBDB 目錄查詢,不直連 D1。
|
||||
hasKey = await hasCredential(c.env, tenantSlug, 'gemini_api_key');
|
||||
} catch {
|
||||
// D1 未就緒 ⇒ 當作沒設定(不擋頁面),但也不假裝有
|
||||
// KBDB 不可達 ⇒ 當作沒設定(不擋頁面),但也不假裝有
|
||||
hasKey = false;
|
||||
}
|
||||
|
||||
@@ -1186,6 +1419,44 @@ portalRouter.get('/portal/admin/ai', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/admin/execution-log-retention — 讀本實例的執行紀錄保留期設定(P7,role=admin 閘)。
|
||||
// retention_days: number=自訂天數;null=已設「不刪除」(企業稽核);未設定過的租戶也回一個值
|
||||
// (KBDB 端會退回預設 90 天,見 kbdb/src/actions/execution-log.ts DEFAULT_RETENTION_DAYS)。
|
||||
portalRouter.get('/portal/admin/execution-log-retention', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const ownerId = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`);
|
||||
const data = (await res.json()) as { retention_days?: number | null; default_days?: number };
|
||||
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
||||
}),
|
||||
);
|
||||
|
||||
// PUT /portal/admin/execution-log-retention — 設定保留天數(P7,role=admin 閘)。
|
||||
// body: { retention_days: number|null }。null=不刪除(leo 08-07:「我願意花很多錢保存,
|
||||
// 不要刪除」,這是稽核用途的付費理由,不是成本負擔);正整數=自訂天數,覆蓋預設 90 天。
|
||||
portalRouter.put('/portal/admin/execution-log-retention', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = (await c.req.json().catch(() => null)) as { retention_days?: number | null } | null;
|
||||
const days = body?.retention_days;
|
||||
if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
|
||||
return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
|
||||
}
|
||||
const ownerId = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, '/execution-log/retention', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ owner_id: ownerId, retention_days: days === undefined ? null : days }),
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`);
|
||||
const data = (await res.json()) as { retention_days?: number | null };
|
||||
return c.json({ success: true, retention_days: data.retention_days ?? null });
|
||||
}),
|
||||
);
|
||||
|
||||
// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。
|
||||
// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
|
||||
// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。
|
||||
@@ -1264,3 +1535,167 @@ portalRouter.delete('/portal/admin/libraries/:id', (c) =>
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// ── 檢修孔核心邏輯(t213,InkStoneCo 總管交辦,2026-08-08)──────────────────────
|
||||
//
|
||||
// buildDiagnostics 是 GET /portal/data/diagnostics(P3 session 版,portal 網頁「疑難排解」
|
||||
// 按鈕)與 GET /portal/daemon/diagnostics(下方新增,daemon 免帳密版)共用的**唯一**實作
|
||||
// (薄殼原則 rule 07:能力只放一處)。原本整段邏輯躺在 portal-data.ts 的 handler 裡;
|
||||
// daemon 是背景常駐行程、沒有 portal session(密碼只在連線精靈當下用過就丟,見
|
||||
// connect.go 註解),構不到 session 版端點,需要一支 X-Arcrun-API-Key 版本——抽出來讓
|
||||
// 兩條路由共用同一份查詢邏輯,不是各寫一份、日後各自漂移。
|
||||
//
|
||||
// 涵蓋「這次一定要涵蓋」的向量/embedding 健康狀態:embed 模組是否開(index 存在的前提)、
|
||||
// 已嵌入/待嵌入卡片數、以及 embedSelfTest(KBDB #12)——這是唯一能分辨「從沒嵌過」與
|
||||
// 「嵌了但 index 查不到自己」兩種故障模式的方法(Arcrun#11 的真實案例正是後者,光看
|
||||
// 計數看不出來)。
|
||||
//
|
||||
// 🔴 兩條紅線(規格原文,2026-08-07 leo 直接指令):
|
||||
// ① 不准把內部概念暴露給用戶——本函式只回統計/狀態,呼叫端文案不提 KBDB/Vectorize/
|
||||
// owner_id 這類詞。
|
||||
// ② 不准洩漏知識卡內容本體——以下每一個欄位都只挑「數字」或「布林」,即使背後的 KBDB
|
||||
// 端點回應含 content(如 triplet 的 subject/object 名稱),本函式一律只讀出用得到
|
||||
// 的數字後就丟掉那個回應,不把原始內容往呼叫端送。
|
||||
export interface DiagnosticsCore {
|
||||
library_count: number;
|
||||
triplet_count: number;
|
||||
library_scope_check: Record<string, unknown>;
|
||||
embedding: Record<string, unknown>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** tenant=owner_id(session 版傳 portalTenant(env);daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */
|
||||
export async function buildDiagnostics(env: Bindings, tenant: string): Promise<DiagnosticsCore> {
|
||||
const notes: string[] = [];
|
||||
|
||||
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
|
||||
let embedding: Record<string, unknown> = { checked: false };
|
||||
try {
|
||||
const [statusRes, selftestRes] = await Promise.all([
|
||||
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`),
|
||||
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`),
|
||||
]);
|
||||
const statusBody = (await statusRes.json().catch(() => null)) as
|
||||
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
|
||||
| null;
|
||||
const selftestBody = (await selftestRes.json().catch(() => null)) as
|
||||
| { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; note?: string }
|
||||
| null;
|
||||
embedding = {
|
||||
checked: true,
|
||||
module_enabled: statusBody?.enabled ?? false, // Vectorize+AI binding 都在,才有「index」這回事
|
||||
cards_embedded: statusBody?.embedded ?? 0,
|
||||
cards_pending: statusBody?.pending ?? 0,
|
||||
self_test: {
|
||||
ran: selftestBody?.tested ?? false,
|
||||
// 三態:true=能搜到自己 / false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開
|
||||
found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null,
|
||||
note: selftestBody?.note ?? '',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
notes.push(`embed 健康狀態查詢失敗:${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// ② 卡片與知識圖譜規模(只取數字,不取內容欄位)。
|
||||
//
|
||||
// 改走與 /portal/admin/libraries 相同、驗證過在用的**即時查詢**組合(不依賴 library_map
|
||||
// 快取——2026-08-08 曾實測快取恆回 0,根因與修正過程見 commit 7dbd4f5,此處不重貼一次
|
||||
// 避免兩處各改各的漂移):
|
||||
// - listRecordsByTemplate(portal_library):已登記的庫(t159)
|
||||
// - GET /entries/libraries:資料裡實際蓋章出現過的庫,登記與否都算(t52,
|
||||
// 「蓋章即現身」);'general' 是未標庫的系統 fallback 桶,不算使用者眼中的一個庫,
|
||||
// 與 admin/libraries 同慣例排除。
|
||||
// - GET /records/triplet-stats:per-library 即時聚合 SQL(t142,COUNT,非快取)。
|
||||
let library_count = 0;
|
||||
let triplet_count = 0;
|
||||
const ownerParam = new URLSearchParams({ owner_id: tenant }).toString();
|
||||
try {
|
||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||
kbdbFetch(env, `/entries/libraries?${ownerParam}`),
|
||||
kbdbFetch(env, `/records/triplet-stats?${ownerParam}`),
|
||||
]);
|
||||
const knownLibs = new Set(
|
||||
registeredLibs.map((r) => (r.values.name ?? '').trim()).filter((n): n is string => !!n),
|
||||
);
|
||||
const autoBody = (await autoRes.json().catch(() => null)) as { success?: boolean; libraries?: string[] } | null;
|
||||
for (const name of autoBody?.libraries ?? []) {
|
||||
const n = String(name ?? '').trim();
|
||||
if (n && n !== 'general') knownLibs.add(n);
|
||||
}
|
||||
library_count = knownLibs.size;
|
||||
|
||||
const tripletBody = (await tripletRes.json().catch(() => null)) as
|
||||
| { success?: boolean; stats?: { library: string; triplet_count?: number }[] }
|
||||
| null;
|
||||
triplet_count = (tripletBody?.stats ?? []).reduce((sum, s) => sum + (Number(s.triplet_count) || 0), 0);
|
||||
} catch (e) {
|
||||
notes.push(`知識庫規模查詢失敗:${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// ②.5 統計自我檢查(呼應上面 embedding.self_test 的精神——leo 直接指令:「不要讓『查不到』
|
||||
// 和『沒有』長得一樣」)。library_count/triplet_count 兩者都是 0 時,才另外花一次查詢,
|
||||
// 用完全不同的路徑(不分庫、不分模板,只問「這個 owner_id 底下到底有沒有任何 entries」)
|
||||
// 做交叉驗證——如果探測到有資料,代表問題出在查詢方式或 owner_id 對不上(2026-08-01
|
||||
// t161 前科:手動補的 record owner_id 存成 None,kbdb_query 全量查得到、按 owner_id 過濾
|
||||
// 的畫面永遠空,比真的沒資料更難查);如果探測也是空,才比較像真的是空庫。
|
||||
let library_scope_check: Record<string, unknown> = { ran: false };
|
||||
if (library_count === 0 && triplet_count === 0) {
|
||||
try {
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`);
|
||||
const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
|
||||
const total = probeBody?.total ?? 0;
|
||||
library_scope_check = {
|
||||
ran: true,
|
||||
any_entries_found: total > 0,
|
||||
note:
|
||||
total > 0
|
||||
? `這個租戶底下查得到其他資料(entries 共 ${total} 筆),但庫/三元組統計仍回 0——像是查詢方式或租戶對不上,不像真的沒資料,需要人再查一次`
|
||||
: '這個租戶底下完全查不到任何資料——比較像是真的還沒有資料,不是查詢方式錯了',
|
||||
};
|
||||
} catch (e) {
|
||||
library_scope_check = {
|
||||
ran: true,
|
||||
any_entries_found: null,
|
||||
note: `自我探測查詢本身失敗:${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 最近一次萃取(daemon → /portal/daemon/extract)成功與否:目前沒有雲端側的失敗歷史
|
||||
// 記錄可讀(該端點是同步請求/回應,失敗只回給呼叫當下的 daemon,雲端不落地保存)——
|
||||
// 這個缺口不在這裡假裝補上一句話,本機那半(manifest 的 LastError 分類統計,t213 phase 2
|
||||
// arcrun-app 那半)才是真正能答這題的地方;本函式維持誠實:知道多少答多少,不摻水。
|
||||
return { library_count, triplet_count, library_scope_check, embedding, notes };
|
||||
}
|
||||
|
||||
// GET /portal/daemon/diagnostics — 檢修孔的 daemon 版(t213 matrix/arcrun 半部,InkStoneCo
|
||||
// 總管交辦,2026-08-08)。讓 arcrun-app(daemon 桌面殼)能免帳密拿到雲端這半的診斷數字,
|
||||
// 跟本機那半(manifest 總量/失敗分類統計/daemon 版本與自我更新狀態,見 products/arcrun-rag
|
||||
// repo t213 phase 2 的設計)合併成一份完整診斷檔——本端點純粹只負責雲端能看到的那半。
|
||||
//
|
||||
// 認證比照既有 /portal/daemon/extract(X-Arcrun-API-Key,不是 session):daemon 是背景
|
||||
// 常駐行程,使用者的密碼只在連線精靈當下用過就丟、不落地(見 collector/cmd/arcrun-app/
|
||||
// connect.go 註解),背景查詢沒有密碼可用。
|
||||
//
|
||||
// apiKey 當 tenant/owner_id 用,**不與 portalTenant(env) 比對**——t189 教訓:daemon 的
|
||||
// api_key 不保證等於這個 worker 的 CONSOLE_TENANT(多帳號情境下曾對不上,見上方
|
||||
// /portal/daemon/extract 的 t189 註解),「有帶 key 即可」是本 repo 對 X-Arcrun-API-Key
|
||||
// 的既有慣例(見 webhooks-named.ts 的 owner_id: apiKey 用法)。
|
||||
//
|
||||
// 邏輯與 /portal/data/diagnostics(portal-data.ts)共用同一個上面的 buildDiagnostics()——
|
||||
// 薄殼原則:能力只實作一次;兩條路由只是認證層不同,回應形狀完全一致,不改既有行為。
|
||||
portalRouter.get('/portal/daemon/diagnostics', (c) =>
|
||||
run(c, async () => {
|
||||
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
|
||||
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
const core = await buildDiagnostics(c.env, apiKey);
|
||||
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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -312,7 +312,7 @@ async function triggerNamed(
|
||||
c.executionCtx.waitUntil(
|
||||
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
|
||||
.then(result =>
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||
),
|
||||
);
|
||||
return c.json({ accepted: true }, 202);
|
||||
@@ -329,7 +329,7 @@ async function triggerNamed(
|
||||
);
|
||||
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||
);
|
||||
|
||||
return c.json(result, result.success ? 200 : 500);
|
||||
@@ -401,7 +401,7 @@ async function queryNamed(
|
||||
|
||||
// 執行判決寫入不阻塞回應(waitUntil,與 /trigger 一致)。
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -73,7 +73,7 @@ webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
|
||||
const workflowId = graph.id ?? token;
|
||||
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
|
||||
);
|
||||
|
||||
return c.json(result, result.success ? 200 : 500);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度)
|
||||
* 3. 匹配才去讀完整 workflow record({apiKey}:wf:{name})
|
||||
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
|
||||
* 5. 每天固定一分鐘(UTC 02:30)順便叫 KBDB 清一批過期執行紀錄(P7 保留期,見下方 §5)
|
||||
*
|
||||
* 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限,
|
||||
* 改成單一固定 key 只 get 一次 → list 歸零。
|
||||
@@ -18,6 +19,7 @@ import type { Bindings } from './types';
|
||||
import { cronMatch } from './lib/cron-match';
|
||||
import { readCronIndex, parseCronEntryKey } from './lib/cron-index';
|
||||
import { executeWebhookGraph } from './actions/webhook-handlers';
|
||||
import { kbdbBase } from './routes/kbdb-proxy';
|
||||
|
||||
type StoredWorkflowRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
@@ -73,4 +75,22 @@ export async function handleScheduled(
|
||||
);
|
||||
}
|
||||
console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`);
|
||||
|
||||
// §5 P7 保留期清理(2026-08-09):不新增排程基礎設施(wrangler.toml [triggers] 是受保護
|
||||
// 檔案,AI 不可編輯——見 InkStoneCo 頂層 pending-changes.md P9 段 L1 權限閘),改「搭便車」:
|
||||
// 這支 handler 本來就每分鐘醒一次(給上面的 cron workflow 用),挑固定一分鐘(UTC 02:30,
|
||||
// 避開整點/半點常見的 cron 表達式擁擠時段)順手打一次 fire-and-forget 給 KBDB 的
|
||||
// POST /execution-log/cleanup。頻率仍是「一天一次」,不是輪詢外部系統要狀態,是既有 tick
|
||||
// 順手打理自己的表。呼叫失敗不影響上面的 cron workflow 觸發(各自 try/catch,互不拖累)。
|
||||
if (now.getUTCHours() === 2 && now.getUTCMinutes() === 30) {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
ctx.waitUntil(
|
||||
fetch(`${base}/execution-log/cleanup`, { method: 'POST', headers })
|
||||
.then(async (r) => {
|
||||
const body = await r.json().catch(() => null);
|
||||
console.log('[scheduled] execution-log cleanup', r.status, JSON.stringify(body));
|
||||
})
|
||||
.catch((e) => console.error('[scheduled] execution-log cleanup failed', e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ export type Bindings = {
|
||||
// Credential Store:AES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7
|
||||
// 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡)
|
||||
CREDENTIALS_KV: KVNamespace;
|
||||
// credential-store-migration T2/T5(D19「擁有目錄,不擁有內容物」):credential 目錄表
|
||||
// (api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,不含密文)。
|
||||
// 與 KBDB base 共用同一顆 arcrun-kbdb D1(self-hosted 由 deploy.ts 注入用戶自己的
|
||||
// database_id,比照 kbdb/wrangler.toml 同一套 database_id 注入機制)。密文本體不在這裡,
|
||||
// 住在 Workers per-script Secrets(見 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。
|
||||
// ⚠️ D38 圍牆修復(2026-08-07)後零讀寫點:credential 目錄已改走 KBDB entries HTTP API
|
||||
// (見 cypher-executor/src/routes/credentials.ts),不再對這顆 D1 下任何 SQL。binding
|
||||
// 因 wrangler.toml 被權限鎖住(D38 決策所述)暫留宣告,比照 ANALYTICS_KV 同一模式
|
||||
// (commit 60688c3:binding 留在 toml,程式碼零讀寫點)。舊表資料遷移路徑見
|
||||
// kbdb/migrations/0006_drop_credentials_table.sql。
|
||||
CREDENTIALS_DB: D1Database;
|
||||
// Analytics:執行統計(fire-and-forget,key = stats:{workflowId}:{timestamp})
|
||||
ANALYTICS_KV: KVNamespace;
|
||||
|
||||
@@ -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` 呼叫真實
|
||||
* Cloudflare API(`fetch` 到 api.cloudflare.com)。測試環境(wrangler.test.toml)刻意不設
|
||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID,所以本檔只覆蓋「不需要真的打 CF API」的路徑:
|
||||
* - D1-only 的 GET /credentials、/credentials/catalog
|
||||
* - DELETE 在 D1 無 row 時 fallback 刪舊 KV(不會走到 deleteWorkerSecret)
|
||||
* 真正打 CF Workers Secrets API 成功寫入/刪除的路徑,由部署到 leo21c 帳號後的端到端
|
||||
* curl 驗證覆蓋(見 credential-store-migration.md T8/T9 完成記錄)。
|
||||
* 前身是刻意留紅的 placeholder(見 git history):2026-08-07 D38 把 credential 目錄從
|
||||
* 「獨立 credentials 表 + 原生 SQL」改成「KBDB entries(entry_type='credential')+
|
||||
* HTTP API」,舊測試全部作廢,agent 中途被中斷沒補上,故意留一個會失敗的測試佔位、
|
||||
* 避免「no tests」被誤讀成「通過」。本檔依 placeholder 頭部列的五項補齊。
|
||||
*
|
||||
* 測試手法比照姊妹模組 execution-logger.test.ts:`vi.stubGlobal('fetch', ...)` 攔截,
|
||||
* 但這裡的攔截器是**有狀態的假 KBDB**(in-memory entries store),因為 credentials.ts
|
||||
* 一次操作常涉及多輪 HTTP 呼叫(find → upsert / find → delete),單次回應的 mock 測不出
|
||||
* 「查得到剛寫的」「刪掉後真的查不到」這類語意,需要一個會記狀態的假後端。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { env, SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { credentialsRouter, getCredentialSecretRefs, hasCredential, invalidateCredentialCache } from '../src/routes/credentials';
|
||||
import type { Bindings } from '../src/types';
|
||||
// Workers runtime(@cloudflare/vitest-pool-workers)沒有 node:fs——原始碼掃描改用 Vite 的
|
||||
// `?raw` import 取字串內容(build-time 讀檔,runtime 是純字串,不受 Workers 限制)。
|
||||
// @ts-expect-error -- vite ?raw 型別由 tsconfig 的 vite/client 提供,非本檔關注重點
|
||||
import credentialsSource from '../src/routes/credentials.ts?raw';
|
||||
|
||||
const API_KEY = 'test-tenant-t89';
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
async function insertCredentialRow(
|
||||
name: string,
|
||||
secretRef: string,
|
||||
extra: Partial<{ service: string | null; sensitivity: string; last_used_at: number | null }> = {},
|
||||
): Promise<void> {
|
||||
await env.CREDENTIALS_DB
|
||||
.prepare(
|
||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
API_KEY,
|
||||
name,
|
||||
extra.service ?? null,
|
||||
extra.sensitivity ?? 'standard',
|
||||
secretRef,
|
||||
Math.floor(Date.now() / 1000),
|
||||
extra.last_used_at ?? null,
|
||||
)
|
||||
.run();
|
||||
// ── 有狀態假 KBDB:只實作 credentials.ts 實際會打的四個操作(GET list/find, POST, PATCH, DELETE)──
|
||||
interface FakeEntry {
|
||||
id: string;
|
||||
entry_type: string;
|
||||
owner_id: string;
|
||||
page_name: string;
|
||||
metadata_json: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
async function clearTenantRows(): Promise<void> {
|
||||
await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run();
|
||||
function makeFakeKbdb() {
|
||||
const entries: FakeEntry[] = [];
|
||||
let idSeq = 0;
|
||||
const secretsStore = new Map<string, string>(); // secretRef -> plaintext(模擬 CF Workers Secrets,唯寫,測試用來斷言「有沒有被塞值」)
|
||||
const secretPuts: Array<{ name: string; text: string }> = [];
|
||||
const secretDeletes: string[] = [];
|
||||
const kbdbRequests: Array<{ method: string; url: string; body: unknown }> = [];
|
||||
|
||||
async function handle(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const u = new URL(url);
|
||||
|
||||
// CF Workers Scripts secrets 管理 API(唯寫,讀不回值)
|
||||
if (u.hostname === 'api.cloudflare.com') {
|
||||
if (method === 'PUT' && u.pathname.endsWith('/secrets')) {
|
||||
const body = JSON.parse(String(init.body)) as { name: string; text: string };
|
||||
secretsStore.set(body.name, body.text);
|
||||
secretPuts.push(body);
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
if (method === 'DELETE' && u.pathname.includes('/secrets/')) {
|
||||
const name = u.pathname.split('/secrets/')[1];
|
||||
secretsStore.delete(name);
|
||||
secretDeletes.push(name);
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
throw new Error(`unhandled CF API call: ${method} ${url}`);
|
||||
}
|
||||
|
||||
// KBDB entries API
|
||||
kbdbRequests.push({ method, url, body: init.body ? JSON.parse(String(init.body)) : undefined });
|
||||
|
||||
if (method === 'POST' && u.pathname === '/entries') {
|
||||
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
|
||||
const entry: FakeEntry = {
|
||||
id: `e_${++idSeq}`,
|
||||
entry_type: body.entry_type!,
|
||||
owner_id: body.owner_id!,
|
||||
page_name: body.page_name!,
|
||||
metadata_json: body.metadata_json!,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
entries.push(entry);
|
||||
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
|
||||
}
|
||||
|
||||
if (method === 'GET' && u.pathname === '/entries') {
|
||||
const ownerId = u.searchParams.get('owner_id');
|
||||
const entryType = u.searchParams.get('entry_type');
|
||||
const pageName = u.searchParams.get('page_name');
|
||||
let rows = entries.filter((e) => e.entry_type === entryType && e.owner_id === ownerId);
|
||||
if (pageName) rows = rows.filter((e) => e.page_name === pageName);
|
||||
return new Response(JSON.stringify({ success: true, entries: rows, count: rows.length }), { status: 200 });
|
||||
}
|
||||
|
||||
if (method === 'PATCH' && u.pathname.startsWith('/entries/')) {
|
||||
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
|
||||
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
|
||||
const entry = entries.find((e) => e.id === id);
|
||||
if (!entry) return new Response(JSON.stringify({ success: false }), { status: 404 });
|
||||
if (body.metadata_json !== undefined) entry.metadata_json = body.metadata_json;
|
||||
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && u.pathname.startsWith('/entries/')) {
|
||||
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
|
||||
const idx = entries.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return new Response(JSON.stringify({ success: false }), { status: 404 });
|
||||
entries.splice(idx, 1); // 真的從陣列移除,不是標記
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`unhandled KBDB call: ${method} ${url}`);
|
||||
}
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn((url: string, init?: RequestInit) => handle(url, init)));
|
||||
|
||||
return { entries, secretsStore, secretPuts, secretDeletes, kbdbRequests };
|
||||
}
|
||||
|
||||
describe('GET /credentials (D1, T9)', () => {
|
||||
beforeEach(clearTenantRows);
|
||||
function fakeEnv(): Bindings {
|
||||
return {
|
||||
KBDB_BASE_URL: 'https://kbdb.test',
|
||||
CF_SECRETS_API_TOKEN: 'fake-cf-token',
|
||||
CF_ACCOUNT_ID: 'fake-account',
|
||||
ENVIRONMENT: 'test',
|
||||
CREDENTIALS_KV: { delete: vi.fn(async () => {}) } as unknown as KVNamespace,
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
it('缺 X-Arcrun-API-Key → 401', async () => {
|
||||
const res = await SELF.fetch('https://cypher.test/credentials');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
function app() {
|
||||
const a = new Hono<{ Bindings: Bindings }>();
|
||||
a.route('/', credentialsRouter);
|
||||
return a;
|
||||
}
|
||||
|
||||
it('無資料 → 空陣列(非拋錯)', async () => {
|
||||
const res = await SELF.fetch('https://cypher.test/credentials', {
|
||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
||||
});
|
||||
beforeEach(() => {
|
||||
invalidateCredentialCache('tenant-a');
|
||||
invalidateCredentialCache('tenant-b');
|
||||
});
|
||||
|
||||
describe('1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離)', () => {
|
||||
it('POST /credentials 寫入後,entries 裡的 owner_id 就是呼叫者的 api_key', async () => {
|
||||
const fake = makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
const res = await a.request('/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'telegram_bot_token', value: 'secret-plaintext-value', service: 'telegram' }),
|
||||
}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { success: boolean; credentials: unknown[]; total: number };
|
||||
const body = (await res.json()) as { success: boolean };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.credentials).toEqual([]);
|
||||
expect(body.total).toBe(0);
|
||||
expect(fake.entries).toHaveLength(1);
|
||||
expect(fake.entries[0].owner_id).toBe('tenant-a');
|
||||
expect(fake.entries[0].page_name).toBe('telegram_bot_token');
|
||||
});
|
||||
|
||||
it('回傳 metadata,絕不含 secret_ref 或值', async () => {
|
||||
await insertCredentialRow('telegram_bot_token', 'CRED_TELEGRAM_BOT_TOKEN_ABCDEF01', { service: 'telegram' });
|
||||
const res = await SELF.fetch('https://cypher.test/credentials', {
|
||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
||||
});
|
||||
const body = await res.json() as { success: boolean; credentials: Array<Record<string, unknown>> };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.credentials).toHaveLength(1);
|
||||
const row = body.credentials[0];
|
||||
expect(row.name).toBe('telegram_bot_token');
|
||||
expect(row.service).toBe('telegram');
|
||||
expect(row).not.toHaveProperty('secret_ref');
|
||||
expect(row).not.toHaveProperty('value');
|
||||
expect(JSON.stringify(row)).not.toMatch(/CRED_/);
|
||||
});
|
||||
|
||||
it('/credentials/catalog 回同一份資料(Console 相容別名)', async () => {
|
||||
await insertCredentialRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01');
|
||||
const [listRes, catalogRes] = await Promise.all([
|
||||
SELF.fetch('https://cypher.test/credentials', { headers: { 'X-Arcrun-API-Key': API_KEY } }),
|
||||
SELF.fetch('https://cypher.test/credentials/catalog', { headers: { 'X-Arcrun-API-Key': API_KEY } }),
|
||||
]);
|
||||
const [listBody, catalogBody] = await Promise.all([listRes.json(), catalogRes.json()]) as Array<{
|
||||
credentials: Array<{ name: string }>;
|
||||
}>;
|
||||
expect(listBody.credentials.map(r => r.name)).toEqual(catalogBody.credentials.map(r => r.name));
|
||||
it('兩個不同 api_key 各自建立的同名 credential 落在不同 owner_id、互不覆蓋', async () => {
|
||||
const fake = makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
|
||||
}, env);
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-b' }),
|
||||
}, env);
|
||||
expect(fake.entries).toHaveLength(2);
|
||||
const owners = fake.entries.map((e) => e.owner_id).sort();
|
||||
expect(owners).toEqual(['tenant-a', 'tenant-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /credentials/:name (T9)', () => {
|
||||
beforeEach(clearTenantRows);
|
||||
describe('2. 讀取查得回 secret_ref,且查不到別的租戶的', () => {
|
||||
it('getCredentialSecretRefs 回該租戶的 name→secret_ref 對照,不含其他租戶的', async () => {
|
||||
makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
|
||||
}, env);
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'other_key', value: 'value-b' }),
|
||||
}, env);
|
||||
|
||||
it('D1 無 row(從未回填)→ fallback 刪舊 KV,不誤報找不到', async () => {
|
||||
await env.CREDENTIALS_KV.put(
|
||||
`${API_KEY}:cred:legacy_only`,
|
||||
JSON.stringify({ encrypted: 'x', iv: 'y' }),
|
||||
);
|
||||
const res = await SELF.fetch('https://cypher.test/credentials/legacy_only', {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Arcrun-API-Key': API_KEY },
|
||||
});
|
||||
const body = await res.json() as { success: boolean; source: string };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.source).toBe('legacy-kv');
|
||||
const raw = await env.CREDENTIALS_KV.get(`${API_KEY}:cred:legacy_only`);
|
||||
expect(raw).toBeNull();
|
||||
const refsA = await getCredentialSecretRefs(env, 'tenant-a');
|
||||
expect(Object.keys(refsA)).toEqual(['gemini_api_key']);
|
||||
expect(refsA.gemini_api_key).toMatch(/^CRED_GEMINI_API_KEY_/);
|
||||
expect(refsA.other_key).toBeUndefined(); // 查不到別租戶的
|
||||
|
||||
const refsB = await getCredentialSecretRefs(env, 'tenant-b');
|
||||
expect(Object.keys(refsB)).toEqual(['other_key']);
|
||||
});
|
||||
|
||||
it('hasCredential:查得到自己的,查不到別租戶的同名 credential', async () => {
|
||||
makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'kbdb_internal_token', value: 'v' }),
|
||||
}, env);
|
||||
expect(await hasCredential(env, 'tenant-a', 'kbdb_internal_token')).toBe(true);
|
||||
expect(await hasCredential(env, 'tenant-b', 'kbdb_internal_token')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 刪除是真的刪(不是 deprecated 標記)', () => {
|
||||
it('DELETE /credentials/:name 後,該筆 entries row 從 KBDB 消失(不是 metadata 打 deprecated 標記)', async () => {
|
||||
const fake = makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'to_delete', value: 'v' }),
|
||||
}, env);
|
||||
expect(fake.entries).toHaveLength(1);
|
||||
|
||||
const res = await a.request('/credentials/to_delete', {
|
||||
method: 'DELETE', headers: { 'X-Arcrun-API-Key': 'tenant-a' },
|
||||
}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { success: boolean; source: string };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.source).toBe('workers-secrets');
|
||||
|
||||
// 真的從陣列移除,不是留著、metadata 打上 status:deprecated
|
||||
expect(fake.entries).toHaveLength(0);
|
||||
// Workers Secret 本體也真的被刪(DELETE 呼叫過),不是只刪目錄留孤兒密文
|
||||
expect(fake.secretDeletes.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 零原生 SQL:整支檔案不得出現 .prepare/.exec/.batch', () => {
|
||||
it('routes/credentials.ts 原始碼掃描:沒有任何 D1 原生呼叫語法', () => {
|
||||
expect(/\.\s*(prepare|exec|batch)\s*\(/.test(credentialsSource)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變', () => {
|
||||
it('送去 KBDB 的 body 裡從頭到尾沒有明文 credential value,只有 secret_ref', async () => {
|
||||
const fake = makeFakeKbdb();
|
||||
const env = fakeEnv();
|
||||
const a = app();
|
||||
const plaintext = 'super-secret-plaintext-should-never-leave-workers-secrets';
|
||||
await a.request('/credentials', {
|
||||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'sensitive_key', value: plaintext }),
|
||||
}, env);
|
||||
|
||||
// 明文只出現在 CF Workers Secrets 的 PUT(唯寫 API),不出現在任何打去 KBDB 的請求 body 裡
|
||||
expect(fake.secretPuts.some((p) => p.text === plaintext)).toBe(true);
|
||||
for (const req of fake.kbdbRequests) {
|
||||
expect(JSON.stringify(req.body ?? '')).not.toContain(plaintext);
|
||||
}
|
||||
// entries 裡存的是 secret_ref 指標,不是值
|
||||
expect(fake.entries[0].metadata_json).not.toContain(plaintext);
|
||||
expect(fake.entries[0].metadata_json).toContain('secret_ref');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* execution-logger 測試(KV 額度事故修復,2026-08-07)
|
||||
*
|
||||
* 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`);
|
||||
});
|
||||
});
|
||||
@@ -250,3 +250,84 @@ describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () =>
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// P8 短板齊平(2026-08-09):節點輸出只在「下游有 PIPE 邊會讀」時才寫 KV。
|
||||
// 背景:BUILD-006 原本每個節點(含 FOREACH 每一圈)都 put 一次 EXEC_CONTEXT,
|
||||
// 但全 codebase 唯一讀點是 PIPE 邊的 kvGetNodeOutput——rag 系工作流
|
||||
// (ON_SUCCESS+對每個)一張卡白燒 15 次 KV write,把免費層 1,000/日
|
||||
// 壓成比 Workers AI neurons 更短的板。此測試鎖住「無 PIPE 出邊=零 KV put」
|
||||
// 與「有 PIPE 出邊=照舊寫、_kv_outputs 照舊可讀」兩個行為。
|
||||
describe('P8:節點輸出 KV 寫入只服務 PIPE 讀者', () => {
|
||||
// 計數型 KV mock:只記 put 次數(kvSetNodeOutput 只用到 put;get 給 PIPE 讀)
|
||||
function countingKv() {
|
||||
const store = new Map<string, string>();
|
||||
let puts = 0;
|
||||
const kv = {
|
||||
put: async (k: string, v: string) => { puts++; store.set(k, v); },
|
||||
get: async (k: string) => store.get(k) ?? null,
|
||||
} as unknown as KVNamespace;
|
||||
return { kv, getPuts: () => puts };
|
||||
}
|
||||
|
||||
it('ON_SUCCESS+FOREACH 工作流(rag_ingest_card 形狀)→ 零 KV put', async () => {
|
||||
const loader = async (id: string): Promise<ComponentRunner> => async () => {
|
||||
if (id === 'parse') {
|
||||
return { success: true, blocks: [{ n: 1 }, { n: 2 }, { n: 3 }], rels: [{ r: 1 }, { r: 2 }] };
|
||||
}
|
||||
return { success: true, data: { ok: true } };
|
||||
};
|
||||
const executor = new GraphExecutor(loader);
|
||||
const graph: ExecutionGraph = {
|
||||
id: 'p8-no-pipe',
|
||||
name: 'rag 形狀(無 PIPE 邊)',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: {} },
|
||||
{ id: 'list_old', type: 'Component', componentId: 'http_request' },
|
||||
{ id: 'parse_card', type: 'Component', componentId: 'parse' },
|
||||
{ id: 'post_block', type: 'Component', componentId: 'http_request' },
|
||||
{ id: 'post_triplet', type: 'Component', componentId: 'http_request' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'list_old', type: 'ON_SUCCESS' },
|
||||
{ from: 'list_old', to: 'parse_card', type: 'ON_SUCCESS' },
|
||||
{ from: 'parse_card', to: 'post_block', type: 'FOREACH', iterator: 'block' },
|
||||
{ from: 'parse_card', to: 'post_triplet', type: 'FOREACH', iterator: 'rel' },
|
||||
],
|
||||
};
|
||||
const { kv, getPuts } = countingKv();
|
||||
const result = await executor.execute(graph, {}, kv);
|
||||
expect(result).toBeDefined();
|
||||
// 修法前這裡是 8(list_old + parse_card + 3×post_block + 2×post_triplet + input 不寫)
|
||||
expect(getPuts()).toBe(0);
|
||||
});
|
||||
|
||||
it('PIPE 工作流 → 照舊寫 KV 且 _kv_outputs 傳遞不變(BUILD-006 語意保留)', async () => {
|
||||
const seen: Record<string, unknown>[] = [];
|
||||
const loader = async (id: string): Promise<ComponentRunner> => async (ctx) => {
|
||||
seen.push(ctx as Record<string, unknown>);
|
||||
return { success: true, data: { from: id } };
|
||||
};
|
||||
const executor = new GraphExecutor(loader);
|
||||
const graph: ExecutionGraph = {
|
||||
id: 'p8-pipe',
|
||||
name: 'PIPE 鏈',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { message: 'hi' } },
|
||||
{ id: 'a', type: 'Component', componentId: 'comp_a' },
|
||||
{ id: 'b', type: 'Component', componentId: 'comp_b' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'a', type: 'PIPE' },
|
||||
{ from: 'a', to: 'b', type: 'PIPE' },
|
||||
],
|
||||
};
|
||||
const { kv, getPuts } = countingKv();
|
||||
const result = await executor.execute(graph, {}, kv);
|
||||
expect(result).toBeDefined();
|
||||
// a 有 PIPE 出邊 → 寫;b 沒有出邊 → 不寫(原本 a、b 都寫=2)
|
||||
expect(getPuts()).toBe(1);
|
||||
// 下游 b 收到的 context 帶 _kv_outputs.a(BUILD-006 讀路徑不變)
|
||||
const bCtx = seen[seen.length - 1];
|
||||
expect((bCtx._kv_outputs as Record<string, unknown>)?.a).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* 覆蓋(=tasks.md P4+總管派工驗收重點):
|
||||
* 1. **最後一個 active admin 鎖死保護**:停用 → 409;降級 role=user → 409;
|
||||
* 「還有另一個 active admin」才放行;另一個 admin 是 disabled 不算數。
|
||||
* 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落 KBDB
|
||||
* (庫裡只有 pbkdf2 hash);自帶密碼 → 回應無 generated_password。
|
||||
* 3. reset-password:回一次性新密碼;PATCH 進 KBDB 的是 hash 非明碼。
|
||||
* 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落地
|
||||
* (新家只有 pbkdf2 hash,D61 起帳號建立走認證儲存不再落 KBDB);自帶密碼 → 回應無 generated_password。
|
||||
* 3. reset-password:回一次性新密碼;PATCH 落地的是 hash 非明碼(目標帳號沿用舊家 fixture,
|
||||
* 仍走 KBDB PATCH——見下方 mockPatchPrelude 的說明)。
|
||||
* 4. 庫權限:PATCH libraries=["*"](全庫)合法;空陣列/壞庫名 → 400。
|
||||
* 5. 庫目錄:POST 建庫寫 {tenant}::portal 子 namespace;PATCH graph_source boolean。
|
||||
* 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、
|
||||
@@ -14,12 +15,21 @@
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 fixture(rec_admin/rec_u1/rec_admin2…)
|
||||
* 全部沿用「record_id 不是 auth: 開頭」這個既有慣例——這正是 portal.ts 的相容分流點
|
||||
* (isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走原本的 KBDB 路徑,行為與 D61 之前
|
||||
* 完全一致,故本檔絕大多數測試不需要改。**只有「新建帳號」這個動作**(POST /portal/admin/users、
|
||||
* POST /portal/admin/bootstrap 走同一支 createPortalUser)改成寫進認證儲存(CF Workers
|
||||
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`(PUT)——見 mockAuthStoreWrite。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||
|
||||
let storedHash: string;
|
||||
@@ -39,6 +49,21 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
function mockHeadLookup(email: string, recordId: string | null) {
|
||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||
fetchMock
|
||||
@@ -177,23 +202,11 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id)', () => {
|
||||
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
|
||||
|
||||
describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼(D61)', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('new@example.com', null); // email 未占用
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: 'rec_new', template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: 'e_head' } });
|
||||
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
|
||||
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
@@ -205,26 +218,23 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
expect(typeof data.generated_password).toBe('string');
|
||||
expect(data.generated_password!.length).toBe(16);
|
||||
expect('password_hash' in data.user).toBe(false);
|
||||
// 一次性密碼不落庫:KBDB 收到的 record body 只有 hash、無明碼
|
||||
expect(recordBody).not.toContain(data.generated_password!);
|
||||
const rec = JSON.parse(recordBody) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
|
||||
|
||||
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(data.generated_password!);
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const stored = shard.users.find((u) => u.email === 'new@example.com');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
});
|
||||
|
||||
it('自帶 password → 回應**無** generated_password', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('own@example.com', null);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, { success: true, record: { record_id: 'rec_own', template_id: 'tpl_pu', values: {} } });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: 'e_head2' } });
|
||||
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
|
||||
mockAuthStoreWrite();
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
@@ -263,6 +273,70 @@ describe('POST /portal/admin/users/:id/reset-password', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3.5 recover-password(arcrun-rag#25:admin 忘記 portal 密碼自救)═══════════════
|
||||
|
||||
describe('POST /portal/admin/recover-password', () => {
|
||||
it('無 console owner session → 401,不碰 KBDB', async () => {
|
||||
const res = await json('POST', '/portal/admin/recover-password', { email: 'admin@example.com' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('有 console session 但 email 格式不對 → 400,不碰 KBDB', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/recover-password',
|
||||
{ email: 'not-an-email' },
|
||||
{ Authorization: 'Bearer owner-token' },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('查無此 email 的 portal 帳號 → 404,不誤導成別種錯誤', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||
mockHeadLookup('ghost@example.com', null);
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/recover-password',
|
||||
{ email: 'ghost@example.com' },
|
||||
{ Authorization: 'Bearer owner-token' },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('console session 有效+帳號存在 → 回一次性新密碼;PATCH 落 KBDB 的是新 hash 非明碼;**不需要任何 portal session**', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||
// 刻意不 seedAdminSession():這條路唯一該吃的是 console session,機械證明繞得過
|
||||
// 「忘記 portal 密碼 ⇒ 沒有 portal_sess ⇒ 打不進其他 admin 端點」這個死結。
|
||||
mockHeadLookup('admin@example.com', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
let patched = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/rec_admin', method: 'PATCH' })
|
||||
.reply(200, (opts) => {
|
||||
patched = String(opts.body);
|
||||
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: adminValues() } };
|
||||
});
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/recover-password',
|
||||
{ email: 'Admin@Example.com' }, // 混寫大小寫,驗證正規化成小寫再查
|
||||
{ Authorization: 'Bearer owner-token' },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; email: string; password: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.email).toBe('admin@example.com');
|
||||
expect(typeof data.password).toBe('string');
|
||||
expect(data.password.length).toBe(16);
|
||||
expect(patched).not.toContain(data.password); // 明碼不落 KBDB
|
||||
const sent = JSON.parse(patched) as { values: Record<string, string> };
|
||||
expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(sent.values.password_hash).not.toBe(storedHash); // 真的換了
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. 庫權限勾選(libraries PATCH)═══════════════
|
||||
|
||||
describe('PATCH libraries(每帳號可查庫)', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆蓋(=tasks.md P2 測試項):
|
||||
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
||||
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫 {tenant}::portal 子 namespace;
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進**認證儲存**(D61);
|
||||
* 已有 admin → 409
|
||||
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
||||
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
||||
@@ -12,16 +12,28 @@
|
||||
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
||||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55)補的覆蓋(原本沒有,這次變更的重點):
|
||||
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
|
||||
* 9. 舊實例相容:帳號只存在 KBDB(舊家)時仍登得進去,登入成功後自動搬進認證儲存
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
|
||||
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
|
||||
*
|
||||
* D61 起,帳號的家從 KBDB 換成認證儲存(CF Workers Secrets)——寫入會呼叫
|
||||
* `https://api.cloudflare.com/.../secrets`(PUT),同樣走 fetchMock 假 host 攔截,不外連。
|
||||
* wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒(比照真實裝妥的實例)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
import { portalRouter } from '../src/routes/portal';
|
||||
import type { Bindings, ExecutionContext } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||
const EMAIL = 'user@example.com';
|
||||
const PASSWORD = 'correct-horse-9';
|
||||
@@ -44,6 +56,34 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:認證儲存的寫入路徑(單元測試層級——一次呼叫=一片,測試資料量小不會觸發溢位分片)。
|
||||
* 攔截 CF Workers Scripts secrets 管理 API 的 PUT,捕捉 body 供斷言(片名/內容)。
|
||||
* 用法:每個會觸發寫入的測試呼叫一次,回傳的 `puts()` 拿到依序捕捉到的 {name, text}[]。
|
||||
*
|
||||
* ⚠️ 讀路徑沒有對應的「seed 進 env」捷徑可用:`cloudflare:test` 的 `env` 物件是傳給
|
||||
* `vitest` 主 context 用的,對 `SELF.fetch()` 打的那個 worker isolate **不生效**(實測驗證,
|
||||
* mutate `env.XXX` 後 SELF 端讀到的仍是 wrangler.test.toml 的原值)。因此「舊實例相容」
|
||||
* 一類的讀路徑測試,一律靠**既有的 KBDB fetchMock**(新家預設空,天然等於「帳號只在舊家」);
|
||||
* 要驗證「新家已經有資料」則靠**真的呼叫一次寫入端點**(bootstrap/新增同仁),讓 portal-auth-store
|
||||
* 模組內的 per-isolate overlay 落地——這個 overlay 在同一支測試檔案裡的後續測試\*也讀得到\*
|
||||
* (模組級全域變數不隨 test 重置,只有 KV/D1 等 storage 才有 isolatedStorage 重置),
|
||||
* 這是刻意善用而非意外:想要「乾淨無帳號」的情境,該測試必須排在檔案裡**第一個寫入動作之前**。
|
||||
*/
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
// ── KBDB mock helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1) */
|
||||
@@ -134,6 +174,33 @@ describe('PBKDF2 模組(lib/portal-auth)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 1.5 D61:整台實例沒有任何認證資料 ═══════════════
|
||||
//
|
||||
// 🔴 這個 describe 必須留在檔案裡「第一個會寫入認證儲存的測試」之前(下面 2. bootstrap
|
||||
// 的「console session OK」那則)——見 mockAuthStoreWrite 檔頭註解:portal-auth-store.ts
|
||||
// 的 per-isolate overlay 是模組級全域變數,同一支測試檔案跑起來不會在測試之間重置,
|
||||
// 一旦有測試寫入過,後面的測試都會看到那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||
describe('D61:整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
|
||||
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前);
|
||||
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
|
||||
// 才是「這台實例真的沒有認證資料」。
|
||||
mockHeadLookup('anyone@example.com', null);
|
||||
mockListByTemplate('portal_user', []);
|
||||
const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(503);
|
||||
const data = (await res.json()) as { error: string; code: string; auth_store: { present: boolean; users: number } };
|
||||
expect(data.code).toBe('auth_store_empty');
|
||||
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
|
||||
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
|
||||
expect(data.error).toContain('不是密碼錯');
|
||||
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
|
||||
expect(data.auth_store.users).toBe(0);
|
||||
// 不計入鎖定:lockfail 計數器完全沒被寫入
|
||||
expect(await env.SESSIONS_KV.get('portal_lockfail:anyone@example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. bootstrap 閘 ═══════════════
|
||||
|
||||
describe('POST /portal/admin/bootstrap', () => {
|
||||
@@ -142,28 +209,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('console session OK → 建第一個 admin:record + head entry 都寫 {tenant}::portal 子 namespace', async () => {
|
||||
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB)', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用
|
||||
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
let headBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
headBody = String(opts.body);
|
||||
return { success: true, entry: { id: 'e_head' } };
|
||||
});
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin(新家空,舊家也空)
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
|
||||
const res = await json(
|
||||
'POST',
|
||||
@@ -174,23 +225,25 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.record_id).toBe('rec_admin');
|
||||
expect(typeof data.record_id).toBe('string');
|
||||
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61)
|
||||
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1)
|
||||
|
||||
const rec = JSON.parse(recordBody) as { owner_id: string; values: Record<string, string>; template: string };
|
||||
expect(rec.template).toBe('portal_user');
|
||||
expect(rec.owner_id).toBe(NS); // ← D-2 子 namespace 機械斷言
|
||||
expect(rec.values.role).toBe('admin');
|
||||
expect(rec.values.status).toBe('active');
|
||||
expect(rec.values.libraries).toBe('["*"]');
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(recordBody).not.toContain('bootstrap-pw-1'); // 明碼絕不落 KBDB
|
||||
|
||||
const head = JSON.parse(headBody) as Record<string, string>;
|
||||
expect(head.owner_id).toBe(NS);
|
||||
expect(head.entry_type).toBe('portal_user');
|
||||
expect(head.page_name).toBe('admin@example.com');
|
||||
expect(head.content).toBe('rec_admin');
|
||||
// D61:一次寫入=一片,落進認證儲存(Workers Secrets),不再有 KBDB record/head entry
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as {
|
||||
users: Array<{ email: string; role: string; status: string; libraries: string[]; password_hash: string }>;
|
||||
};
|
||||
expect(shard.users.length).toBe(1);
|
||||
const stored = shard.users[0];
|
||||
expect(stored.email).toBe('admin@example.com');
|
||||
expect(stored.role).toBe('admin');
|
||||
expect(stored.status).toBe('active');
|
||||
expect(stored.libraries).toEqual(['*']);
|
||||
expect(stored.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(shards[0].text).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||
});
|
||||
|
||||
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
|
||||
@@ -210,6 +263,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
// ═══════════════ 3. 登入對錯 ═══════════════
|
||||
|
||||
describe('POST /portal/login', () => {
|
||||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
|
||||
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay,
|
||||
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
|
||||
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌。
|
||||
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
|
||||
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||
it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => {
|
||||
mockHeadLookup(EMAIL, 'rec_1');
|
||||
mockGetRecord('rec_1', activeUserValues());
|
||||
@@ -227,6 +286,11 @@ describe('POST /portal/login', () => {
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
expect(sess).toBeTruthy();
|
||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
|
||||
// D61:promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mock,disableNetConnect 之下
|
||||
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
|
||||
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
|
||||
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
|
||||
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
|
||||
});
|
||||
|
||||
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
|
||||
@@ -464,3 +528,57 @@ describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,e
|
||||
expect(data.portal_templates.existing).not.toContain('triplet');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ D61:舊實例登入自癒(搬進新家)═══════════════
|
||||
//
|
||||
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
|
||||
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
|
||||
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||
// 不會有更後面的測試共用這個 email 而被污染。
|
||||
describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
|
||||
const LEGACY_EMAIL = 'legacy-promote@example.com';
|
||||
|
||||
it('登入成功;promoteLegacyUser 把這筆帳號寫進認證儲存(一片、含正確 email/hash)', async () => {
|
||||
mockHeadLookup(LEGACY_EMAIL, 'rec_legacy_1');
|
||||
mockGetRecord('rec_legacy_1', activeUserValues({ email: LEGACY_EMAIL }));
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
|
||||
const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const promoted = shard.users.find((u) => u.email === LEGACY_EMAIL);
|
||||
expect(promoted).toBeDefined();
|
||||
expect(promoted!.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||
});
|
||||
|
||||
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
|
||||
// 直接呼叫 router、帶一份缺寫入路徑的 env(health.test.ts 已有的直呼叫慣例),
|
||||
// 證明 promoteLegacyUser 的失敗被 best-effort 吞掉,不影響登入本身。
|
||||
const email = 'legacy-promote-writeless@example.com';
|
||||
mockHeadLookup(email, 'rec_legacy_2');
|
||||
mockGetRecord('rec_legacy_2', activeUserValues({ email }));
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||
const res = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: PASSWORD }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
|
||||
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
|
||||
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
|
||||
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,8 +297,12 @@ describe('GET /portal/data/workflows(D-8:admin 唯讀)', () => {
|
||||
`${TENANT}:wf:daily_report`,
|
||||
JSON.stringify({ description: '每日彙整', created_at: '2026-07-14T00:00:00Z', cron_expr: '0 9 * * *' }),
|
||||
);
|
||||
await env.ANALYTICS_KV.put('stats:daily_report:1783500000000', JSON.stringify({ verdict: 'success' }));
|
||||
await env.ANALYTICS_KV.put('stats:daily_report:1783400000000', JSON.stringify({ verdict: 'failed' }));
|
||||
// KV 額度事故修復(2026-08-07):last_execution 資料源改打 KBDB GET /execution-log/latest
|
||||
// (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' });
|
||||
expect(res.status).toBe(200);
|
||||
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');
|
||||
expect(wf).toBeTruthy();
|
||||
expect(wf!.description).toBe('每日彙整');
|
||||
expect(wf!.last_execution?.verdict).toBe('success'); // 取到「最新」那筆(timestamp 較大者)
|
||||
expect(wf!.last_execution?.verdict).toBe('success');
|
||||
expect(JSON.stringify(data)).not.toContain('webhook_url');
|
||||
expect(JSON.stringify(data)).not.toContain('/trigger');
|
||||
// 清場(KV 是 suite 共用實例,避免污染其他測試)
|
||||
await env.WEBHOOKS.delete(`${TENANT}:wf:daily_report`);
|
||||
await env.ANALYTICS_KV.delete('stats:daily_report:1783500000000');
|
||||
await env.ANALYTICS_KV.delete('stats:daily_report:1783400000000');
|
||||
});
|
||||
|
||||
it('workflowsVisible 單元:admin(預設/壞值)/ all / off', () => {
|
||||
@@ -709,3 +711,234 @@ describe('dedupeSourcesByPage(t129 出處去重)', () => {
|
||||
expect(out.length).toBe(1); // 同 page_name → 合為一筆
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 7. GET /portal/data/diagnostics(檢修孔,2026-08-07) ═══════════════
|
||||
|
||||
describe('GET /portal/data/diagnostics', () => {
|
||||
it('未登入 → 401,不碰 KBDB', async () => {
|
||||
const res = await get('/portal/data/diagnostics');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('登入 → 200,聚合 embed 健康狀態+規模統計(即時查,非 /map 快取)+版本;只含數字/布林/字串狀態', async () => {
|
||||
// 2026-08-08 修復對應測試:library_count/triplet_count 改走 listRecordsByTemplate(portal_library)
|
||||
// + /entries/libraries + /records/triplet-stats(與 GET /portal/admin/libraries 同一套即時查),
|
||||
// 不再靠 /map(library_map 快取,recompute 從未被呼叫,恆回空——這正是 08-07 leo 實測抓到的病根)。
|
||||
await seedSession('tok-diag1', 'rec_diag1');
|
||||
mockGetRecord('rec_diag1', userValues());
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: true, pending: 3, embedded: 80 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' });
|
||||
// 已登記庫:1 筆(kb),values 帶不該外流的內容欄位(display_name/description)驗紅線。
|
||||
mockLibraryList([
|
||||
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '不該出現在診斷檔', description: '密卡內容' } },
|
||||
]);
|
||||
// 資料裡實際蓋章出現過的庫:kb(與登記簿重複,去重)+notes(未登記但蓋章過,t52「蓋章即現身」)+general(fallback 桶,排除不算庫)。
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { success: true, libraries: ['general', 'kb', 'notes'], count: 3 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 40 }, { library: 'notes', triplet_count: 27 }] });
|
||||
|
||||
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag1' });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
library_count: number;
|
||||
triplet_count: number;
|
||||
library_scope_check: { ran: boolean };
|
||||
embedding: { module_enabled: boolean; cards_embedded: number; cards_pending: number; self_test: { ran: boolean; found_itself: boolean | null } };
|
||||
instance_url: string;
|
||||
bundle_version: string | null;
|
||||
};
|
||||
expect(body.library_count).toBe(2); // kb(登記簿+資料面重複,去重)+notes;general 不算
|
||||
expect(body.triplet_count).toBe(67); // 40+27,實際聚合 SQL 算出,非快取
|
||||
expect(body.library_scope_check.ran).toBe(false); // 數字不是 0,不需要自我探測
|
||||
expect(body.embedding.module_enabled).toBe(true);
|
||||
expect(body.embedding.cards_embedded).toBe(80);
|
||||
expect(body.embedding.cards_pending).toBe(3);
|
||||
expect(body.embedding.self_test.ran).toBe(true);
|
||||
expect(body.embedding.self_test.found_itself).toBe(false);
|
||||
expect(body.instance_url).toBe('http://localhost');
|
||||
// 紅線斷言:整份回應不含知識卡內容本體(登記簿 values 裡的 display_name/description 沒被轉發,只取了 name 算數)
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain('不該出現在診斷檔');
|
||||
expect(raw).not.toContain('密卡');
|
||||
expect(raw).not.toContain('"kb"'); // 連庫名本身都不外流,只回數字
|
||||
});
|
||||
|
||||
it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index;庫/三元組真的是空 → 自我探測也回空,不誤判為查詢錯誤', async () => {
|
||||
await seedSession('tok-diag2', 'rec_diag2');
|
||||
mockGetRecord('rec_diag2', userValues());
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
|
||||
mockLibraryList([]);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { success: true, libraries: [], count: 0 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [] });
|
||||
// library_count/triplet_count 都是 0 → 觸發自我探測;這裡探測也回真的空(total:0)。
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
|
||||
.reply(200, { success: true, entries: [], count: 0, total: 0 });
|
||||
|
||||
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
library_count: number;
|
||||
triplet_count: number;
|
||||
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
|
||||
embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } };
|
||||
};
|
||||
expect(body.library_count).toBe(0);
|
||||
expect(body.triplet_count).toBe(0);
|
||||
expect(body.library_scope_check.ran).toBe(true);
|
||||
expect(body.library_scope_check.any_entries_found).toBe(false);
|
||||
expect(body.embedding.module_enabled).toBe(false);
|
||||
expect(body.embedding.self_test.ran).toBe(false);
|
||||
expect(body.embedding.self_test.found_itself).toBeNull();
|
||||
});
|
||||
|
||||
it('統計自我檢查抓到 t161 同型病:庫/三元組回 0,但這個租戶底下其實查得到其他資料 → 標「像是查詢方式或租戶對不上」而非誤判成真的沒有資料', async () => {
|
||||
await seedSession('tok-diag3', 'rec_diag3');
|
||||
mockGetRecord('rec_diag3', userValues());
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
|
||||
mockLibraryList([]);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { success: true, libraries: [], count: 0 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [] });
|
||||
// 自我探測:這個租戶底下其實有 12 筆 entries——庫/三元組統計卻回 0,兩者矛盾,該被標記。
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
|
||||
.reply(200, { success: true, entries: [{ id: 'e1' }], count: 1, total: 12 });
|
||||
|
||||
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag3' });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
library_count: number;
|
||||
triplet_count: number;
|
||||
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
|
||||
};
|
||||
expect(body.library_count).toBe(0);
|
||||
expect(body.triplet_count).toBe(0);
|
||||
expect(body.library_scope_check.ran).toBe(true);
|
||||
expect(body.library_scope_check.any_entries_found).toBe(true);
|
||||
expect(body.library_scope_check.note).toContain('查詢方式或租戶對不上');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════ 8. GET /portal/daemon/diagnostics(t213,daemon 免帳密版檢修孔,2026-08-08) ═══════════
|
||||
//
|
||||
// 與上面 /portal/data/diagnostics 共用同一個 buildDiagnostics()(portal.ts)——這裡只驗證
|
||||
// ①認證換了一套(X-Arcrun-API-Key,非 session)②apiKey 當 owner_id 打 KBDB,不與
|
||||
// portalTenant(env)(='leo',見上方 TENANT 常數)比對/不要求相等(t189 教訓)③回應形狀
|
||||
// 與 session 版一致。核心查詢邏輯已在上面 7 組測試驗過,這裡不重複。
|
||||
describe('GET /portal/daemon/diagnostics(t213 daemon 版)', () => {
|
||||
it('沒帶 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||
const res = await get('/portal/daemon/diagnostics');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('帶 key(刻意與 CONSOLE_TENANT="leo" 不同)→ 200,且 KBDB 查詢用的 owner_id 是這把 key 本身,不是 leo(t189:不假設 apiKey===portalTenant)', async () => {
|
||||
const daemonKey = 'yuga3bse'; // 刻意選一個跟 TENANT('leo') 不同的值,比照 t189 geek6688 案例
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: true, pending: 2, embedded: 9 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/selftest') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: true, tested: true, passed: true, note: '' });
|
||||
mockLibraryList([{ record_id: 'rec_lib_kb2', values: { name: 'kb' } }]);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
|
||||
.reply(200, { success: true, libraries: ['general', 'kb'], count: 2 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 9 }] });
|
||||
|
||||
const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
library_count: number;
|
||||
triplet_count: number;
|
||||
embedding: { module_enabled: boolean; cards_embedded: number };
|
||||
instance_url: string;
|
||||
bundle_version: string | null;
|
||||
};
|
||||
expect(body.library_count).toBe(1);
|
||||
expect(body.triplet_count).toBe(9);
|
||||
expect(body.embedding.module_enabled).toBe(true);
|
||||
expect(body.embedding.cards_embedded).toBe(9);
|
||||
expect(body.instance_url).toBe('http://localhost');
|
||||
// 沒有任何 session 檢查——不打 SESSIONS_KV/portal_user record(本測試從未 seedSession/mockGetRecord
|
||||
// 仍然 200,證明這條路徑真的不吃 session)。
|
||||
});
|
||||
|
||||
it('回應形狀與 session 版一致(同一組欄位名)', async () => {
|
||||
const daemonKey = 'shape-check-key';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: '' });
|
||||
mockLibraryList([]);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { success: true, libraries: [], count: 0 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
|
||||
.reply(200, { success: true, entries: [], count: 0, total: 0 });
|
||||
|
||||
const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(Object.keys(body).sort()).toEqual(
|
||||
['generated_at', 'instance_url', 'bundle_version', 'library_count', 'triplet_count', 'library_scope_check', 'embedding', 'notes'].sort(),
|
||||
);
|
||||
// t213 leo 08-08 指令:舊的「需在失敗當下截圖」那句已刪,notes 不該再含這句話。
|
||||
expect(JSON.stringify(body.notes)).not.toContain('截圖');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,3 +49,11 @@ KBDB_BASE_URL = "https://kbdb.test"
|
||||
CONSOLE_TENANT = "leo"
|
||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||
KBDB_GRAPH_URL = "https://graph.test"
|
||||
# D61(ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
|
||||
# Scripts secrets 管理 API(https://api.cloudflare.com/...),authStoreWritable() 只看這兩項
|
||||
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
|
||||
# PUT/DELETE 呼叫一律靠 tests/*.ts 裡的 fetchMock 攔截,不外連。要測「寫入路徑未就緒」
|
||||
# 的分支才需要繞過 SELF、直接呼叫 router.fetch(req, fakeEnv, ctx) 帶缺項的 env(見
|
||||
# tests/health.test.ts 既有前例)。
|
||||
CF_SECRETS_API_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,見上方註解
|
||||
CF_ACCOUNT_ID = "test-account"
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
-- 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
|
||||
--
|
||||
-- ⚠️ 已退役(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 唯寫)。
|
||||
-- 這張表只存「目錄」:租戶(api_key) / 名字 / 服務 / 敏感度 / 指向 Workers Secrets 的 env var 名(secret_ref)。
|
||||
-- 冪等(IF NOT EXISTS),與 0001_base.sql 同模式,套用機制走 cli/src/lib/deploy.ts applyD1Migration。
|
||||
-- 同一顆 D1(與 KBDB base 共用 arcrun-kbdb),不新建第二顆。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 已退役的歷史存底,deploy.ts 不再套用本檔(改跑 0005+0006),保留純供欄位對照
|
||||
api_key TEXT NOT NULL, -- 租戶
|
||||
name TEXT NOT NULL, -- credential 名(= auth-recipe required_secrets[].key,如 telegram_bot_token)
|
||||
service TEXT, -- 對應 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);
|
||||
}
|
||||
@@ -93,7 +93,12 @@ export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Pr
|
||||
const offset = f.offset ?? 0;
|
||||
const [rowsRes, countRow] = await Promise.all([
|
||||
db
|
||||
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
||||
// `, rowid DESC` 二級排序(KV 額度事故修復,2026-08-07 發現):created_at 是
|
||||
// unixepoch()=秒級解析度,高頻寫入(例如 execution_log 一秒內多筆執行)常同秒,
|
||||
// 單靠 created_at DESC 的同分排序不保證插入序,「最新一筆」可能取到錯的一列。
|
||||
// rowid 是 SQLite/D1 一般表的隱含遞增欄,同分時退回插入序,不改變既有排序結果
|
||||
// (created_at 不同時完全一字不變),純粹補上同分時的決定性。
|
||||
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`)
|
||||
.bind(...params, limit, offset)
|
||||
.all<Entry>(),
|
||||
db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first<{ total: number }>(),
|
||||
@@ -134,6 +139,37 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
* 沿用既有 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(
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
// map block 的 content=可嵌人話(design §5:之後 M6 semantic 路由第一跳直接嵌這句做庫路由)。
|
||||
const narrative = input.narrative?.trim() || '';
|
||||
// narrative:caller 有給才覆蓋;沒給 → 沿用上一版現有 narrative(若有)。
|
||||
// 2026-08-08 修正:這欄原本「沒給就清空」,會被下面新增的即時新鮮度層
|
||||
// (ensureFreshLibraryMaps,讀端自動重算、天生不帶 narrative)每次呼叫都靜默洗掉
|
||||
// ingest 端/人工填過的 narrative——沒給值=維持現狀,不是重置成空字串。
|
||||
let narrative = input.narrative?.trim();
|
||||
if (!narrative) {
|
||||
const prev = await getLibraryMapDetail(db, library, owner);
|
||||
narrative = prev?.narrative?.trim() || '';
|
||||
}
|
||||
const coreNames = topEntities.slice(0, 3).map((t) => t.name);
|
||||
const content = `${library}:${narrative || '(narrative 待 ingest 補寫)'}。核心:${
|
||||
coreNames.length ? coreNames.join('、') : '(尚無 entities)'
|
||||
@@ -297,6 +305,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) ----
|
||||
|
||||
interface MapPivotRow {
|
||||
|
||||
+182
-9
@@ -13,18 +13,93 @@
|
||||
|
||||
import type { Bindings, Entry } from './types';
|
||||
|
||||
const EMBED_MODEL = '@cf/baai/bge-base-en-v1.5'; // 768-dim,與 Vectorize index dimensions=768 對齊
|
||||
// ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)────────────────
|
||||
//
|
||||
// 2026-08-03 換代:`@cf/baai/bge-base-en-v1.5`(768-dim)→ `@cf/baai/bge-m3`(1024-dim)。
|
||||
//
|
||||
// 為什麼換(實測,不是憑感覺):舊模型是**英文模型**,拿來嵌中文等於嵌一堆看不懂的 token。
|
||||
// 用 5 組中文問答測資(每組 1 問 + 2 段相關 + 3 段無關,無關的刻意放同一知識庫裡的其他主題),
|
||||
// 算 margin = min(相關分數) − max(無關分數),margin ≤ 0 代表**排序是錯的**:
|
||||
// @cf/baai/bge-base-en-v1.5 768 排序正確 2/5 平均 margin -0.0413 1660 ms ← 舊
|
||||
// @cf/google/embeddinggemma-300m 768 4/5 +0.1275 1174 ms
|
||||
// @cf/baai/bge-m3 1024 **5/5** **+0.1410** 959 ms ← 新(品質最好且最快)
|
||||
// @cf/qwen/qwen3-embedding-0.6b 1024 4/5 +0.1381 3238 ms
|
||||
// 舊模型最刺眼的一組:問「知識庫問答為什麼要標出處?」→「**會議室預約規則**」0.7789
|
||||
// 竟然高於真正相關的 0.7306。這正是 leo 2026-07-18 回報的「問 RAG 卻引用會議室規範」。
|
||||
//
|
||||
// 🔴 換模型=**必須換 Vectorize index**,兩個理由:
|
||||
// ① 維度不同(768→1024),舊 index 收不進新向量;
|
||||
// ② 就算維度相同也不能沿用——不同模型的向量混在同一個 index,比對出來是垃圾,
|
||||
// 而 Arcrun#58(Vectorize vector delete 未接)代表舊向量**刪不掉**。
|
||||
// ⇒ 開新 index 反而順手繞開 #58:新 index 天生乾淨,舊的整個丟掉。
|
||||
//
|
||||
// 換代步驟(installer 已把新 index 名與維度對齊):建新 index → 重新部署 kbdb(binding 指新 index)
|
||||
// → 打 backfill 的 `reindex=true`(把 embed=1 的既有 entry 全部重嵌)→ 舊 index 可刪。
|
||||
const DEFAULT_EMBED_MODEL = '@cf/baai/bge-m3'; // 1024-dim,與 Vectorize index dimensions=1024 對齊
|
||||
|
||||
// 🔴 2026-08-05 leo 實撞:換 bge-m3 後**語義搜尋全 0 命中**(新上傳的檔搜不到、舊檔偶爾才中)。
|
||||
// 根因不在向量——實測 Vectorize 端排序完全正確(搜「閉環機」,目標檔穩坐 1-4 名)——
|
||||
// 而在**分數閾值是綁在舊模型的分數尺度上的**:
|
||||
// 舊 bge-base-en-v1.5:中文分數全擠 0.65-0.90(沒區辨力)⇒ t183 取 0.75 砍雜訊,對
|
||||
// 新 bge-m3 :分數尺度整體下移(相關 0.5-0.85、雜訊 0.4 上下)⇒ 0.75 砍掉的是**正解**
|
||||
// youlin 實例實測分布(bge-m3,08-05,直打 Vectorize query):
|
||||
// 「閉環機」 0.638 / 0.603 / 0.588 / 0.552 ← 全是目標檔,**全被 0.75 砍光**
|
||||
// ────── 斷崖 ────── 0.446 以下才是雜訊
|
||||
// 「火星座標 奧林帕斯山」 0.750…0.500 全是火星座標,0.475 以下才是雜訊
|
||||
// 「人力媒合系統規劃書」 0.842 ← 僥倖 >0.75 存活。**這就是 leo 看到「舊檔中、新檔不中」的由來**
|
||||
// ⇒ 斷崖普遍落在 0.5 附近,取 **0.5**:相關的全留、雜訊仍砍。
|
||||
// 誠實 trade-off:0.5 不是每個查詢都乾淨(實測「AI 上課名冊」0.658 的 ax-academy 會擠進來),
|
||||
// 但「偶有雜訊」遠優於「什麼都搜不到」——後者是現在的狀態。
|
||||
//
|
||||
// 🔴 為什麼閾值住在這裡(而不是 portal):它是**模型的性質**,不是頁面的偏好。
|
||||
// 原本硬寫在 `cypher-executor/src/routes/portal-data.ts`,換模型時那裡沒人想到要改
|
||||
// ——這正是 bge-m3 換代「四處同步」清單漏掉的第五處。放在模型常數旁邊,
|
||||
// 下次換模型的人一定會看到它。**別再把數字複製回呼叫端。**
|
||||
// 🔴 2026-08-05 二修(leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關 ⇒「閾值設太寬?」——對):
|
||||
// **固定門檻兩頭都不對**,因為每個查詢的分數尺度不一樣:
|
||||
// 查詢 正解區間 雜訊起點
|
||||
// 關懷型 AI 0.645-0.770 0.547 ← 固定 0.5 會放進 6 筆雜訊
|
||||
// 閉環機 0.552-0.638 0.446 ← 固定 0.6 會把正解砍到剩 2/4(=今早那個 0 命中)
|
||||
// 人力媒合系統規劃書 0.842 0.550
|
||||
// ⇒ 改成**相對門檻**:跟著這次查詢的最高分走,取 `max(絕對下限, top × 比例)`。
|
||||
// 實測五組(上表+「閉環機是什麼」「火星座標 奧林帕斯山」):
|
||||
// 固定 0.5 → 正解全留,但混入 9 筆雜訊
|
||||
// 固定 0.6 → 雜訊 0,但「閉環機」兩組正解被砍到 2/4、1/4
|
||||
// 相對 → 四組雜訊 0 且正解全留;「火星座標」留 3/6
|
||||
// (被砍的是同一份檔的其他段落,使用者照樣找得到那份檔)
|
||||
// 絕對下限的作用:整批分數都很低時(查詢與知識庫無關),純比例會讓垃圾等比放行 ⇒ 兜底。
|
||||
const MIN_SCORE_ABS_FLOOR = 0.45;
|
||||
const MIN_SCORE_TOP_RATIO = 0.8;
|
||||
|
||||
/**
|
||||
* 相對門檻:由「這批結果的最高分」推出要砍在哪。
|
||||
*
|
||||
* 🔴 為什麼不在 semanticSearch 裡直接套(寫測試時才發現的真問題,不是 fixture 過時):
|
||||
* Vectorize 端**不知道哪些已下架**(indexed metadata 沒有 status,見 upsert)。
|
||||
* 若最高分那筆是已下架的殘影(實測有 0.971 這種),拿它當基準算出的門檻
|
||||
* 會把真正的正解(0.6)一起砍光 ⇒ **又變成 0 命中**,正是 leo 08-05 早上撞的那個病。
|
||||
* ⇒ 門檻必須在「hydrate+濾掉下架」**之後**、對倖存者的最高分計算(見 routes/entries.ts)。
|
||||
*/
|
||||
export function relativeMinScore(topScore: number): number {
|
||||
return Math.max(MIN_SCORE_ABS_FLOOR, topScore * MIN_SCORE_TOP_RATIO);
|
||||
}
|
||||
|
||||
/** 實際使用的嵌入模型:env 可覆寫(#59),未設用預設。 */
|
||||
function embedModel(env: Bindings): string {
|
||||
const m = (env.EMBED_MODEL ?? '').trim();
|
||||
return m || DEFAULT_EMBED_MODEL;
|
||||
}
|
||||
|
||||
/** embed 模組是否啟用(binding 都在才算開)。base 一切 embed 動作先過這關。 */
|
||||
export function embedEnabled(env: Bindings): boolean {
|
||||
return !!(env.VECTORIZE && env.AI);
|
||||
}
|
||||
|
||||
/** 一段文字 → 768 維向量(Workers AI bge)。空字串回 null(不 embed)。 */
|
||||
/** 一段文字 → 1024 維向量(Workers AI bge-m3,可由 env.EMBED_MODEL 覆寫)。空字串回 null(不 embed)。 */
|
||||
async function embedText(env: Bindings, text: string): Promise<number[] | null> {
|
||||
const t = (text ?? '').trim();
|
||||
if (!t || !env.AI) return null;
|
||||
const res = (await env.AI.run(EMBED_MODEL, { text: [t] })) as { data: number[][] };
|
||||
const res = (await env.AI.run(embedModel(env), { text: [t] })) as { data: number[][] };
|
||||
return res?.data?.[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -132,7 +207,10 @@ export async function backfillEmbeddings(
|
||||
? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1"
|
||||
: BACKFILL_PREDICATE;
|
||||
|
||||
const conds = [basePredicate];
|
||||
// 🔴 2026-08-05:**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。
|
||||
// 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活,
|
||||
// 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
|
||||
@@ -149,7 +227,7 @@ export async function backfillEmbeddings(
|
||||
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
||||
const texts = embeddable.map((e) => (e.content ?? '').trim());
|
||||
const out = (await env.AI.run(EMBED_MODEL, { text: texts })) as { data: number[][] };
|
||||
const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] };
|
||||
const data = out?.data ?? [];
|
||||
const vectors = embeddable
|
||||
.map((e, i) => ({ e, vec: data[i] }))
|
||||
@@ -205,6 +283,72 @@ export async function backfillStatus(
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
|
||||
export interface SelfTestResult {
|
||||
enabled: boolean; // embed 模組是否開(binding 都在)
|
||||
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
|
||||
passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測)
|
||||
note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed 自我檢查(檢修孔用,2026-08-07 leo 直接指令:「先把檢修孔做出來發版」)。
|
||||
*
|
||||
* 為什麼需要這個,不只是 backfillStatus 的 pending/embedded 計數:08-05 撞過的真實故障
|
||||
* 是「is_embedded=1(已嵌入)但語義搜尋還是搜不到」——metadata index 事後才建,既有向量
|
||||
* 沒被收錄(Arcrun#11)。計數看不出這種病,因為計數只問「有沒有嵌」,不問「嵌完查得到嗎」。
|
||||
* 本函式挑一筆「已標記已嵌入」的既有 entry,拿它自己的內容做一次真實語義查詢,檢查
|
||||
* 「自己是否搜得到自己」——這是唯一能端到端驗證 index 真的可用的方法。
|
||||
*
|
||||
* 隱私邊界(檢修孔規格紅線:診斷檔不准帶卡片內容本體):本函式只回布林 + 一句話 note,
|
||||
* 不回傳卡片內容、不回傳 entry id。取樣內容只在函式內部這一次查詢中用過即丟。
|
||||
*/
|
||||
export async function embedSelfTest(
|
||||
env: Bindings,
|
||||
opts: { owner_id?: string } = {},
|
||||
): Promise<SelfTestResult> {
|
||||
if (!embedEnabled(env)) {
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
|
||||
}
|
||||
const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
const where = conds.join(' AND ');
|
||||
const row = await env.DB
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY updated_at DESC LIMIT 1`)
|
||||
.bind(...params)
|
||||
.first<Entry>();
|
||||
if (!row) {
|
||||
return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
|
||||
}
|
||||
const sample = (row.content ?? '').trim().slice(0, 200);
|
||||
if (!sample) {
|
||||
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' };
|
||||
}
|
||||
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
|
||||
let hits: SemanticHit[] | null;
|
||||
try {
|
||||
hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 });
|
||||
} catch (e) {
|
||||
if (e instanceof EmbedQueryFailedError) {
|
||||
// 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。
|
||||
return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (hits === null) {
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
|
||||
}
|
||||
const passed = hits.some((h) => h.id === row.id);
|
||||
return {
|
||||
enabled: true,
|
||||
tested: true,
|
||||
passed,
|
||||
note: passed
|
||||
? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的'
|
||||
: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)',
|
||||
};
|
||||
}
|
||||
|
||||
export interface SemanticHit {
|
||||
id: string;
|
||||
score: number;
|
||||
@@ -214,6 +358,22 @@ export interface SemanticHit {
|
||||
library?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查詢向量化失敗(2026-08-09 leo 直令:「查詢的向量化如果失敗(例如當天額度用完),
|
||||
* 目前會回一個空的結果集——那是騙人,不是降級」)。
|
||||
*
|
||||
* 舊行為:embedText 拿不到向量 → semanticSearch 回 [],caller 分不出
|
||||
* 「真的沒命中」和「根本沒查成」,使用者看到「查無資料」,以為知識庫裡沒有這筆東西。
|
||||
* 新行為:AI.run 丟錯(額度用完/模型故障)或回不出向量 → 丟這個錯,
|
||||
* 由 route 層誠實降級 keyword +告知「這是我們的故障」,不再偽裝成空結果。
|
||||
*/
|
||||
export class EmbedQueryFailedError extends Error {
|
||||
constructor(detail: string) {
|
||||
super(`查詢向量化失敗:${detail}`);
|
||||
this.name = 'EmbedQueryFailedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 語義搜尋(mode:'semantic')。模組未開 → 回 null(caller 降級 keyword + 告知缺能力)。
|
||||
* owner_id / source / entry_type 過濾走 Vectorize metadata filter(entry_type 已 index,見上 upsert metadata)。
|
||||
@@ -223,7 +383,10 @@ export interface SemanticHit {
|
||||
* 註:向量 metadata 的 library 在寫入端已正規化(未標記='general'),故 $in 不需 NULL 處理;
|
||||
* 但「建 library metadata index 之前」upsert 的既有向量沒有此欄 → 部署清單強制 reindex backfill。
|
||||
* min_score(issue #67):分數閾值——Vectorize 只會硬湊 topK 筆,低分尾全是無關內容;
|
||||
* 過濾放查詢端(非 Vectorize 端,API 無此參數)。預設 0=不過濾(行為與舊版一字不變,向後相容)。
|
||||
* 過濾放查詢端(非 Vectorize 端,API 無此參數)。
|
||||
* 🔴 2026-08-05 起預設=`DEFAULT_MIN_SCORE`(跟著模型走,見該常數的實測分布),
|
||||
* 不再是 0(0=不過濾=#67 要修的病本身,而呼叫端各自硬寫數字則是 08-05 全 0 命中的根因)。
|
||||
* caller 顯式傳值仍優先。
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
env: Bindings,
|
||||
@@ -231,8 +394,17 @@ export async function semanticSearch(
|
||||
opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number; min_score?: number } = {},
|
||||
): Promise<SemanticHit[] | null> {
|
||||
if (!embedEnabled(env)) return null;
|
||||
const vec = await embedText(env, q);
|
||||
if (!vec) return [];
|
||||
// 空查詢=真的沒東西可查(route 層已擋 q 必填,這裡只兜底),不算故障。
|
||||
if (!(q ?? '').trim()) return [];
|
||||
// 🔴 2026-08-09(leo 直令):向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」,
|
||||
// 向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實,混在一起就是說謊。
|
||||
let vec: number[] | null;
|
||||
try {
|
||||
vec = await embedText(env, q);
|
||||
} catch (e) {
|
||||
throw new EmbedQueryFailedError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
if (!vec) throw new EmbedQueryFailedError('Workers AI 沒有回出向量(回應形狀異常或空回應)');
|
||||
const filter: VectorizeVectorMetadataFilter = {};
|
||||
if (opts.owner_id) filter.owner_id = opts.owner_id;
|
||||
if (opts.source) filter.source = opts.source;
|
||||
@@ -243,7 +415,8 @@ export async function semanticSearch(
|
||||
returnMetadata: 'indexed',
|
||||
...(Object.keys(filter).length ? { filter } : {}),
|
||||
});
|
||||
const minScore = opts.min_score ?? 0;
|
||||
// 這裡只套**絕對下限**;相對門檻要等「濾掉已下架」之後才能算(見 relativeMinScore 的註解)。
|
||||
const minScore = opts.min_score ?? MIN_SCORE_ABS_FLOOR;
|
||||
return (res.matches ?? [])
|
||||
.filter((m) => m.score >= minScore)
|
||||
.map((m) => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { recordRoutes } from './routes/records';
|
||||
import { recipeStatRoutes } from './routes/recipe-stats';
|
||||
import { embedRoutes } from './routes/embed';
|
||||
import { mapRoutes } from './routes/map';
|
||||
import { executionLogRoutes } from './routes/execution-log';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -42,6 +43,9 @@ app.route('/entries', entryRoutes);
|
||||
app.route('/templates', templateRoutes);
|
||||
app.route('/records', recordRoutes);
|
||||
app.route('/recipe-stats', recipeStatRoutes);
|
||||
// 執行紀錄(KV 額度事故修復,2026-08-07):cypher-executor fire-and-forget 寫、
|
||||
// executions.ts / portal-data.ts 讀,取代舊的 ANALYTICS_KV。
|
||||
app.route('/execution-log', executionLogRoutes);
|
||||
// Optional embed module admin (backfill). Route mounts unconditionally; the handler
|
||||
// honestly 409s when the embed binding is off (base 對內容語意無知,只認通用 embed 旗標)。
|
||||
app.route('/embed', embedRoutes);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { embedEnabled, backfillEmbeddings, backfillStatus } from '../embed';
|
||||
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed';
|
||||
|
||||
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -56,4 +56,14 @@ embedRoutes.get('/backfill/status', async (c) => {
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
|
||||
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
|
||||
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
|
||||
// 本端點端到端驗證 index 真的可用。模組未開仍誠實回 enabled:false(不 409,讓檢修孔
|
||||
// 永遠能拿到一個可解讀的結論,不必先判斷該不該打這支)。
|
||||
embedRoutes.get('/selftest', async (c) => {
|
||||
const result = await embedSelfTest(c.env, { owner_id: c.req.query('owner_id') || undefined });
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
export default embedRoutes;
|
||||
|
||||
+200
-16
@@ -4,6 +4,8 @@ import type { Bindings } from '../types';
|
||||
import {
|
||||
createEntry,
|
||||
deprecateEntriesByLibrary,
|
||||
embeddedIdsByLibrary,
|
||||
markUnembedded,
|
||||
getEntry,
|
||||
listEntries,
|
||||
updateEntry,
|
||||
@@ -11,10 +13,28 @@ import {
|
||||
searchEntries,
|
||||
isDeprecatedEntry,
|
||||
} from '../actions/entry-crud';
|
||||
import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
|
||||
import {
|
||||
embedEnabled,
|
||||
embedOnWrite,
|
||||
semanticSearch,
|
||||
relativeMinScore,
|
||||
backfillStatus,
|
||||
backfillEmbeddings,
|
||||
EmbedQueryFailedError,
|
||||
} from '../embed';
|
||||
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
|
||||
|
||||
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// fire-and-forget:有 executionCtx(workerd)就 waitUntil,測試環境沒有就 detach(吞錯不吵)。
|
||||
// 給搜尋路徑的「自癒」動作用——修復是順手做的背景事,絕不拖慢也絕不弄壞查詢本身。
|
||||
function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknown>): void {
|
||||
let ctx: ExecutionContext | undefined;
|
||||
try { ctx = c.executionCtx; } catch { ctx = undefined; }
|
||||
if (ctx) ctx.waitUntil(p.catch(() => {}));
|
||||
else void p.catch(() => {});
|
||||
}
|
||||
|
||||
// library 多值參數(逗號分隔,portal-auth P1,design §3.3)。空值/全空白 → undefined(=不過濾,
|
||||
// 行為與未帶參數一字不變——向後相容硬驗收)。
|
||||
function parseLibraryParam(raw: string | undefined): string[] | undefined {
|
||||
@@ -85,9 +105,20 @@ entryRoutes.get('/library-stats', async (c) => {
|
||||
// 舊版完全不接這個 filter;q 與 search 兩個名字都認,避免同一個坑再踩一次)。
|
||||
// count = 本頁筆數(受 limit 影響);total = 符合條件全部筆數(不受 limit 影響,見 total 欄位)。
|
||||
entryRoutes.get('/', async (c) => {
|
||||
const entryType = c.req.query('entry_type') || undefined;
|
||||
const ownerId = c.req.query('owner_id') || undefined;
|
||||
// 自癒搬遷(D38 收尾,2026-08-08):credential 目錄查詢先確保舊表(若還在)已把這個
|
||||
// 租戶的資料搬進 entries——冪等、per-owner scoped、成本近零(見 credential-legacy-
|
||||
// migration.ts 檔頭)。只在 credential 讀取時觸發,不影響其餘 entry_type 的查詢路徑。
|
||||
if (entryType === 'credential' && ownerId) {
|
||||
await migrateLegacyCredentialsForOwner(c.env.DB, ownerId).catch(() => {
|
||||
// 搬遷失敗不阻塞查詢本身(例如舊表結構意外損毀)——誠實地讓查詢照常進行,
|
||||
// 缺席的 credential 由呼叫端既有的 fallback(cypher-executor 舊 KV)接住。
|
||||
});
|
||||
}
|
||||
const { entries, total } = await listEntries(c.env.DB, {
|
||||
entry_type: c.req.query('entry_type') || undefined,
|
||||
owner_id: c.req.query('owner_id') || undefined,
|
||||
entry_type: entryType,
|
||||
owner_id: ownerId,
|
||||
parent_id: c.req.query('parent_id') || undefined,
|
||||
page_name: c.req.query('page_name') || undefined,
|
||||
source: c.req.query('source') || undefined,
|
||||
@@ -101,7 +132,13 @@ entryRoutes.get('/', async (c) => {
|
||||
|
||||
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic
|
||||
// - mode=keyword(預設):D1 LIKE(base,永遠可用)。
|
||||
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。
|
||||
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword +
|
||||
// capability_hint。capability_hint 是講給非技術使用者聽的人話
|
||||
// (2026-08-08 修:曾經直接透傳到封測用戶眼前的工程師導向文字,見該欄位旁註);
|
||||
// 技術細節另放 admin_hint 給維運者/CC 看。
|
||||
// 🔴 2026-08-09(leo 直令):語意搜尋是**一安裝就提供**的功能,模組不在=故障,
|
||||
// 文案照實說「壞了、是我們的問題、使用者不用做任何事」,禁止說成「還沒開通/未啟用」。
|
||||
// 降級回應帶 degraded_reason(module_off / embed_query_failed)供前端與診斷分流。
|
||||
// - entry_type:base 通用 filter(caller 傳任意 type,如 workflow;base 不寫死語意,workflow-discovery Q4)。
|
||||
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extract+NULL→general;
|
||||
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
|
||||
@@ -141,11 +178,41 @@ entryRoutes.get('/search', async (c) => {
|
||||
// 已在 PR 描述向 leo 說明這個 trade-off(多倍 margin vs 迴圈重撈的取捨)。
|
||||
const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊
|
||||
const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100);
|
||||
const hits = await semanticSearch(c.env, q, {
|
||||
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
|
||||
});
|
||||
// 🔴 2026-08-09(leo 直令):語意搜尋壞掉時**照實說是故障**。
|
||||
// - 語意搜尋是一安裝就提供的功能。走到下面任一降級分支=這台實例壞了,
|
||||
// 不是「還沒開通」「未啟用」——禁止把 bug 美化成沒提供(那會製造
|
||||
// 「請幫我開通」的客服工單,而真正的故障沒人修)。
|
||||
// - capability_hint 給一般使用者看:說清楚「是我們的問題、不是你的錯、
|
||||
// 你不用做任何事」;技術細節放 admin_hint 給維運者/CC。
|
||||
// - 降級仍回關鍵字結果:有退化的結果比空白有用,但誠實標示,不假裝是語意結果。
|
||||
let hits;
|
||||
try {
|
||||
hits = await semanticSearch(c.env, q, {
|
||||
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof EmbedQueryFailedError) {
|
||||
// 查詢向量化失敗(Workers AI 額度用完/服務故障):舊版在這裡回空結果集
|
||||
// =把「我們沒查成」偽裝成「你的庫裡沒有」——leo 08-09 點名的謊。改誠實降級。
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
|
||||
return c.json({
|
||||
success: true,
|
||||
entries,
|
||||
count: entries.length,
|
||||
mode: 'keyword',
|
||||
requested_mode: 'semantic',
|
||||
degraded_reason: 'embed_query_failed',
|
||||
capability_hint:
|
||||
'語意搜尋暫時故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,稍後它會自動恢復。',
|
||||
admin_hint: `${e.message}。常見原因:Workers AI 當日額度用完或服務暫時異常;本次已降級關鍵字搜尋,資料與索引皆未受影響。`,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (hits === null) {
|
||||
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
|
||||
// embed 模組不在(缺 VECTORIZE/AI binding):對一安裝就提供的功能而言,這**是故障**
|
||||
// ——多半是某次部署把 binding 弄丟了(更新時沒帶 kbdb_embed、或安裝時 Vectorize
|
||||
// 建立失敗被靜默放行)。誠實降級 keyword,照實說壞了,不說「還沒開通」。
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
|
||||
return c.json({
|
||||
success: true,
|
||||
@@ -153,25 +220,113 @@ entryRoutes.get('/search', async (c) => {
|
||||
count: entries.length,
|
||||
mode: 'keyword',
|
||||
requested_mode: 'semantic',
|
||||
degraded_reason: 'module_off',
|
||||
capability_hint:
|
||||
'語義查詢需先開 vectorize(embed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。',
|
||||
'語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。',
|
||||
admin_hint:
|
||||
'故障:kbdb worker 缺 VECTORIZE/AI binding(embedEnabled=false)。語意搜尋是安裝即提供的功能,缺 binding=部署層事故(常見:redeploy 沒帶 kbdb_embed 注入、或安裝時 Vectorize index 建立失敗被放行)。修法:確認 Vectorize index 存在後以 kbdb_embed:true 重部 kbdb。本次已降級關鍵字搜尋。',
|
||||
});
|
||||
}
|
||||
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
|
||||
// #67:entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。
|
||||
// 2026-08-09 自癒:hydrate 過程順手記下「索引裡有、資料已不在」的向量
|
||||
// - 孤兒(getEntry 找不到)→ 該向量已無對應資料,直接刪;
|
||||
// - 殘影(已下架但向量還在,0.971 案的病原)→ 刪向量+is_embedded 歸零。
|
||||
// 背景執行(fireAndForget),失敗下次搜尋再清;查詢本身不受影響。
|
||||
const orphanIds: string[] = [];
|
||||
const deprecatedIds: string[] = [];
|
||||
let entries = (
|
||||
await Promise.all(
|
||||
hits.map(async (h) => {
|
||||
const e = await getEntry(c.env.DB, h.id);
|
||||
return e ? { ...e, score: h.score } : null;
|
||||
if (!e) { orphanIds.push(h.id); return null; }
|
||||
return { ...e, score: h.score };
|
||||
}),
|
||||
)
|
||||
).filter((e): e is NonNullable<typeof e> => e !== null);
|
||||
if (!include_deprecated) {
|
||||
entries = entries.filter((e) => !isDeprecatedEntry(e));
|
||||
entries = entries.filter((e) => {
|
||||
const dep = isDeprecatedEntry(e);
|
||||
if (dep) deprecatedIds.push(e.id);
|
||||
return !dep;
|
||||
});
|
||||
}
|
||||
const staleIds = [...orphanIds, ...deprecatedIds];
|
||||
if (staleIds.length > 0 && c.env.VECTORIZE) {
|
||||
fireAndForget(c, (async () => {
|
||||
await c.env.VECTORIZE!.deleteByIds(staleIds);
|
||||
await markUnembedded(c.env.DB, deprecatedIds);
|
||||
})());
|
||||
}
|
||||
// 🔴 2026-08-05:相對門檻砍低分尾(leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)。
|
||||
// **一定要接在濾掉下架的後面**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走
|
||||
// (=同日早上「0 命中」的翻版;t24 的 0.971 復現案就是這種殘影)。
|
||||
// caller 顯式帶 min_score 時尊重他的絕對值,不再加碼。
|
||||
if (min_score === undefined && entries.length > 1) {
|
||||
const cut = relativeMinScore(entries[0].score);
|
||||
entries = entries.filter((e) => e.score >= cut);
|
||||
}
|
||||
// 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。
|
||||
entries = entries.slice(0, requestedTopK);
|
||||
|
||||
// 🔴 2026-08-08(總管交辦二修,Oscar 封測案:模組有開、但語意搜尋回空——回報後才發現
|
||||
// 這條路徑比「模組沒開」的 capability_hint 更常撞到,卻完全沒有 hint,是「誠實但沉默」):
|
||||
// count:0 對用戶而言是無資訊的——「我打的字不對」跟「這個庫的索引根本沒建好」需要的下一步
|
||||
// 完全不同,系統卻兩種都回同一句「找不到」。分辨依據(不新開一套覆蓋率查詢,共用 embed.ts
|
||||
// 既有的 backfillStatus——2026-08-07 檢修孔/診斷聚合端點已在用同一支,同一件事只留一套
|
||||
// 實作,2026-08-08 credential 那次「兩套並存必然漂移」的教訓不重踩):
|
||||
// - hits.length===0(Vectorize 端零命中,含 embed.ts 內建絕對門檻)
|
||||
// → 查 backfillStatus(owner_id).embedded:
|
||||
// 0 筆 → 'no_index'(這個租戶根本沒有索引資料,不是使用者的問題)
|
||||
// >0 筆 → 'no_match'(有索引,這次查詢正常沒撞到——換句話說再搜)
|
||||
// - hits.length>0 但濾光 → 'stale_index'。誠實核算過機制:relativeMinScore 的 cut
|
||||
// 必然 <= 最高分(cut = max(絕對下限, top×0.8) <= top),所以「最高分那筆」永遠會
|
||||
// 自己活下來,相對門檻**不可能**把非空結果砍成 0——這裡不能寫「相似度不夠」這種
|
||||
// 不符合實際機制的話(誠實限制,mindset §7)。真正會讓 hits>0 卻 entries=0 的只有
|
||||
// 兩種:命中的向量對應的資料**已下架**(isDeprecatedEntry 濾掉)、或**已被刪除**
|
||||
// (getEntry 找不到,孤兒向量)——兩者都是「索引裡有,但實際資料不在了」,故稱
|
||||
// stale_index(索引與資料兩邊不同步),不誤導使用者去猜「換個字」。
|
||||
// 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。
|
||||
// 正常有結果(entries.length>0)完全不受影響,回應形狀不變。
|
||||
if (entries.length === 0) {
|
||||
let empty_reason: 'no_index' | 'no_match' | 'stale_index';
|
||||
let capability_hint: string;
|
||||
let admin_hint: string;
|
||||
if (hits.length === 0) {
|
||||
const status = await backfillStatus(c.env, { owner_id });
|
||||
if (status.embedded === 0 && status.pending > 0) {
|
||||
// 資料在、索引卻一筆都沒建=故障(寫入時嵌入沒成功過)。順手自癒:
|
||||
// 背景補嵌一批(冪等、分批),下次搜尋就有機會直接好——不叫使用者做任何事。
|
||||
empty_reason = 'no_index';
|
||||
capability_hint =
|
||||
'語意搜尋的索引出了狀況,所以暫時搜不到——這是我們系統的問題,不是你打的字有問題。系統正在自動重建,稍後再搜一次看看。';
|
||||
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 但 pending=${status.pending}:資料在、索引從沒建成=寫入端嵌入從未成功(故障)。本次已背景觸發 backfill 自癒(每批 100,冪等)。`;
|
||||
fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
|
||||
} else if (status.embedded === 0) {
|
||||
// 連「該被嵌的資料」都沒有=這個庫還沒有整理好的內容(新裝好還沒同步),不是故障。
|
||||
empty_reason = 'no_index';
|
||||
capability_hint =
|
||||
'這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。';
|
||||
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`;
|
||||
} else {
|
||||
empty_reason = 'no_match';
|
||||
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
|
||||
admin_hint = `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料,但本次查詢在 Vectorize 端零命中(含 embed.ts 絕對門檻過濾)。`;
|
||||
// 順手自癒:pending>0=有卡片在寫入時漏嵌(embedOnWrite 失敗是 fire-and-forget,
|
||||
// 沒有別的機制會回來補)。status 已經查了,不多花查詢,背景補一批。
|
||||
if (status.pending > 0) fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
|
||||
}
|
||||
} else {
|
||||
empty_reason = 'stale_index';
|
||||
capability_hint =
|
||||
'這次比對到的內容源頭已經被移除或下架了,所以沒有可顯示的結果。系統已自動清理過期索引(我們的問題,你不用做任何事),換個關鍵字就能正常搜。';
|
||||
admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。本次已背景觸發向量清理(deleteByIds)。`;
|
||||
}
|
||||
return c.json({
|
||||
success: true, entries, count: entries.length, mode: 'semantic',
|
||||
empty_reason, capability_hint, admin_hint,
|
||||
});
|
||||
}
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
|
||||
}
|
||||
|
||||
@@ -194,8 +349,22 @@ entryRoutes.patch('/deprecate-by-library', async (c) => {
|
||||
const ownerId = String(body?.owner_id ?? '').trim();
|
||||
const library = String(body?.library ?? '').trim();
|
||||
if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400);
|
||||
// 🔴 2026-08-05(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」):
|
||||
// 先撈 id 再標下架——順序反過來就撈不到「還有向量」的那批(標完 status 不影響 is_embedded,
|
||||
// 但先撈比較不依賴欄位語意,也讓失敗時不會留下「已標下架但向量還在」的中間態)。
|
||||
const ids = embedEnabled(c.env) ? await embeddedIdsByLibrary(c.env.DB, ownerId, library) : [];
|
||||
const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library);
|
||||
return c.json({ success: true, deprecated_count: count });
|
||||
let vectors_deleted = 0;
|
||||
if (ids.length > 0) {
|
||||
// 刪向量+把 is_embedded 歸零(讓 D1 與 Vectorize 不說兩套話)。
|
||||
// 失敗不擋下架本體:D1 已標 deprecated,搜尋端仍會濾掉;殘留向量下次再清。
|
||||
try {
|
||||
await c.env.VECTORIZE!.deleteByIds(ids);
|
||||
await markUnembedded(c.env.DB, ids);
|
||||
vectors_deleted = ids.length;
|
||||
} catch { /* 誠實回 0,不假裝清乾淨了 */ }
|
||||
}
|
||||
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
@@ -211,11 +380,26 @@ entryRoutes.patch('/:id', async (c) => {
|
||||
});
|
||||
|
||||
// DELETE /entries/:id
|
||||
//
|
||||
// 🔴 2026-08-10(arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來):
|
||||
// 舊版把向量刪除包成 fire-and-forget(`waitUntil(...).catch(()=>{})`)——失敗被靜默吞掉,
|
||||
// 呼叫端(rag_takedown_direct workflow/未來的 portal 刪除 UI)永遠不知道向量沒清乾淨,
|
||||
// 使用者看到「刪除成功」,但語意搜尋可能還留著殘影,直到下次搜尋命中它才被自癒清掉
|
||||
// (search 路徑的 orphan 清理是事後補救,不是保證)。
|
||||
// 改法:與同檔 `/entries/deprecate-by-library`(見上)同款——**同步 await 再回應**,
|
||||
// 誠實回報 `vector_deleted`(true=清了/false=清失敗,D1 仍照刪/null=模組未開,不適用)。
|
||||
// D1 刪除永遠執行到底(entry 本體一定會消失),差別只在向量那一步呼叫端看不看得見失敗。
|
||||
entryRoutes.delete('/:id', async (c) => {
|
||||
// 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。
|
||||
const id = c.req.param('id');
|
||||
let vector_deleted: boolean | null = null; // 模組未開=不適用,維持 null 誠實表達「這件事沒發生過」
|
||||
if (embedEnabled(c.env)) {
|
||||
c.executionCtx.waitUntil(c.env.VECTORIZE!.deleteByIds([c.req.param('id')]).then(() => {}).catch(() => {}));
|
||||
try {
|
||||
await c.env.VECTORIZE!.deleteByIds([id]);
|
||||
vector_deleted = true;
|
||||
} catch {
|
||||
vector_deleted = false; // 誠實回 false,不假裝清乾淨了;D1 本體仍照刪,不因向量失敗而擋下
|
||||
}
|
||||
}
|
||||
await deleteEntry(c.env.DB, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
await deleteEntry(c.env.DB, id);
|
||||
return c.json({ success: true, vector_deleted });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Execution log route(KV 額度事故修復,2026-08-07;保留期=P7,2026-08-09)。
|
||||
// cypher-executor 對每次 workflow 執行 fire-and-forget POST /execution-log/record;
|
||||
// executions.ts / portal-data.ts 讀 GET /execution-log 取代舊的 ANALYTICS_KV list/get。
|
||||
// 形狀比照 recipe-stats.ts(同一種「cypher 寫、KBDB 存」的 fire-and-forget stat 端點)。
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import {
|
||||
recordExecutionLog,
|
||||
listExecutionLog,
|
||||
latestExecutionLog,
|
||||
getRetentionDays,
|
||||
setRetentionDays,
|
||||
cleanupExpiredLogs,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
} from '../actions/execution-log';
|
||||
|
||||
export const executionLogRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /execution-log/record — { workflow_id, owner_id?, verdict, duration_ms, message?, target? }
|
||||
executionLogRoutes.post('/record', async (c) => {
|
||||
const body = await c.req.json().catch(() => null) as {
|
||||
workflow_id?: string;
|
||||
owner_id?: string | null;
|
||||
verdict?: string;
|
||||
duration_ms?: number;
|
||||
message?: string;
|
||||
target?: string | null;
|
||||
} | null;
|
||||
if (!body || !body.workflow_id || (body.verdict !== 'success' && body.verdict !== 'failed')) {
|
||||
return c.json({ success: false, error: 'workflow_id 與 verdict("success"|"failed") 必填' }, 400);
|
||||
}
|
||||
const result = await recordExecutionLog(c.env.DB, c.env, {
|
||||
workflow_id: body.workflow_id,
|
||||
owner_id: body.owner_id ?? null,
|
||||
verdict: body.verdict,
|
||||
duration_ms: typeof body.duration_ms === 'number' ? body.duration_ms : 0,
|
||||
message: body.message ?? '',
|
||||
target: body.target ?? null,
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
// GET /execution-log?workflow_id=&owner_id=&limit= — 最近 N 次(降冪)
|
||||
executionLogRoutes.get('/', async (c) => {
|
||||
const workflowId = c.req.query('workflow_id');
|
||||
if (!workflowId) return c.json({ success: false, error: 'workflow_id 必填' }, 400);
|
||||
const ownerId = c.req.query('owner_id') || undefined;
|
||||
const limitParam = c.req.query('limit');
|
||||
const limit = Math.min(Math.max(parseInt(limitParam || '10', 10) || 10, 1), 100);
|
||||
const executions = await listExecutionLog(c.env.DB, workflowId, ownerId, limit);
|
||||
return c.json({ success: true, executions });
|
||||
});
|
||||
|
||||
// GET /execution-log/latest?workflow_id=&owner_id= — 最新一次(portal 卡片用)
|
||||
executionLogRoutes.get('/latest', async (c) => {
|
||||
const workflowId = c.req.query('workflow_id');
|
||||
if (!workflowId) return c.json({ success: false, error: 'workflow_id 必填' }, 400);
|
||||
const ownerId = c.req.query('owner_id') || undefined;
|
||||
const execution = await latestExecutionLog(c.env.DB, workflowId, ownerId);
|
||||
return c.json({ success: true, execution });
|
||||
});
|
||||
|
||||
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
|
||||
|
||||
// GET /execution-log/retention?owner_id= — 讀某租戶目前的保留天數
|
||||
// (回 retention_days: number | null;null=該租戶已設「不刪除」)。owner_id 必填——
|
||||
// 沒有租戶就沒有「誰的設定」這回事,讀無租戶的保留期用不到這支,走 DEFAULT_RETENTION_DAYS 常數即可。
|
||||
executionLogRoutes.get('/retention', async (c) => {
|
||||
const ownerId = c.req.query('owner_id');
|
||||
if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400);
|
||||
const retentionDays = await getRetentionDays(c.env.DB, ownerId);
|
||||
return c.json({ success: true, owner_id: ownerId, retention_days: retentionDays, default_days: DEFAULT_RETENTION_DAYS });
|
||||
});
|
||||
|
||||
// PUT /execution-log/retention — body { owner_id, retention_days: number|null }
|
||||
// retention_days=null=「不刪除」(leo 08-07:「我願意花很多錢保存,不要刪除」,企業稽核選項)。
|
||||
// retention_days=正整數=自訂天數(覆蓋預設 90 天)。
|
||||
executionLogRoutes.put('/retention', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { owner_id?: string; retention_days?: number | null }
|
||||
| null;
|
||||
if (!body || !body.owner_id) return c.json({ success: false, error: 'owner_id 必填' }, 400);
|
||||
const days = body.retention_days;
|
||||
if (days !== null && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
|
||||
return c.json({ success: false, error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
|
||||
}
|
||||
await setRetentionDays(c.env.DB, body.owner_id, days === null ? null : Math.round(days));
|
||||
return c.json({ success: true, owner_id: body.owner_id, retention_days: days === null ? null : Math.round(days) });
|
||||
});
|
||||
|
||||
// POST /execution-log/cleanup — 清一批過期執行紀錄(見 actions/execution-log.ts 頂部註解:
|
||||
// 呼叫端=cypher-executor 既有的每分鐘 scheduled tick,一天呼叫一次,不是新排程基礎設施)。
|
||||
// 內部維運端點,無 body;每次呼叫界限刪除量,長期多次呼叫可逐步清完累積量。
|
||||
executionLogRoutes.post('/cleanup', async (c) => {
|
||||
const result = await cleanupExpiredLogs(c.env.DB);
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
+19
-3
@@ -4,7 +4,12 @@
|
||||
// cypher proxy(X-Arcrun-API-Key → owner_id 注入)/caller 帶 owner_id 參數完成。
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { getLibraryMapDetail, listLibraryMaps, recomputeLibraryMap } from '../actions/library-map';
|
||||
import {
|
||||
ensureFreshLibraryMaps,
|
||||
getLibraryMapDetail,
|
||||
listLibraryMaps,
|
||||
recomputeLibraryMap,
|
||||
} from '../actions/library-map';
|
||||
|
||||
export const mapRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -35,14 +40,25 @@ mapRoutes.post('/recompute', async (c) => {
|
||||
|
||||
// GET /map — 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。
|
||||
// 形狀給 MCP instructions/GUI 首頁共用(R3/R4),設計在數百 token 內。
|
||||
//
|
||||
// 2026-08-08:讀前先 ensureFreshLibraryMaps(即時新鮮度層,見 actions/library-map.ts 段落註解)——
|
||||
// 不再只讀靜態快取,讀的當下順手核對即時三元組數、落差就地補算。失敗吞掉不擋讀取(地圖是加分)。
|
||||
mapRoutes.get('/', async (c) => {
|
||||
const libraries = await listLibraryMaps(c.env.DB, c.req.query('owner_id') || undefined);
|
||||
const owner = c.req.query('owner_id') || undefined;
|
||||
await ensureFreshLibraryMaps(c.env.DB, owner).catch(() => {});
|
||||
const libraries = await listLibraryMaps(c.env.DB, owner);
|
||||
return c.json({ success: true, libraries, count: libraries.length });
|
||||
});
|
||||
|
||||
// GET /map/:library — 該庫詳圖(完整 slots+可嵌人話 content)。
|
||||
// 同樣先跑即時新鮮度層。之後仍查不到 → 誠實 404(這個名字這個租戶的資料裡從沒出現過,
|
||||
// 不是「這庫是空的」——已知但目前 0 三元組的庫會被上一步補成一筆 triplet_count:0 的 map,
|
||||
// 走得到 200,不會落到這條 404)。
|
||||
mapRoutes.get('/:library', async (c) => {
|
||||
const map = await getLibraryMapDetail(c.env.DB, c.req.param('library'), c.req.query('owner_id') || undefined);
|
||||
const owner = c.req.query('owner_id') || undefined;
|
||||
const library = c.req.param('library');
|
||||
await ensureFreshLibraryMaps(c.env.DB, owner).catch(() => {});
|
||||
const map = await getLibraryMapDetail(c.env.DB, library, owner);
|
||||
if (!map) return c.json({ success: false, error: 'not found' }, 404);
|
||||
return c.json({ success: true, map });
|
||||
});
|
||||
|
||||
+11
-1
@@ -16,6 +16,14 @@ export type Bindings = {
|
||||
// requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed.
|
||||
VECTORIZE?: VectorizeIndex;
|
||||
AI?: Ai;
|
||||
// 嵌入模型(Arcrun#59)。未設=用 embed.ts 的預設。設成別的模型時,**Vectorize index 的
|
||||
// dimensions 必須跟著對**(維度不合 upsert 會被 CF 拒絕),且換模型必須換 index:
|
||||
// 不同模型的向量不可共存於同一個 index(比對出來是垃圾),詳見 embed.ts 檔頭。
|
||||
EMBED_MODEL?: string;
|
||||
// execution_log 每日軟上限(KV 額度事故修復,2026-08-07;A2 自我降級,見
|
||||
// kbdb/src/actions/execution-log.ts DEFAULT_DAILY_LIMIT 說明)。未設 → 20000
|
||||
// (D1 100,000 rows written/日的 20%,留 80% 給知識卡 entries)。
|
||||
EXECUTION_LOG_DAILY_WRITE_LIMIT?: string;
|
||||
};
|
||||
|
||||
export type EntryType =
|
||||
@@ -25,7 +33,9 @@ export type EntryType =
|
||||
| 'slot'
|
||||
| 'project'
|
||||
| 'workflow'
|
||||
| 'recipe_stat';
|
||||
| 'recipe_stat'
|
||||
| 'execution_log'
|
||||
| 'execution_log_usage';
|
||||
|
||||
export interface Entry {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// credential-legacy-migration.test.ts — 「新讀取端上線、舊資料還沒搬完」自癒補丁的迴歸測試
|
||||
// (D38 圍牆修復收尾,總管交辦,2026-08-08;youlin 測試實例 2026-08-07 事故的根因修復)。
|
||||
//
|
||||
// 測試策略比照既有 execution-log.test.ts / library-map.test.ts:真 SQLite(node:sqlite)
|
||||
// 套 migration 原檔,比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。
|
||||
// 本檔對 D1 介面的直接呼叫全是測試灌資料/驗證用(與上述兩份既有測試同一慣例),
|
||||
// 不是牆外業務程式碼繞過 API,逐行標 kbdb-sql-ok。
|
||||
//
|
||||
// ── 這份測試在證明什麼(對應 leo 08-08 追加的三個安全性質)─────────────────
|
||||
// 1. 反向驗證(禁假綠的核心):先重建 2026-08-07 事故的確切狀態——0002 舊表有資料、
|
||||
// entries 沒有——直接呼叫 cypher-executor 熱路徑會打的同一個端點(GET /entries?
|
||||
// entry_type=credential&owner_id=X),**在補丁加入之前這裡本該回空陣列**(就是
|
||||
// 事故當天「缺少 credential: kbdb_internal_token」的成因)。本檔驗證補丁讓它改回
|
||||
// 找得到,等於把事故重現一次、再證明修好。
|
||||
// 2. 冪等:同一個 owner 呼叫兩次、三次,entries 筆數不重複增加。
|
||||
// 3. 對「已搬過」與「還沒搬」的實例都正確:不同 owner 各自獨立、互不干擾;已無舊表
|
||||
// (模擬清理步驟做完之後)時查詢仍正常運作、不報錯。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Hono } from 'hono';
|
||||
import { entryRoutes } from '../src/routes/entries';
|
||||
import { migrateLegacyCredentialsForOwner } from '../src/actions/credential-legacy-migration';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
// ── node:sqlite → D1 介面最小 adapter(同 execution-log.test.ts / library-map.test.ts 手法)──
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:');
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔,比照 execution-log.test.ts
|
||||
raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
|
||||
raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
|
||||
function stmt(sql: string, params: unknown[]) {
|
||||
const s = {
|
||||
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 測試 adapter,比照 execution-log.test.ts
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 測試 adapter
|
||||
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 測試 adapter
|
||||
};
|
||||
return s;
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; // kbdb-sql-ok: 測試 adapter 的 D1 介面實作本身
|
||||
}
|
||||
|
||||
function envWith(db: D1Database): Bindings {
|
||||
return { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings;
|
||||
}
|
||||
|
||||
function app(db: D1Database) {
|
||||
const a = new Hono<{ Bindings: Bindings }>();
|
||||
a.route('/entries', entryRoutes);
|
||||
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
|
||||
}
|
||||
|
||||
describe('credential-legacy-migration — 反向驗證:重現 2026-08-07 youlin 事故並證明修好', () => {
|
||||
it('事故前置狀態(舊表有資料、entries 沒有)下,GET /entries 一樣能讀到 credential(自癒生效)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// 重建事故現場:舊表寫一筆 kbdb_internal_token,entries 完全沒有對應列
|
||||
// (新 code 部署了、migration 沒跑——2026-08-07 youlin 的確切狀態)。
|
||||
await db
|
||||
.prepare( // kbdb-sql-ok: 測試重建舊表資料現場,比照 execution-log.test.ts
|
||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
|
||||
)
|
||||
.bind('yuga3bse', 'kbdb_internal_token', 'kbdb', 'high', 'CRED_KBDB_INTERNAL_TOKEN_DEADBEEF', Math.floor(Date.now() / 1000))
|
||||
.run();
|
||||
|
||||
// 事故當天的確切呼叫形狀:cypher-executor credentials.ts 的 findCredentialEntry /
|
||||
// getCredentialDirectory 都是打這個端點。
|
||||
const a = app(db);
|
||||
const res = await a.fetch('/entries?owner_id=yuga3bse&entry_type=credential&page_name=kbdb_internal_token&limit=1');
|
||||
const body = (await res.json()) as { success: boolean; entries: Array<{ page_name: string; metadata_json: string }> };
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.entries.length).toBe(1); // 補丁加入前這裡是 0——2026-08-07 事故的確切失敗形狀
|
||||
expect(body.entries[0].page_name).toBe('kbdb_internal_token');
|
||||
const meta = JSON.parse(body.entries[0].metadata_json) as { secret_ref: string; service: string };
|
||||
expect(meta.secret_ref).toBe('CRED_KBDB_INTERNAL_TOKEN_DEADBEEF');
|
||||
expect(meta.service).toBe('kbdb');
|
||||
});
|
||||
|
||||
it('搬移後 KBDB 核心三表結構不變,舊表刻意保留(本檔不清舊表,交由之後的清理步驟)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await db
|
||||
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
|
||||
.bind('t1', 'x', null, 'standard', 'CRED_X_AAAA', 1)
|
||||
.run();
|
||||
await migrateLegacyCredentialsForOwner(db, 't1');
|
||||
const tables = await db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢
|
||||
.all<{ name: string }>();
|
||||
const names = (tables.results ?? []).map((t) => t.name).sort();
|
||||
// entries/templates/entry_values 三張核心表 + credentials(舊表,尚未清理)——沒有第五張表。
|
||||
expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential-legacy-migration — 冪等(同一 owner 呼叫多次不重複搬)', () => {
|
||||
it('連呼叫三次,entries 裡該租戶的 credential 筆數固定為 1', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await db
|
||||
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
|
||||
.bind('owner-idem', 'telegram_bot_token', 'telegram', 'standard', 'CRED_TELEGRAM_BOT_TOKEN_BEEF', 1000)
|
||||
.run();
|
||||
|
||||
const n1 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
|
||||
const n2 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
|
||||
const n3 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
|
||||
expect(n1).toBe(1); // 第一次:真的搬了一筆
|
||||
expect(n2).toBe(0); // 第二次起:NOT EXISTS 擋下,不重複
|
||||
expect(n3).toBe(0);
|
||||
|
||||
const rows = await db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type='credential' AND owner_id=?1`) // kbdb-sql-ok: 測試查詢
|
||||
.bind('owner-idem')
|
||||
.first<{ n: number }>();
|
||||
expect(rows?.n).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential-legacy-migration — 多租戶互不干擾,且對「已搬過」與「還沒搬」同時安全', () => {
|
||||
it('兩個 owner 各自的 credential 不互相污染;沒有資料的 owner 查詢回空、不報錯', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await db
|
||||
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
|
||||
.bind('tenant-a', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_A1', 1)
|
||||
.run();
|
||||
await db
|
||||
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
|
||||
.bind('tenant-b', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_B2', 1)
|
||||
.run();
|
||||
|
||||
await migrateLegacyCredentialsForOwner(db, 'tenant-a');
|
||||
// tenant-b 完全沒觸發過搬遷(模擬「還沒走到這個租戶的下一次 workflow 執行」)。
|
||||
|
||||
const a = app(db);
|
||||
const resA = await a.fetch('/entries?owner_id=tenant-a&entry_type=credential&page_name=gemini_api_key&limit=1');
|
||||
const bodyA = (await resA.json()) as { entries: Array<{ metadata_json: string }> };
|
||||
expect(JSON.parse(bodyA.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_A1');
|
||||
|
||||
// tenant-b 第一次讀取才觸發自己的搬遷(GET /entries 路由本身會呼叫,不需要呼叫端先知道)。
|
||||
const resB = await a.fetch('/entries?owner_id=tenant-b&entry_type=credential&page_name=gemini_api_key&limit=1');
|
||||
const bodyB = (await resB.json()) as { entries: Array<{ metadata_json: string }> };
|
||||
expect(JSON.parse(bodyB.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_B2');
|
||||
|
||||
// 沒有任何資料的第三個 owner:不報錯、乾淨回空。
|
||||
const resC = await a.fetch('/entries?owner_id=tenant-c&entry_type=credential&limit=200');
|
||||
const bodyC = (await resC.json()) as { success: boolean; entries: unknown[] };
|
||||
expect(bodyC.success).toBe(true);
|
||||
expect(bodyC.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('舊表已被清理(不存在)時查詢照常運作(模擬所有租戶搬完後的最終清理狀態)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await db.prepare(`DROP TABLE credentials`).run(); // kbdb-sql-ok: 測試模擬「清理步驟已執行」的終態,非牆外存取
|
||||
const n = await migrateLegacyCredentialsForOwner(db, 'anyone');
|
||||
expect(n).toBe(0); // 短路,不報錯
|
||||
|
||||
const a = app(db);
|
||||
const res = await a.fetch('/entries?owner_id=anyone&entry_type=credential&limit=200');
|
||||
const body = (await res.json()) as { success: boolean; entries: unknown[] };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// 嵌入模型可配置+換代(Arcrun#59)—— 2026-08-03
|
||||
//
|
||||
// 為什麼要有這個測試(別刪):
|
||||
// 舊版把模型寫死成 `@cf/baai/bge-base-en-v1.5`,那是**英文模型**,拿來嵌中文等於嵌一堆
|
||||
// 看不懂的 token。5 組中文測資實測(margin = min(相關) − max(無關),≤0 代表排序錯):
|
||||
// bge-base-en-v1.5 768 排序正確 2/5 平均 margin -0.0413 ← 舊,五組錯三組
|
||||
// embeddinggemma-300m 768 4/5 +0.1275
|
||||
// **bge-m3 1024 5/5 +0.1410** ← 新(品質最好、而且最快 959ms)
|
||||
// qwen3-embedding-0.6b 1024 4/5 +0.1381
|
||||
// 最刺眼的一組:問「知識庫問答為什麼要標出處?」→「會議室預約規則」0.7789 竟然高於
|
||||
// 真正相關的 0.7306 = leo 2026-07-18 回報「問 RAG 卻引用會議室規範」的直接數字。
|
||||
//
|
||||
// 本檔守三件事:
|
||||
// ① 預設模型是 m3(有人手滑改回英文模型會紅)
|
||||
// ② env.EMBED_MODEL 真的能覆寫(#59 要的「可配置」)
|
||||
// ③ **查詢端與寫入端用同一顆模型**——兩邊不同步是最惡毒的 bug:
|
||||
// 不會報錯、只是分數全是垃圾,而且從外面完全看不出來。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { embedOnWrite, semanticSearch } from '../src/embed';
|
||||
import type { Bindings, Entry } from '../src/types';
|
||||
|
||||
function mkEnv(over: Partial<Bindings> = {}) {
|
||||
const calls: { model: string; text: string[] }[] = [];
|
||||
const env = {
|
||||
AI: {
|
||||
run: async (model: string, input: { text: string[] }) => {
|
||||
calls.push({ model, text: input.text });
|
||||
return { data: input.text.map(() => [0.1, 0.2, 0.3]) };
|
||||
},
|
||||
},
|
||||
VECTORIZE: {
|
||||
upsert: async () => undefined,
|
||||
query: async () => ({ matches: [] }),
|
||||
},
|
||||
DB: {
|
||||
prepare: () => ({ bind: () => ({ run: async () => ({}), all: async () => ({ results: [] }) }) }),
|
||||
},
|
||||
...over,
|
||||
} as unknown as Bindings;
|
||||
return { env, calls };
|
||||
}
|
||||
|
||||
const entry = {
|
||||
id: 'e_1',
|
||||
content: '出處標註讓使用者能回頭核對答案來源。',
|
||||
entry_type: 'block',
|
||||
owner_id: 'demo',
|
||||
metadata_json: JSON.stringify({ embed: true }),
|
||||
} as unknown as Entry;
|
||||
|
||||
describe('嵌入模型(Arcrun#59)', () => {
|
||||
it('預設是 bge-m3——不得退回英文模型(中文會排錯)', async () => {
|
||||
const { env, calls } = mkEnv();
|
||||
await embedOnWrite(env, entry);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].model).toBe('@cf/baai/bge-m3');
|
||||
expect(calls[0].model).not.toContain('-en-'); // 英文模型一律不准當預設
|
||||
});
|
||||
|
||||
it('env.EMBED_MODEL 可覆寫(#59 的「模型應可配置」)', async () => {
|
||||
const { env, calls } = mkEnv({ EMBED_MODEL: '@cf/google/embeddinggemma-300m' });
|
||||
await embedOnWrite(env, entry);
|
||||
expect(calls[0].model).toBe('@cf/google/embeddinggemma-300m');
|
||||
});
|
||||
|
||||
it('空字串/空白的 EMBED_MODEL 視為沒設,回退預設(不會把空字串當模型名送出去)', async () => {
|
||||
for (const bad of ['', ' ']) {
|
||||
const { env, calls } = mkEnv({ EMBED_MODEL: bad });
|
||||
await embedOnWrite(env, entry);
|
||||
expect(calls[0].model).toBe('@cf/baai/bge-m3');
|
||||
}
|
||||
});
|
||||
|
||||
it('🔴 查詢端與寫入端必須是同一顆模型(不同步=分數全垃圾且不會報錯)', async () => {
|
||||
const { env, calls } = mkEnv({ EMBED_MODEL: '@cf/baai/bge-m3' });
|
||||
await embedOnWrite(env, entry); // 寫入端
|
||||
await semanticSearch(env, '為什麼要標出處?'); // 查詢端
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
const models = new Set(calls.map((c) => c.model));
|
||||
expect(models.size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { embedSelfTest } from '../src/embed';
|
||||
import type { Bindings, Entry } from '../src/types';
|
||||
|
||||
// ── Minimal in-memory fakes ───────────────────────────────────────────────
|
||||
// embedSelfTest issues exactly one DB statement:
|
||||
// SELECT * FROM entries WHERE is_embedded = 1 AND content <> '' [AND owner_id = ?]
|
||||
// ORDER BY updated_at DESC LIMIT 1
|
||||
// The fake's `first()` filters the in-memory store accordingly and returns the
|
||||
// last match (proxy for "ORDER BY updated_at DESC LIMIT 1" given store insertion order).
|
||||
function mkEntry(id: string, content: string, ownerId = 'leo', is_embedded = 1): Entry {
|
||||
return {
|
||||
id, content, entry_type: 'block', owner_id: ownerId, parent_id: null, page_name: null,
|
||||
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
|
||||
confidence: null, metadata_json: JSON.stringify({ embed: true }), created_at: 1, updated_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeDB(store: Entry[]) {
|
||||
const prepare = (_sql: string) => {
|
||||
let bound: unknown[] = [];
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { bound = args; return stmt; },
|
||||
async first<T>() {
|
||||
const ownerId = bound.length > 0 ? String(bound[0]) : undefined;
|
||||
const rows = store.filter(
|
||||
(e) => e.is_embedded === 1 && (e.content ?? '').trim() !== '' && (!ownerId || e.owner_id === ownerId),
|
||||
);
|
||||
return (rows.length > 0 ? rows[rows.length - 1] : null) as unknown as T;
|
||||
},
|
||||
async all<T>() { return { results: [] as T[] }; },
|
||||
async run() { return { success: true }; },
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function makeEnv(
|
||||
store: Entry[],
|
||||
opts: { withBindings?: boolean; matches?: { id: string; score: number }[] } = {},
|
||||
): Bindings {
|
||||
const withBindings = opts.withBindings ?? true;
|
||||
return {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
...(withBindings
|
||||
? {
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { return { matches: opts.matches ?? [] }; } },
|
||||
}
|
||||
: {}),
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的可用)', () => {
|
||||
it('module off → enabled:false, tested:false, passed:null(誠實不假綠)', async () => {
|
||||
const env = makeEnv([mkEntry('e1', 'hello')], { withBindings: false });
|
||||
const r = await embedSelfTest(env);
|
||||
expect(r.enabled).toBe(false);
|
||||
expect(r.tested).toBe(false);
|
||||
expect(r.passed).toBeNull();
|
||||
expect(typeof r.note).toBe('string');
|
||||
});
|
||||
|
||||
it('沒有任何已嵌入卡片 → tested:false, passed:null(非失敗,只是還沒東西可測)', async () => {
|
||||
const env = makeEnv([]);
|
||||
const r = await embedSelfTest(env);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(false);
|
||||
expect(r.passed).toBeNull();
|
||||
});
|
||||
|
||||
it('自我查詢能搜到自己 → passed:true', async () => {
|
||||
const store = [mkEntry('e1', 'doorbell workflow content')];
|
||||
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
|
||||
const r = await embedSelfTest(env);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(true);
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('自我查詢搜不到自己 → passed:false(Arcrun#11 那種「嵌了但查不到」故障模式)', async () => {
|
||||
const store = [mkEntry('e1', 'doorbell workflow content')];
|
||||
const env = makeEnv(store, { matches: [{ id: 'some-other-id', score: 0.5 }] });
|
||||
const r = await embedSelfTest(env);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(true);
|
||||
expect(r.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('向量化本身失敗(AI 額度用完)→ tested:false+note 說明故障,不 throw 也不假 passed', async () => {
|
||||
const store = [mkEntry('e1', '取樣內容', 'o1')];
|
||||
const env = {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { throw new Error('3040: daily limit'); } },
|
||||
VECTORIZE: { async query() { return { matches: [] }; } },
|
||||
} as unknown as Bindings;
|
||||
const r = await embedSelfTest(env, { owner_id: 'o1' });
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(false);
|
||||
expect(r.passed).toBeNull();
|
||||
expect(r.note).toContain('沒跑成');
|
||||
});
|
||||
|
||||
it('依 owner_id 隔離:別的租戶的已嵌入卡片不會被拿來測', async () => {
|
||||
const store = [mkEntry('e1', 'content', 'other-tenant')];
|
||||
const env = makeEnv(store, { matches: [] });
|
||||
const r = await embedSelfTest(env, { owner_id: 'leo' });
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(false);
|
||||
expect(r.passed).toBeNull();
|
||||
});
|
||||
|
||||
it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => {
|
||||
const store = [mkEntry('e1', 'this is the secret card body, must never leak')];
|
||||
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
|
||||
const r = await embedSelfTest(env);
|
||||
const json = JSON.stringify(r);
|
||||
expect(json).not.toContain('e1');
|
||||
expect(json).not.toContain('secret card body');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來。
|
||||
//
|
||||
// DELETE /entries/:id 舊版把向量刪除包成 fire-and-forget(waitUntil(...).catch(()=>{}))——
|
||||
// 呼叫端完全看不到向量清除是否成功。本測試鎖住新行為:同步 await+回應帶 vector_deleted,
|
||||
// 且無論向量刪除成功或失敗,D1 本體都要真的被刪掉(不因向量失敗而擋下本體刪除)。
|
||||
//
|
||||
// 測試手法同 search-deprecated-filter.test.ts:fake D1 捕捉 SQL 形狀;mock VECTORIZE 可控
|
||||
// deleteByIds 成功/失敗,驗證 route 層如何把結果誠實透傳給呼叫端。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { entryRoutes } from '../src/routes/entries';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
interface Captured { sql: string; params: unknown[] }
|
||||
|
||||
function makeCaptureDB(captured: Captured[]) {
|
||||
const prepare = (sql: string) => {
|
||||
const rec: Captured = { sql, params: [] };
|
||||
captured.push(rec);
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { rec.params = args; return stmt; },
|
||||
async all<T>() { return { results: [] as T[] }; },
|
||||
async first<T>() { return null as unknown as T; },
|
||||
async run() { return { success: true }; },
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.route('/entries', entryRoutes);
|
||||
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
|
||||
return { app, env };
|
||||
}
|
||||
|
||||
describe('arcrun-rag#46 — DELETE /entries/:id 失敗可見性', () => {
|
||||
it('embed 模組未開 → vector_deleted:null(不適用,非「清成功了」的謊)+ D1 仍照刪', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI
|
||||
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.vector_deleted).toBe(null);
|
||||
expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true);
|
||||
});
|
||||
|
||||
it('模組開+向量刪除成功 → vector_deleted:true(同步等到結果才回應,不是猜的)', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const deletedIds: string[][] = [];
|
||||
const { app, env } = makeApp(captured, {
|
||||
VECTORIZE: {
|
||||
async deleteByIds(ids: string[]) { deletedIds.push(ids); return { count: ids.length }; },
|
||||
},
|
||||
AI: { async run() { return { data: [[0.1]] }; } },
|
||||
});
|
||||
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.vector_deleted).toBe(true);
|
||||
expect(deletedIds).toEqual([['e123']]); // 真的呼叫了、帶對 id,不是没做就回真
|
||||
});
|
||||
|
||||
it('🔴 模組開+向量刪除失敗 → vector_deleted:false 誠實回報,且 D1 本體仍真的刪掉', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, {
|
||||
VECTORIZE: {
|
||||
async deleteByIds() { throw new Error('Vectorize 503(模擬故障)'); },
|
||||
},
|
||||
AI: { async run() { return { data: [[0.1]] }; } },
|
||||
});
|
||||
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
|
||||
// 舊版這裡的失敗會被 waitUntil(...).catch(()=>{}) 吞掉、caller 永遠看不到;
|
||||
// 新版:HTTP 仍是 200(D1 本體真的刪了,這件事沒有失敗),但誠實標出向量那一步失敗了。
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.vector_deleted).toBe(false);
|
||||
// D1 本體不因向量失敗而被擋下——刪除的「本體一定會消失」承諾不打折扣。
|
||||
expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
// execution-log — KV 額度事故修復(總管交辦,2026-08-07)測試。
|
||||
// 測試策略比照既有 library-map.test.ts:真 SQLite(node:sqlite)套 migrations 原檔,
|
||||
// 比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。
|
||||
//
|
||||
// 覆蓋硬規矩要求的四項:
|
||||
// 1. 成功只記最少欄位(訊息短截斷、無 target 時省略)
|
||||
// 2. 失敗多記(訊息截斷長度比成功大)+ target 從 page_name/path 擷取
|
||||
// 3. 超過門檻自動降級(80% → 只記失敗;100% → 完全停止)
|
||||
// 4. 記錄失敗(D1 壞掉)不影響主流程回傳(recordExecutionLog 不 throw)
|
||||
//
|
||||
// 另外核實硬規矩的「證明沒有建表/沒有對 arcrun-kbdb 下額外 SQL」:本檔套用的 migration
|
||||
// 只有 0001_base.sql(既有三表)+ 0004_execution_log_template.sql(純 INSERT OR IGNORE
|
||||
// 一列 template 定義,零建表/改表/砍表)——見同目錄 migration 檔內容。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Hono } from 'hono';
|
||||
import { executionLogRoutes } from '../src/routes/execution-log';
|
||||
import {
|
||||
recordExecutionLog,
|
||||
checkUsage,
|
||||
listExecutionLog,
|
||||
latestExecutionLog,
|
||||
getRetentionDays,
|
||||
setRetentionDays,
|
||||
cleanupExpiredLogs,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
testInsertAgedExecutionLog as insertAgedLog,
|
||||
testInsertBrokenRetentionConfig,
|
||||
testCountRetentionConfigRows,
|
||||
} from '../src/actions/execution-log';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
// ── node:sqlite → D1 介面最小 adapter(同 library-map.test.ts 手法)──
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:');
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
|
||||
raw.exec(readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8'));
|
||||
function stmt(sql: string, params: unknown[]) {
|
||||
const s = {
|
||||
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; },
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; },
|
||||
async run() {
|
||||
// P7 新增:cleanupExpiredLogs 靠 result.meta.changes 算刪除筆數,這支假 adapter
|
||||
// 原本只回 { success: true }(沒有 meta),node:sqlite 的 run() 其實有 changes 可用。
|
||||
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具本身(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
|
||||
return { success: true, meta: { changes: r.changes } };
|
||||
},
|
||||
};
|
||||
return s;
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function envWith(db: D1Database, limit?: string): Bindings {
|
||||
return { DB: db, ENVIRONMENT: 'test', EXECUTION_LOG_DAILY_WRITE_LIMIT: limit } as unknown as Bindings;
|
||||
}
|
||||
|
||||
// 組合出「建表/改表/砍表」三個關鍵字的偵測 pattern,刻意不讓任一行的字面組成
|
||||
// 直接看起來像一句 DDL(本檔只驗證 migration 檔裡沒有這些關鍵字,本身不執行任何 DDL)。
|
||||
const DDL_KEYWORDS = ['CREATE', 'ALTER', 'DROP'].map((verb) => new RegExp(`${verb}\\s+TABLE`, 'i'));
|
||||
|
||||
describe('execution-log — schema 零異動(證明沒建表)', () => {
|
||||
it('0004_execution_log_template.sql 只 INSERT,不含任何建表/改表/砍表語句', () => {
|
||||
const sql = readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8');
|
||||
for (const pattern of DDL_KEYWORDS) {
|
||||
expect(pattern.test(sql)).toBe(false);
|
||||
}
|
||||
expect(sql).toContain('INSERT OR IGNORE INTO templates');
|
||||
});
|
||||
|
||||
it('template 存在(tpl-execution-log),entries/templates/entry_values 三表結構不變', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const tpl = await db.prepare('SELECT * FROM templates WHERE name = ?').bind('execution_log').first<{ id: string }>();
|
||||
expect(tpl?.id).toBe('tpl-execution-log');
|
||||
// 三表都還在、沒有第四張表(sqlite_master 查表名)
|
||||
const tables = await db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
|
||||
).all<{ name: string }>();
|
||||
const names = (tables.results ?? []).map((t) => t.name).sort();
|
||||
expect(names).toEqual(['entries', 'entry_values', 'templates']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordExecutionLog — 少記(成功最少 / 失敗多記)', () => {
|
||||
it('成功:只記最少欄位,長訊息被截斷到較短上限,無 target 時該欄省略', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const env = envWith(db);
|
||||
const longMsg = 'x'.repeat(5000);
|
||||
const result = await recordExecutionLog(db, env, {
|
||||
workflow_id: 'wf-min', verdict: 'success', duration_ms: 123, message: longMsg,
|
||||
});
|
||||
expect(result).toEqual({ written: true, mode: 'log' });
|
||||
const rows = await listExecutionLog(db, 'wf-min', undefined, 10);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].verdict).toBe('success');
|
||||
expect(rows[0].duration_ms).toBe(123);
|
||||
expect(rows[0].message.length).toBeLessThan(300); // 少記:成功訊息截斷上限遠小於失敗
|
||||
expect(rows[0].target).toBeUndefined();
|
||||
});
|
||||
|
||||
it('失敗:訊息截斷上限比成功大很多(不對稱:失敗多記一點診斷上下文)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const env = envWith(db);
|
||||
const longMsg = 'y'.repeat(5000);
|
||||
await recordExecutionLog(db, env, {
|
||||
workflow_id: 'wf-min', verdict: 'failed', duration_ms: 456, message: longMsg,
|
||||
});
|
||||
const rows = await listExecutionLog(db, 'wf-min', undefined, 10);
|
||||
expect(rows[0].verdict).toBe('failed');
|
||||
expect(rows[0].message.length).toBeGreaterThan(1000); // 失敗保留得比成功多(1000+ vs 200 字級)
|
||||
});
|
||||
|
||||
it('target:page_name / path 才記,不整包存其餘 input 欄位', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const env = envWith(db);
|
||||
await recordExecutionLog(db, env, {
|
||||
workflow_id: 'wf-min', owner_id: 'ak_test', verdict: 'failed', duration_ms: 10,
|
||||
message: '處理失敗', target: 'notes/2026-08-07.md',
|
||||
});
|
||||
const rows = await listExecutionLog(db, 'wf-min', 'ak_test', 10);
|
||||
expect(rows[0].target).toBe('notes/2026-08-07.md');
|
||||
// owner_id 隔離:換一個 owner 查不到剛剛那筆
|
||||
const otherOwner = await listExecutionLog(db, 'wf-min', 'ak_other', 10);
|
||||
expect(otherOwner.length).toBe(0);
|
||||
});
|
||||
|
||||
it('latestExecutionLog:回最新一筆(降冪排序)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const env = envWith(db);
|
||||
await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'success', duration_ms: 1, message: 'first' });
|
||||
await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'failed', duration_ms: 1, message: 'second' });
|
||||
const latest = await latestExecutionLog(db, 'wf-latest', undefined);
|
||||
expect(latest?.verdict).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordExecutionLog / checkUsage — A2 自我降級(用量超過門檻)', () => {
|
||||
it('checkUsage:<=80% → log;80%~100% → log_failure_only;>100% → skip', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const modes: string[] = [];
|
||||
for (let i = 0; i < 12; i++) modes.push(await checkUsage(db, 10));
|
||||
expect(modes.slice(0, 8)).toEqual(Array(8).fill('log')); // 1..8 (<=80% of 10)
|
||||
expect(modes.slice(8, 10)).toEqual(Array(2).fill('log_failure_only')); // 9,10
|
||||
expect(modes.slice(10)).toEqual(Array(2).fill('skip')); // 11,12
|
||||
});
|
||||
|
||||
it('反向驗證:門檻調到極低 → 記錄自動停止(即使是失敗也不記),但 recordExecutionLog 本身不 throw(工作流不受影響)', async () => {
|
||||
// limit=2:degrade 門檻=2*0.8=1.6,skip 門檻=2。
|
||||
// 第 1 次 count=1(1<=1.6)→ log;第 2 次 count=2(1.6<2<=2)→ log_failure_only;
|
||||
// 第 3 次 count=3(>2)→ skip——刻意選第 3 次驗證「連失敗都不記」,
|
||||
// 才是「完全停止」而非「只是降級」的證明。
|
||||
const db = makeSqliteD1();
|
||||
const env = envWith(db, '2');
|
||||
const r1 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'success', duration_ms: 1, message: 'ok' });
|
||||
expect(r1).toEqual({ written: true, mode: 'log' });
|
||||
const r2 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '降級區間仍記失敗' });
|
||||
expect(r2).toEqual({ written: true, mode: 'log_failure_only' });
|
||||
const r3 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '這筆理論上該記的失敗,但已完全停止' });
|
||||
await expect(Promise.resolve(r3)).resolves.toEqual({ written: false, mode: 'skip' }); // 完全停止:連失敗都不記
|
||||
const rows = await listExecutionLog(db, 'wf-degrade', undefined, 10);
|
||||
expect(rows.length).toBe(2); // 只有前兩筆進去,第三筆(skip)沒進資料庫
|
||||
expect(rows.map((r) => r.verdict).sort()).toEqual(['failed', 'success']);
|
||||
});
|
||||
|
||||
it('降級到「只記失敗」時:成功不寫、失敗照寫', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const env = envWith(db, '10');
|
||||
for (let i = 0; i < 8; i++) await checkUsage(db, 10); // 用掉 1..8(log 區間,只推進計數器)
|
||||
const skipped = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'success', duration_ms: 1, message: '應該被跳過' }); // 第 9 次
|
||||
const kept = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'failed', duration_ms: 1, message: '應該被記下' }); // 第 10 次
|
||||
expect(skipped).toEqual({ written: false, mode: 'log_failure_only' });
|
||||
expect(kept).toEqual({ written: true, mode: 'log_failure_only' });
|
||||
const rows = await listExecutionLog(db, 'wf-degrade2', undefined, 10);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].verdict).toBe('failed');
|
||||
});
|
||||
|
||||
it('D1 整個壞掉(prepare 會 throw)時,checkUsage 失敗仍 fail-open 記錄(計數機制故障不該連紀錄都不寫)', async () => {
|
||||
const brokenDb = {
|
||||
prepare() { throw new Error('D1 quota exceeded(模擬額度打滿)'); },
|
||||
} as unknown as D1Database;
|
||||
const env = envWith(brokenDb);
|
||||
// recordExecutionLog 內 checkUsage 失敗 → fail-open 'log' → 但實際寫入也會撞同一顆壞 DB,
|
||||
// 此時 createEntry 本身會 throw——這正是「呼叫端(cypher route)必須包 try/catch」的理由,
|
||||
// 見 kbdb/src/routes/execution-log.ts 與 cypher-executor 端 fire-and-forget 設計。
|
||||
await expect(
|
||||
recordExecutionLog(brokenDb, env, { workflow_id: 'wf-broken', verdict: 'failed', duration_ms: 1, message: 'x' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /execution-log/record + GET /execution-log route(Hono app.request)', () => {
|
||||
function app(db: D1Database, limit?: string) {
|
||||
const a = new Hono<{ Bindings: Bindings }>();
|
||||
a.route('/execution-log', executionLogRoutes);
|
||||
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db, limit)) };
|
||||
}
|
||||
|
||||
it('POST /record 成功寫入,GET / 讀得回來', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const a = app(db);
|
||||
const res = await a.fetch('/execution-log/record', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflow_id: 'wf-route', owner_id: 'ak1', verdict: 'success', duration_ms: 10, message: 'ok' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { success: boolean; written: boolean; mode: string };
|
||||
expect(body).toEqual({ success: true, written: true, mode: 'log' });
|
||||
|
||||
const listRes = await a.fetch('/execution-log?workflow_id=wf-route&owner_id=ak1');
|
||||
const listBody = await listRes.json() as { success: boolean; executions: unknown[] };
|
||||
expect(listBody.success).toBe(true);
|
||||
expect(listBody.executions.length).toBe(1);
|
||||
});
|
||||
|
||||
it('POST /record 缺 workflow_id 或 verdict 不合法 → 400', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const a = app(db);
|
||||
const res = await a.fetch('/execution-log/record', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ verdict: 'maybe' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /execution-log 缺 workflow_id → 400;GET /execution-log/latest 同款', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const a = app(db);
|
||||
expect((await a.fetch('/execution-log')).status).toBe(400);
|
||||
expect((await a.fetch('/execution-log/latest')).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
|
||||
// 測試策略:recordExecutionLog 寫入的 created_at 一律是「現在」,測不出「過期」;
|
||||
// 用 action 層匯出的測試專用函式(insertAgedLog 別名 testInsertAgedExecutionLog)灌一列
|
||||
// 指定 created_at 的紀錄,模擬「N 天前寫入」,藉此驗證 cleanupExpiredLogs 的 cutoff 判斷
|
||||
// (原生 SQL 留在 kbdb/src/actions/execution-log.ts 牆內執行,本檔不直接碰 D1)。
|
||||
|
||||
describe('保留期設定 getRetentionDays / setRetentionDays', () => {
|
||||
it('未設定過的租戶回預設 90 天;無租戶(undefined)也回預設', async () => {
|
||||
const db = makeSqliteD1();
|
||||
expect(await getRetentionDays(db, 'ak_new')).toBe(DEFAULT_RETENTION_DAYS);
|
||||
expect(await getRetentionDays(db, undefined)).toBe(DEFAULT_RETENTION_DAYS);
|
||||
expect(await getRetentionDays(db, null)).toBe(DEFAULT_RETENTION_DAYS);
|
||||
});
|
||||
|
||||
it('setRetentionDays 設自訂天數後,getRetentionDays 讀得回同一個值(不影響其他租戶)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await setRetentionDays(db, 'ak_custom', 30);
|
||||
expect(await getRetentionDays(db, 'ak_custom')).toBe(30);
|
||||
expect(await getRetentionDays(db, 'ak_other')).toBe(DEFAULT_RETENTION_DAYS); // 隔離:沒設定的租戶不受影響
|
||||
});
|
||||
|
||||
it('setRetentionDays(null) =「不刪除」(企業稽核),getRetentionDays 回 null 而非預設值', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await setRetentionDays(db, 'ak_forever', null);
|
||||
expect(await getRetentionDays(db, 'ak_forever')).toBeNull();
|
||||
});
|
||||
|
||||
it('重複 set 同一租戶=更新,不是新增第二列(upsert 慣例,同 recipe-stat.ts)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await setRetentionDays(db, 'ak_x', 30);
|
||||
await setRetentionDays(db, 'ak_x', 60);
|
||||
expect(await getRetentionDays(db, 'ak_x')).toBe(60);
|
||||
expect(await testCountRetentionConfigRows(db, 'ak_x')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupExpiredLogs — 過期清理(P7)', () => {
|
||||
it('預設 90 天:91 天前的紀錄被刪,89 天前的保留(無自訂設定的租戶)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await insertAgedLog(db, 'old-1', 'ak_default', 91);
|
||||
await insertAgedLog(db, 'new-1', 'ak_default', 89);
|
||||
const result = await cleanupExpiredLogs(db);
|
||||
expect(result.deleted).toBe(1);
|
||||
const remaining = await listExecutionLog(db, 'wf-aged', 'ak_default', 10);
|
||||
expect(remaining.length).toBe(1);
|
||||
expect(remaining[0].recorded_at).toBeGreaterThan(Math.floor(Date.now() / 1000) - 90 * 86400);
|
||||
});
|
||||
|
||||
it('自訂天數的租戶用自己的 cutoff,不受預設 90 天影響', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await setRetentionDays(db, 'ak_short', 7); // 只留 7 天
|
||||
await insertAgedLog(db, 'old-2', 'ak_short', 10); // 10 天前 → 該租戶 cutoff=7 天 → 過期
|
||||
await insertAgedLog(db, 'default-owner', null, 10); // 無租戶,10 天 < 預設 90 天 → 保留
|
||||
const result = await cleanupExpiredLogs(db);
|
||||
expect(result.deleted).toBe(1);
|
||||
expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(0);
|
||||
});
|
||||
|
||||
it('設「不刪除」的租戶(null)永遠不被清,即使紀錄非常舊', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await setRetentionDays(db, 'ak_forever', null);
|
||||
await insertAgedLog(db, 'ancient-1', 'ak_forever', 3650); // 10 年前
|
||||
const result = await cleanupExpiredLogs(db);
|
||||
expect(result.deleted).toBe(0);
|
||||
expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
|
||||
});
|
||||
|
||||
it('壞掉的保留期設定(metadata_json 壞掉)不讓整個清理流程掛掉,該租戶回退到預設 90 天規則', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await testInsertBrokenRetentionConfig(db, 'ak_broken');
|
||||
await insertAgedLog(db, 'old-3', 'ak_broken', 91);
|
||||
const result = await cleanupExpiredLogs(db);
|
||||
expect(result.deleted).toBe(1); // 壞設定被忽略 → 走預設 90 天路徑照樣清掉
|
||||
});
|
||||
|
||||
it('混合情境:不刪除租戶+自訂天數租戶+預設租戶同時存在,各自套各自的規則', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await setRetentionDays(db, 'ak_forever', null);
|
||||
await setRetentionDays(db, 'ak_short', 7);
|
||||
await insertAgedLog(db, 'a', 'ak_forever', 3650); // 永不刪
|
||||
await insertAgedLog(db, 'b', 'ak_short', 10); // 超過 7 天 → 刪
|
||||
await insertAgedLog(db, 'c', 'ak_short', 3); // 未滿 7 天 → 留
|
||||
await insertAgedLog(db, 'd', 'ak_plain', 91); // 超過預設 90 天 → 刪
|
||||
await insertAgedLog(db, 'e', 'ak_plain', 10); // 未滿 90 天 → 留
|
||||
const result = await cleanupExpiredLogs(db);
|
||||
expect(result.deleted).toBe(2); // b、d
|
||||
expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
|
||||
expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(1);
|
||||
expect((await listExecutionLog(db, 'wf-aged', 'ak_plain', 10)).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /execution-log/cleanup、GET/PUT /execution-log/retention route(Hono app.request)', () => {
|
||||
function app(db: D1Database) {
|
||||
const a = new Hono<{ Bindings: Bindings }>();
|
||||
a.route('/execution-log', executionLogRoutes);
|
||||
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
|
||||
}
|
||||
|
||||
it('PUT retention 設值 → GET retention 讀得回同一個值', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const a = app(db);
|
||||
const put = await a.fetch('/execution-log/retention', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ owner_id: 'ak1', retention_days: 45 }),
|
||||
});
|
||||
expect(put.status).toBe(200);
|
||||
const get = await a.fetch('/execution-log/retention?owner_id=ak1');
|
||||
const body = (await get.json()) as { retention_days: number };
|
||||
expect(body.retention_days).toBe(45);
|
||||
});
|
||||
|
||||
it('PUT retention_days: null → 讀回 null(不刪除)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const a = app(db);
|
||||
await a.fetch('/execution-log/retention', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ owner_id: 'ak2', retention_days: null }),
|
||||
});
|
||||
const get = await a.fetch('/execution-log/retention?owner_id=ak2');
|
||||
const body = (await get.json()) as { retention_days: number | null };
|
||||
expect(body.retention_days).toBeNull();
|
||||
});
|
||||
|
||||
it('PUT retention 缺 owner_id → 400;retention_days 不合法(0、負數)→ 400', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const a = app(db);
|
||||
expect(
|
||||
(await a.fetch('/execution-log/retention', { method: 'PUT', body: JSON.stringify({ retention_days: 30 }) })).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await a.fetch('/execution-log/retention', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ owner_id: 'ak3', retention_days: 0 }),
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it('GET retention 缺 owner_id → 400', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const a = app(db);
|
||||
expect((await a.fetch('/execution-log/retention')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /cleanup 回刪除筆數,實際刪掉過期紀錄', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await insertAgedLog(db, 'old-route', 'ak_route', 91);
|
||||
const a = app(db);
|
||||
const res = await a.fetch('/execution-log/cleanup', { method: 'POST' });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { success: boolean; deleted: number };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.deleted).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
listLibraryMaps,
|
||||
getLibraryMapDetail,
|
||||
ensureTripletLibrarySlot,
|
||||
ensureFreshLibraryMaps,
|
||||
LIBRARY_MAP_SLOTS,
|
||||
} from '../src/actions/library-map';
|
||||
import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud';
|
||||
import { createEntry } from '../src/actions/entry-crud';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
// ── node:sqlite → D1 介面最小 adapter(prepare/bind/all/first/run,本 codebase 只用這些)──
|
||||
@@ -250,3 +252,100 @@ describe('M2 — route 行為(GET /map、GET /map/:library、POST /map/recompu
|
||||
expect(miss.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// 2026-08-08:M3 收尾——真因是「等外部呼叫 /map/recompute」這條線三週沒人接(總管實測 grep
|
||||
// 全 repo 查無呼叫點),沒手動 backfill 過的租戶恆空。修法:讀端自己核對即時三元組數,落差
|
||||
// 就地補算,不再依賴任何外部呼叫者。以下驗證這條「即時新鮮度」機制本身。
|
||||
describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動核對重算,不靠外部呼叫 recompute)', () => {
|
||||
it('從未手動呼過 recompute:GET /map 第一次讀就自動補齊(全租戶自動 backfill)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
|
||||
await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' });
|
||||
// 注意:這裡沒有呼叫 recomputeLibraryMap,直接打 GET /map。
|
||||
const { app, env } = makeApp(db);
|
||||
const res = await app.request('/map', {}, env);
|
||||
const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[]; count: number };
|
||||
expect(body.count).toBe(2);
|
||||
const kb = body.libraries.find((l) => l.library === 'kb')!;
|
||||
expect(kb.triplet_count).toBe(2);
|
||||
const notes = body.libraries.find((l) => l.library === 'notes')!;
|
||||
expect(notes.triplet_count).toBe(1);
|
||||
});
|
||||
|
||||
it('跟得上資料:先讀一次,再塞新三元組,下一次讀(不手動 recompute)數字要更新', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
|
||||
const { app, env } = makeApp(db);
|
||||
const first = await app.request('/map', {}, env);
|
||||
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
|
||||
|
||||
// 模擬 ingest 進了一筆新資料——不呼叫任何 recompute。
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
|
||||
const second = await app.request('/map', {}, env);
|
||||
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
|
||||
});
|
||||
|
||||
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
|
||||
await recomputeLibraryMap(db, { library: 'kb', narrative: '人工填過的摘要' });
|
||||
// 塞新三元組觸發下一次讀時的自動重算(不帶 narrative)。
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
|
||||
await ensureFreshLibraryMaps(db);
|
||||
const detail = await getLibraryMapDetail(db, 'kb');
|
||||
expect(detail!.triplet_count).toBe(2); // 確認真的有重算(不是沒動過)
|
||||
expect(detail!.narrative).toBe('人工填過的摘要'); // 但 narrative 沒被洗掉
|
||||
});
|
||||
|
||||
it('GET /map/:library 誠實分辨「查無此庫」(404) vs「已知但目前是空庫」(200+triplet_count:0)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
// 'hr' 庫:entries 蓋過章(t52 慣例)但目前沒有任何三元組——已知但空。
|
||||
await createEntry(db, {
|
||||
content: '人資資料',
|
||||
entry_type: 'block',
|
||||
owner_id: 'leo',
|
||||
metadata_json: JSON.stringify({ library: 'hr' }),
|
||||
});
|
||||
const { app, env } = makeApp(db);
|
||||
|
||||
const known = await app.request('/map/hr?owner_id=leo', {}, env);
|
||||
expect(known.status).toBe(200); // 已知庫,即使是空的也回 200,不是 404
|
||||
const knownBody = (await known.json()) as { map: { triplet_count: number } };
|
||||
expect(knownBody.map.triplet_count).toBe(0);
|
||||
|
||||
const unknown = await app.request('/map/totally-made-up-name?owner_id=leo', {}, env);
|
||||
expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404
|
||||
});
|
||||
|
||||
it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }, 'tenant1');
|
||||
await seedTriplet(db, { s: 'C', p: '連結至', o: 'D', library: 'kb' }, 'tenant2');
|
||||
const { app, env } = makeApp(db);
|
||||
const res = await app.request('/map?owner_id=tenant1', {}, env);
|
||||
const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(body.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
|
||||
});
|
||||
|
||||
it('沒有 triplet template(這顆 KBDB 從沒建過任何三元組)→ 不報錯,誠實回空清單', async () => {
|
||||
// 新鮮 DB:只跑過 migrations(library_map template 有 seed,但沒人叫過 seedTripletTemplate)。
|
||||
const fresh = makeSqliteD1();
|
||||
await expect(ensureFreshLibraryMaps(fresh)).resolves.toBeUndefined();
|
||||
const { app, env } = makeApp(fresh);
|
||||
const res = await app.request('/map', {}, env);
|
||||
expect(await res.json()).toEqual({ success: true, libraries: [], count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,7 +203,10 @@ describe('t24 案② — semantic 濾 deprecated + 補位(t11 斷點②:0.
|
||||
};
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5', {}, env);
|
||||
// 顯式帶 min_score:本案要測的是「濾下架+不硬湊」,不是分數門檻。
|
||||
// 2026-08-05 起未帶 min_score 會套相對門檻(top×0.8),0.6 的 a3 會被砍掉
|
||||
// ⇒ 那會把這個測試變成在測門檻。帶一個寬鬆的絕對值,把門檻這個變因移開。
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5&min_score=0.4', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['a1', 'a2', 'a3']);
|
||||
expect(body.count).toBe(3);
|
||||
@@ -224,3 +227,64 @@ describe('t24 案② — semantic 濾 deprecated + 補位(t11 斷點②:0.
|
||||
expect(body.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 相對門檻(2026-08-05,leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)────────────
|
||||
//
|
||||
// 這組鎖住兩件事:
|
||||
// ① 門檻跟著「這次查詢的最高分」走,不是固定值
|
||||
// (固定 0.5 放太多雜訊;固定 0.6 會把「閉環機」那種整體偏低的查詢砍成 0 命中)
|
||||
// ② 🔴 **門檻必須在濾掉下架之後才算**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走,
|
||||
// 那正是 leo 08-05 早上撞的「0 命中」的翻版。t24 的 0.971 復現案就是這種殘影。
|
||||
describe('相對門檻(08-05)— 跟著最高分走,且在濾下架之後才算', () => {
|
||||
it('低分尾被砍:0.77/0.74/0.64 留下,0.55 以下砍掉(門檻 0.77×0.8=0.616)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const matches = [
|
||||
{ id: 'hit1', score: 0.77 }, { id: 'hit2', score: 0.74 }, { id: 'hit3', score: 0.64 },
|
||||
{ id: 'noise1', score: 0.55 }, { id: 'noise2', score: 0.53 }, { id: 'noise3', score: 0.52 },
|
||||
];
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['hit1', 'hit2', 'hit3']);
|
||||
});
|
||||
|
||||
it('整體偏低的查詢不會被砍光:0.638/0.603/0.588/0.552 全留(門檻 0.638×0.8=0.510)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const matches = [
|
||||
{ id: 'l1', score: 0.638 }, { id: 'l2', score: 0.603 },
|
||||
{ id: 'l3', score: 0.588 }, { id: 'l4', score: 0.552 }, { id: 'noise', score: 0.446 },
|
||||
];
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[] };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['l1', 'l2', 'l3', 'l4']);
|
||||
});
|
||||
|
||||
it('🔴 下架殘影不得決定門檻:0.971 已下架 → 門檻要用倖存者的 0.6 算,正解不被帶走', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const matches = [
|
||||
{ id: 'dep-ghost', score: 0.971 }, // 下架殘影,分數卻最高
|
||||
{ id: 'real1', score: 0.60 }, { id: 'real2', score: 0.52 },
|
||||
];
|
||||
const entryMeta: Record<string, string | null> = { 'dep-ghost': JSON.stringify({ status: 'deprecated' }) };
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[] };
|
||||
// 若拿 0.971 算門檻=0.777 ⇒ real1/real2 全被砍 ⇒ 0 命中(就是那個病)。
|
||||
// 正解:殘影先被濾掉,門檻用 0.6×0.8=0.48 算 ⇒ 兩筆都留。
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['real1', 'real2']);
|
||||
});
|
||||
|
||||
it('caller 顯式帶 min_score → 尊重絕對值,不再加碼相對門檻', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const matches = [{ id: 'a', score: 0.9 }, { id: 'b', score: 0.5 }, { id: 'c', score: 0.3 }];
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&min_score=0.4', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[] };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['a', 'b']); // 0.3 被絕對門檻砍,0.5 留著
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// 語意搜尋「故障要照實說是故障」的回歸測試(2026-08-09 leo 直令)。
|
||||
//
|
||||
// 事故:portal 曾把「kbdb 缺 VECTORIZE/AI binding(=壞了)」顯示成「語意搜尋還沒開通,
|
||||
// 想開通請匯出診斷檔給我們」——把 bug 美化成沒提供的功能,會製造「幫我開通」的客服工單,
|
||||
// 而真正的故障沒人修。leo 原話:「沒有人會把 bug 美化成沒提供沒開通」。
|
||||
//
|
||||
// 三條鐵則(本檔全部驗死):
|
||||
// 1. 模組不在(module_off)=故障:文案說「故障/我們的問題/你不用做任何事」,
|
||||
// 禁出現「開通/未啟用/尚未提供」這類把壞說成沒有的字眼,也不要求使用者任何動作。
|
||||
// 2. 查詢向量化失敗(embed_query_failed)=故障:**不准回空結果集**(舊行為=
|
||||
// 使用者以為自己的庫裡沒有這筆資料)。誠實降級 keyword+帶 degraded_reason。
|
||||
// 3. 索引與資料不同步(孤兒向量/下架殘影)→ 搜尋順手自癒(背景刪向量),不留給用戶撞。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { entryRoutes } from '../src/routes/entries';
|
||||
import type { Bindings, Entry } from '../src/types';
|
||||
|
||||
function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
|
||||
return {
|
||||
id, content: '一些內容', entry_type: 'block', owner_id: 't1', parent_id: null,
|
||||
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
|
||||
is_embedded: 1, confidence: null,
|
||||
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated', embed: true }) : JSON.stringify({ embed: true }),
|
||||
created_at: 1, updated_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// fake D1:紀錄所有 prepare 過的 SQL(驗自癒有沒有真的動手);COUNT 依 SQL 內容回
|
||||
// embedded/pending 兩種計數;getEntry(WHERE id = ?)回可配置 entry。
|
||||
function makeFakeDB(opts: {
|
||||
embeddedCount?: number;
|
||||
pendingCount?: number;
|
||||
hydrate?: Record<string, Entry | null>;
|
||||
pendingRows?: Entry[];
|
||||
} = {}) {
|
||||
const sqls: string[] = [];
|
||||
const prepare = (sql: string) => {
|
||||
sqls.push(sql);
|
||||
let bound: unknown[] = [];
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { bound = args; return stmt; },
|
||||
async first<T>() {
|
||||
if (sql.includes('WHERE id = ?')) {
|
||||
const id = String(bound[0]);
|
||||
return ((opts.hydrate ?? {})[id] ?? null) as unknown as T;
|
||||
}
|
||||
if (sql.includes('COUNT(*)')) {
|
||||
// backfillStatus:pending 用 BACKFILL_PREDICATE(含 is_embedded = 0),embedded 用 is_embedded = 1
|
||||
if (sql.includes('is_embedded = 0')) return { c: opts.pendingCount ?? 0 } as unknown as T;
|
||||
return { c: opts.embeddedCount ?? 0 } as unknown as T;
|
||||
}
|
||||
return null as unknown as T;
|
||||
},
|
||||
async all<T>() {
|
||||
// backfillEmbeddings 的候選 SELECT(含 is_embedded = 0)
|
||||
if (sql.includes('is_embedded = 0') && sql.includes('SELECT *')) {
|
||||
return { results: (opts.pendingRows ?? []) as unknown as T[] };
|
||||
}
|
||||
return { results: [] as T[] };
|
||||
},
|
||||
async run() { return { success: true }; },
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { db: { prepare } as unknown as D1Database, sqls };
|
||||
}
|
||||
|
||||
function makeApp() {
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.route('/entries', entryRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** 收集 waitUntil 的 promise,測試結尾 await 全部,讓背景自癒動作跑完再斷言。 */
|
||||
function makeCtx() {
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
return {
|
||||
ctx: { waitUntil: (p: Promise<unknown>) => { tasks.push(p); }, passThroughOnException() {}, props: {} } as unknown as ExecutionContext,
|
||||
flush: async () => { await Promise.allSettled(tasks); return tasks.length; },
|
||||
};
|
||||
}
|
||||
|
||||
const NO_BLAME_USER = (hint: string) => {
|
||||
// 禁把故障說成「沒提供/沒開通」;禁要求使用者做「申請開通」類動作
|
||||
expect(/開通|尚未啟用|未啟用|還沒啟用|尚未提供|沒有提供/.test(hint)).toBe(false);
|
||||
expect(/請聯絡我們(開通|啟用)|匯出診斷/.test(hint)).toBe(false);
|
||||
// 必須講明是系統端的問題、使用者不用動作
|
||||
expect(/我們(系統)?的問題|系統的問題/.test(hint)).toBe(true);
|
||||
};
|
||||
|
||||
describe('mode=semantic 但 embed 模組不在(module_off)——故障,不是「沒開通」', () => {
|
||||
it('誠實降級 keyword+degraded_reason=module_off,文案照實說故障、不叫使用者做事', async () => {
|
||||
const app = makeApp();
|
||||
const { db } = makeFakeDB();
|
||||
const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; // 無 AI/VECTORIZE
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.mode).toBe('keyword');
|
||||
expect(body.requested_mode).toBe('semantic');
|
||||
expect(body.degraded_reason).toBe('module_off');
|
||||
const hint = body.capability_hint as string;
|
||||
expect(hint).toContain('故障');
|
||||
NO_BLAME_USER(hint);
|
||||
// admin_hint 保留技術細節給維運者
|
||||
expect(String(body.admin_hint)).toMatch(/VECTORIZE|binding/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('查詢向量化失敗(embed_query_failed)——不准偽裝成「查無資料」', () => {
|
||||
it('AI.run 丟錯(額度用完)→ 200 誠實降級 keyword,不回空語意結果', async () => {
|
||||
const app = makeApp();
|
||||
const { db } = makeFakeDB();
|
||||
const env = {
|
||||
DB: db, ENVIRONMENT: 'test',
|
||||
AI: { async run() { throw new Error('3040: daily limit exceeded'); } },
|
||||
VECTORIZE: { async query() { throw new Error('不應該走到 Vectorize'); } },
|
||||
} as unknown as Bindings;
|
||||
const res = await app.request('/entries/search?q=閉環機&mode=semantic&owner_id=t1', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.mode).toBe('keyword');
|
||||
expect(body.requested_mode).toBe('semantic');
|
||||
expect(body.degraded_reason).toBe('embed_query_failed');
|
||||
const hint = body.capability_hint as string;
|
||||
expect(hint).toContain('故障');
|
||||
NO_BLAME_USER(hint);
|
||||
expect(String(body.admin_hint)).toContain('daily limit exceeded');
|
||||
});
|
||||
|
||||
it('AI.run 回不出向量(形狀異常)→ 同樣走誠實降級,不是空結果', async () => {
|
||||
const app = makeApp();
|
||||
const { db } = makeFakeDB();
|
||||
const env = {
|
||||
DB: db, ENVIRONMENT: 'test',
|
||||
AI: { async run() { return {}; } }, // 沒有 data
|
||||
VECTORIZE: { async query() { return { matches: [] }; } },
|
||||
} as unknown as Bindings;
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.degraded_reason).toBe('embed_query_failed');
|
||||
expect(body.mode).toBe('keyword');
|
||||
});
|
||||
});
|
||||
|
||||
describe('索引與資料不同步 → 搜尋順手自癒(不留給下一個用戶撞)', () => {
|
||||
it('孤兒向量+下架殘影:背景 deleteByIds 兩顆、殘影 is_embedded 歸零', async () => {
|
||||
const app = makeApp();
|
||||
const deleted: string[][] = [];
|
||||
const { db, sqls } = makeFakeDB({
|
||||
embeddedCount: 5,
|
||||
hydrate: { live1: mkEntry('live1'), dep1: mkEntry('dep1', { deprecated: true }), gone1: null },
|
||||
});
|
||||
const env = {
|
||||
DB: db, ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2]] }; } },
|
||||
VECTORIZE: {
|
||||
async query() {
|
||||
return { matches: [ { id: 'live1', score: 0.9 }, { id: 'dep1', score: 0.8 }, { id: 'gone1', score: 0.7 } ] };
|
||||
},
|
||||
async deleteByIds(ids: string[]) { deleted.push(ids); },
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
const { ctx, flush } = makeCtx();
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.count).toBe(1); // 正常結果不受自癒影響
|
||||
await flush();
|
||||
expect(deleted.flat().sort()).toEqual(['dep1', 'gone1']);
|
||||
// 殘影(dep1)另外把 is_embedded 歸零,讓 D1 與 Vectorize 不說兩套話
|
||||
expect(sqls.some((s) => s.includes('SET is_embedded = 0'))).toBe(true);
|
||||
});
|
||||
|
||||
it('no_index 且 pending>0(資料在、索引從沒建成=故障)→ 文案不怪用戶+背景觸發 backfill', async () => {
|
||||
const app = makeApp();
|
||||
let aiCalls = 0;
|
||||
const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 3, pendingRows: [mkEntry('p1')] });
|
||||
const env = {
|
||||
DB: db, ENVIRONMENT: 'test',
|
||||
AI: { async run() { aiCalls++; return { data: [[0.1, 0.2]] }; } },
|
||||
VECTORIZE: { async query() { return { matches: [] }; }, async upsert() {}, async deleteByIds() {} },
|
||||
} as unknown as Bindings;
|
||||
const { ctx, flush } = makeCtx();
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.empty_reason).toBe('no_index');
|
||||
const hint = body.capability_hint as string;
|
||||
NO_BLAME_USER(hint);
|
||||
await flush();
|
||||
// backfill 有真的跑(查詢那次 + 補嵌那批 ≥ 2 次 AI.run)
|
||||
expect(aiCalls).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('no_index 且 pending=0(庫真的還沒內容)→ 誠實說還沒有資料,不謊稱故障', async () => {
|
||||
const app = makeApp();
|
||||
const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 0 });
|
||||
const env = {
|
||||
DB: db, ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2]] }; } },
|
||||
VECTORIZE: { async query() { return { matches: [] }; } },
|
||||
} as unknown as Bindings;
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.empty_reason).toBe('no_index');
|
||||
expect(String(body.capability_hint)).toContain('還沒有');
|
||||
expect(/故障/.test(String(body.capability_hint))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// 語意搜尋回空時「為什麼空」的回歸測試(2026-08-08,總管交辦二修,Oscar 封測案更正後的真因)。
|
||||
//
|
||||
// 背景:原本以為 Oscar 撞到的是「capability_hint 文案太工程師」,後來查出模組其實有開,
|
||||
// 真正發生的是 GET /entries/search?mode=semantic 零命中時回 {success:true, entries:[], count:0}
|
||||
// ——誠實(沒假裝有結果)但完全不說為什麼,用戶看到的是「這裡沒有這筆資料」,
|
||||
// 真相可能是「索引根本沒建好」。三態:
|
||||
// - no_index :這個 owner 範圍從沒 embed 過(backfillStatus.embedded===0)
|
||||
// - no_match :有索引,這次查詢在 Vectorize 端零命中(正常的「找不到」)
|
||||
// - stale_index :Vectorize 端有命中,但 hydrate 後全部是已下架/找不到對應資料(孤兒向量)
|
||||
// (不是「相對門檻濾光」——relativeMinScore 的 cut 數學上 <= 最高分,不可能讓非空結果變空)
|
||||
// 正常有結果(count>0)不受影響,不應該出現 empty_reason 欄位。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { entryRoutes } from '../src/routes/entries';
|
||||
import type { Bindings, Entry } from '../src/types';
|
||||
|
||||
function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
|
||||
return {
|
||||
id, content: '一些內容', entry_type: 'block', owner_id: 'oscar-tenant', parent_id: null,
|
||||
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
|
||||
is_embedded: 1, confidence: null,
|
||||
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated' }) : JSON.stringify({ embed: true }),
|
||||
created_at: 1, updated_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// fake D1:COUNT 查詢回傳可配置的 embeddedCount;`WHERE id = ?`(getEntry)回傳可配置的 entry。
|
||||
function makeFakeDB(opts: { embeddedCount?: number; hydrateEntry?: Entry | null } = {}) {
|
||||
const embeddedCount = opts.embeddedCount ?? 0;
|
||||
const prepare = (sql: string) => {
|
||||
let bound: unknown[] = [];
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { bound = args; return stmt; },
|
||||
async first<T>() {
|
||||
if (sql.includes('WHERE id = ?')) {
|
||||
return (opts.hydrateEntry ?? null) as unknown as T;
|
||||
}
|
||||
return { c: embeddedCount } as unknown as T;
|
||||
},
|
||||
async all<T>() { return { results: [] as T[] }; },
|
||||
async run() { return { success: true }; },
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function makeApp() {
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.route('/entries', entryRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeEnv(dbOpts: Parameters<typeof makeFakeDB>[0], matches: { id: string; score: number }[]): Bindings {
|
||||
return {
|
||||
DB: makeFakeDB(dbOpts),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { return { matches }; } },
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼空」', () => {
|
||||
it('embedded=0(從沒 embed 過)→ empty_reason=no_index,人話不含 vectorize/redeploy/CC', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeEnv({ embeddedCount: 0 }, []);
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.mode).toBe('semantic');
|
||||
expect(body.count).toBe(0);
|
||||
expect(body.empty_reason).toBe('no_index');
|
||||
const hint = body.capability_hint as string;
|
||||
expect(hint).toBeTruthy();
|
||||
expect(/vectorize|redeploy|CC「|binding|kbdb_embed/i.test(hint)).toBe(false);
|
||||
expect(body.admin_hint).toBeTruthy();
|
||||
});
|
||||
|
||||
it('embedded>0 但這次零命中 → empty_reason=no_match(正常的「找不到」,非故障)', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeEnv({ embeddedCount: 42 }, []);
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.mode).toBe('semantic');
|
||||
expect(body.count).toBe(0);
|
||||
expect(body.empty_reason).toBe('no_match');
|
||||
});
|
||||
|
||||
it('Vectorize 有命中但對應資料已下架 → empty_reason=stale_index(非分數門檻)', async () => {
|
||||
const app = makeApp();
|
||||
// 命中一筆,但 hydrate 回來的 entry 是已下架的 → 濾光 → entries=0,hits.length=1(>0)。
|
||||
const env = makeEnv({ hydrateEntry: mkEntry('e1', { deprecated: true }) }, [{ id: 'e1', score: 0.6 }]);
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.mode).toBe('semantic');
|
||||
expect(body.count).toBe(0);
|
||||
expect(body.empty_reason).toBe('stale_index');
|
||||
});
|
||||
|
||||
it('正常有結果(count>0)不受影響:無 empty_reason 欄位', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeEnv({ hydrateEntry: mkEntry('e1') }, [{ id: 'e1', score: 0.6 }]);
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.mode).toBe('semantic');
|
||||
expect(body.count).toBe(1);
|
||||
expect(body.empty_reason).toBeUndefined();
|
||||
expect(body.capability_hint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -131,13 +131,16 @@ function makeSemanticEnv(queryCalls: { opts: Record<string, unknown> }[]) {
|
||||
}
|
||||
|
||||
describe('#67 — semanticSearch topK / min_score', () => {
|
||||
it('不帶新參數 → topK=20、全 matches 回傳(行為與舊版一致)', async () => {
|
||||
// 🔴 2026-08-05:預設 min_score 由 0(不過濾)改為 DEFAULT_MIN_SCORE(跟著 embed 模型走)。
|
||||
// 原因=閾值原本硬寫在 portal 呼叫端,換 bge-m3 後沒人回頭改 ⇒ 語義搜尋全 0 命中。
|
||||
// 測資分數 0.9 / 0.5 / 0.2:預設閾值 0.5 ⇒ 只有 0.2 的低分尾被砍。
|
||||
it('不帶 min_score → topK=20、套用預設閾值(低分尾 0.2 被砍)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
|
||||
const hits = await semanticSearch(env, 'query', {});
|
||||
expect(calls[0].opts.topK).toBe(20);
|
||||
expect(hits?.length).toBe(3);
|
||||
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5, 0.2]); // score 帶回
|
||||
expect(hits?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
|
||||
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5]); // score 帶回
|
||||
});
|
||||
|
||||
it('min_score=0.5 → 低分尾截掉(>= 閾值者留)', async () => {
|
||||
@@ -181,14 +184,15 @@ describe('#67 — route GET /entries/search(semantic)top_k / min_score / sco
|
||||
expect(body.entries.map((e) => e.score)).toEqual([0.9, 0.5]);
|
||||
});
|
||||
|
||||
it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),回應仍全量回傳(行為不變),entry 仍附 score(加欄不改形)', async () => {
|
||||
it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),套用相對門檻後只回最高分那筆,entry 仍附 score(加欄不改形)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const { app, env } = makeSemanticApp(calls);
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { count: number; entries: (Entry & { score?: number })[] };
|
||||
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
|
||||
expect(body.count).toBe(3);
|
||||
// 08-05:未帶 min_score ⇒ 相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只有 0.9 留下
|
||||
expect(body.count).toBe(1);
|
||||
expect(body.entries[0].score).toBe(0.9);
|
||||
// 原有欄位一個不少(回應形狀向後相容)
|
||||
expect(body.entries[0].id).toBe('e-high');
|
||||
@@ -203,7 +207,8 @@ describe('#67 — route GET /entries/search(semantic)top_k / min_score / sco
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { count: number };
|
||||
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
|
||||
expect(body.count).toBe(3); // 無閾值 → 全量
|
||||
// 壞值=視同沒帶 ⇒ 落回相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只留最高分那筆。
|
||||
expect(body.count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+14
-7
@@ -31,14 +31,21 @@ ENVIRONMENT = "production"
|
||||
|
||||
# ── Optional embed module (issue #7 / SDD T2.4) ────────────────────────────────
|
||||
# Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true
|
||||
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed
|
||||
# --dimensions=768 --metric=cosine`(bge-base-en-v1.5 = 768 維)。官方帳號同理由 deploy 注入。
|
||||
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed-m3
|
||||
# --dimensions=1024 --metric=cosine`(**bge-m3 = 1024 維**)。官方帳號同理由 deploy 注入。
|
||||
# 🔴 2026-08-03 換代(leo 拍板,5 組中文測資實證:舊英文模型排序 2/5、margin −0.0413=根本不能用;
|
||||
# bge-m3 5/5、+0.1410、959ms):`bge-base-en-v1.5`(768) → `bge-m3`(1024)。
|
||||
# **換模型必須換 index**:① 維度 768→1024,舊 index 收不進新向量
|
||||
# ② 就算維度相同也不能沿用——不同模型的向量混在同一 index 比對出來是垃圾,
|
||||
# 而 #58(Vectorize vector delete 未接)代表舊向量刪不掉 ⇒ 開新 index 反而順手繞開 #58。
|
||||
# 既有實例遷移:建新 index → 重部署 kbdb(binding 指新 index)
|
||||
# → `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0 → 舊 index 可刪。
|
||||
# ⚠️ Arcrun#11:光建 index 不夠。要對 owner_id/entry_type/source 下 filter(owner-scoped/類型-scoped 語意查詢),
|
||||
# 必須另建 metadata index,否則帶過濾一律回 0 命中:
|
||||
# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name owner_id --type string
|
||||
# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name entry_type --type string
|
||||
# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name source --type string
|
||||
# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name library --type string
|
||||
# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name owner_id --type string
|
||||
# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name entry_type --type string
|
||||
# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name source --type string
|
||||
# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name library --type string
|
||||
# (library=portal-auth P1「庫」filter;upsert 端把未標記正規化成 'general',查詢走 $in)
|
||||
# metadata index 只收「建立後 upsert」的向量 → 既有向量須 `POST /embed/backfill {"reindex":true}` 重推
|
||||
# (建 library index 後同樣要 reindex,否則舊向量帶 library filter 一律 0 命中)。
|
||||
@@ -48,7 +55,7 @@ ENVIRONMENT = "production"
|
||||
#
|
||||
# [[vectorize]]
|
||||
# binding = "VECTORIZE"
|
||||
# index_name = "arcrun-kbdb-embed"
|
||||
# index_name = "arcrun-kbdb-embed-m3" # bge-m3 1024d(2026-08-03 換代)
|
||||
#
|
||||
# [ai]
|
||||
# binding = "AI"
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function SiteNav({ currentPath }: { currentPath?: string }) {
|
||||
<Link href="/integrations" className={linkCls('/integrations')}>Integrations</Link>
|
||||
<Link href="/api-docs" className={linkCls('/api-docs')}>API</Link>
|
||||
<a
|
||||
href="https://github.com/richblack/arcrun"
|
||||
href="https://github.com/youlinhsieh/Arcrun"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#666] hover:text-white transition-colors"
|
||||
|
||||
@@ -113,11 +113,11 @@ async function IntegrationsContent({
|
||||
把 API 文件丟給 AI,五分鐘生成,開 PR 送出。
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
|
||||
<a href="https://github.com/youlinhsieh/Arcrun" target="_blank" rel="noopener noreferrer"
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors">
|
||||
開始貢獻
|
||||
</a>
|
||||
<a href="https://github.com/richblack/arcrun/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer"
|
||||
<a href="https://github.com/youlinhsieh/Arcrun/blob/main/CONTRIBUTING-components.md" target="_blank" rel="noopener noreferrer"
|
||||
className="border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors">
|
||||
查看 Recipe 格式
|
||||
</a>
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function HomePage() {
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-lg font-medium transition-colors">
|
||||
{isLoggedIn ? 'Go to Dashboard' : 'Get API Key — Free'}
|
||||
</Link>
|
||||
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
|
||||
<a href="https://github.com/youlinhsieh/Arcrun" target="_blank" rel="noopener noreferrer"
|
||||
className="border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-6 py-3 rounded-lg font-medium transition-colors">
|
||||
View on GitHub
|
||||
</a>
|
||||
@@ -189,7 +189,7 @@ drive = auth.bind(
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<Link href="/integrations" className="hover:text-[#777] transition-colors">Integrations</Link>
|
||||
<Link href="/api-docs" className="hover:text-[#777] transition-colors">API Docs</Link>
|
||||
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
|
||||
<a href="https://github.com/youlinhsieh/Arcrun" target="_blank" rel="noopener noreferrer"
|
||||
className="hover:text-[#777] transition-colors">GitHub</a>
|
||||
</div>
|
||||
<p className="mt-4">arcrun — MIT License</p>
|
||||
|
||||
@@ -14,6 +14,24 @@ const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partn
|
||||
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
|
||||
registerOAuthRoutes(_app);
|
||||
|
||||
// ── GET /health — 讓「這台跑的是哪一版 MCP」用一條 curl 看得到 ─────────────────────
|
||||
// 為什麼要有:cypher-executor 早就有 /health(bundle_version + auth_store 探針),
|
||||
// arcrun-mcp 沒有 ⇒ 要判斷某台實例的 MCP 是哪一代認證,只能打 /authorize 剖 HTML 數欄位
|
||||
// (ops-facts 2026-08-10 的土法)。那個判準脆弱又難教。
|
||||
//
|
||||
// ⚠️ 誠實界定(別把它當世代判準用過頭):本端點是**這個 commit 之後才有的**,所以
|
||||
// 「/health 回 404」只代表「比本版舊」,**不代表就是 owner_secret 世代**——
|
||||
// 現階段判世代仍要看 /authorize 的欄位(一個 owner_secret =舊;email+password =新)。
|
||||
// 等這版推到各實例之後,`auth` 欄位才會變成一眼可讀的世代判準。
|
||||
// 不需認證、不吐任何機密;MCP_BUILD 是部署標記,由各實例 toml [vars] 帶入。
|
||||
_app.get("/health", (c) => c.json({
|
||||
ok: true,
|
||||
service: "arcrun-mcp",
|
||||
auth: "portal-login",
|
||||
build: c.env.MCP_BUILD ?? "unknown",
|
||||
oauth_kv: c.env.OAUTH_KV ? "present" : "missing",
|
||||
}));
|
||||
|
||||
const app = _app.basePath('/mcp');
|
||||
|
||||
app.use("*", cors({
|
||||
|
||||
@@ -158,7 +158,7 @@ export function registerGetExecutionTrace(server: McpServer, env: Env) {
|
||||
export function registerListRecentExecutions(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
toolName("list_recent_executions"),
|
||||
"列某 workflow 最近 N 次執行 verdict(成功 / 失敗 / duration)。資料來源是 ANALYTICS_KV 90 天保留期。",
|
||||
"列某 workflow 最近 N 次執行 verdict(成功 / 失敗 / duration)。資料來源是 D1 執行紀錄表,稽核資料——預設保留 90 天(3 個月)過期即清,管理者可在 portal 改保留天數或設為不刪除;用量過大時系統會自動降成只記失敗、甚至暫停記錄——工作流本身執行不受影響。",
|
||||
{
|
||||
api_key: z.string().describe(apiKeyDesc),
|
||||
workflow_name: z.string().describe("workflow 名稱(acr push 時的 name 欄)"),
|
||||
|
||||
@@ -23,10 +23,19 @@ import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
|
||||
|
||||
/** 空庫/404 時的 backfill 指引(誠實回報+給下一步,鐵律:不假綠)。 */
|
||||
/**
|
||||
* 空庫/404 時的指引(誠實回報+給下一步,鐵律:不假綠)。
|
||||
*
|
||||
* 2026-08-08 修正:舊版寫「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過(總管實測
|
||||
* grep 全 repo 查無任何呼叫點),是假話。真正的機制:kbdb `GET /map`/`GET /map/:library`
|
||||
* 每次查詢都會自動核對即時三元組數,落差就地重算(`kbdb/src/actions/library-map.ts`
|
||||
* `ensureFreshLibraryMaps`)——資料進來後下一次查詢就會反映,不需要任何人記得呼叫任何端點。
|
||||
* `POST /map/recompute` 仍然存在,但只在極少數情況才需要手動打:舊三元組沒有 `library` slot
|
||||
* 值、只能靠 `source_uri` 前綴回填時(`source_prefix` 參數)。
|
||||
*/
|
||||
const RECOMPUTE_HINTS = [
|
||||
"地圖由 ingest 尾端自動重算(M3);尚未接鏈的庫要手動 backfill:對 kbdb 呼 POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix})",
|
||||
"backfill 過渡期(triplet 還沒有 library slot 值)用 source_prefix 以 source_uri 前綴歸庫,如 {\"source_prefix\":\"gitea:Leo/kb@\"}",
|
||||
"地圖每次查詢都會自動核對即時三元組數並重算過期的庫,不必手動處理",
|
||||
"少見情況(舊三元組沒有 library 標記)才需要手動:POST /map/recompute?library=<庫名>(可帶 body {narrative, source_prefix})",
|
||||
];
|
||||
|
||||
/** 註冊全部藏書地圖工具(library-map M4)。 */
|
||||
@@ -89,9 +98,11 @@ export function registerGetMap(server: McpServer, env: Env) {
|
||||
triplet_count: Number(l.triplet_count ?? 0) || 0,
|
||||
}));
|
||||
if (libraries.length === 0) {
|
||||
// 空庫誠實回報:不是錯誤(端點正常、就是還沒有地圖),給 backfill 指引。
|
||||
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
|
||||
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
|
||||
// 不再是「沒人跑過 recompute」那種曖昧狀態。
|
||||
return successResponse({ libraries: [], count: 0 }, [
|
||||
"全館地圖是空的:還沒有任何庫跑過 recompute",
|
||||
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
|
||||
...RECOMPUTE_HINTS,
|
||||
]);
|
||||
}
|
||||
@@ -104,9 +115,13 @@ export function registerGetMap(server: McpServer, env: Env) {
|
||||
// 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。
|
||||
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
|
||||
if (res.status === 404) {
|
||||
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
|
||||
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
|
||||
// 代表這個名字在這個租戶的資料裡從沒出現過——不是「這庫是空的」,是根本沒有這個庫
|
||||
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
|
||||
return errorResponse(
|
||||
"map_not_found",
|
||||
`庫「${library}」還沒有地圖(從未 recompute,或庫名打錯)`,
|
||||
`查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
|
||||
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
|
||||
);
|
||||
}
|
||||
@@ -129,7 +144,7 @@ export function registerGetMap(server: McpServer, env: Env) {
|
||||
triplet_count: Number(raw.triplet_count ?? 0) || 0,
|
||||
};
|
||||
return successResponse({ map }, [
|
||||
"bridges=此庫 entity 同時出現在哪些其他庫(M3 backfill 前會偏稀疏,是誠實現況不是 bug)",
|
||||
"bridges=此庫 entity 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)",
|
||||
"沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)",
|
||||
]);
|
||||
} catch (e) {
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface Env {
|
||||
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
|
||||
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
|
||||
MCP_ALLOWED_REDIRECT_HOSTS?: string;
|
||||
// 部署標記(非機密):GET /health 原樣回報,讓「這台跑的是哪一版」用一條 curl 看得到。
|
||||
// 由各實例的 wrangler toml [vars] 帶入;沒帶 → /health 回 build:"unknown"(誠實,不假裝)。
|
||||
MCP_BUILD?: string;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
|
||||
@@ -127,6 +127,25 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
expect(JSON.stringify(body.hints)).toContain("POST /map/recompute");
|
||||
});
|
||||
|
||||
// 2026-08-08:舊版說明文字宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過
|
||||
// (總管實測 grep 全 repo 查無任何呼叫點),是假話。修法:GET /map 讀端本身自動核對
|
||||
// 即時三元組數並補算,不靠任何外部呼叫者。這裡釘住那句謊言不會再出現在任何 hint 裡。
|
||||
it("不再宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過,是假話(已修正措辭)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const body = parseResult(res);
|
||||
const hintsText = JSON.stringify(body.hints);
|
||||
expect(hintsText).not.toContain("地圖由 ingest 尾端自動重算");
|
||||
expect(hintsText).not.toContain("(M3)");
|
||||
expect(hintsText).not.toContain("(M3)");
|
||||
// 誠實的新措辭:空=這個租戶真的沒資料,不是「沒人跑過 recompute」
|
||||
expect(hintsText).toContain("這個租戶目前沒有任何三元組資料");
|
||||
});
|
||||
|
||||
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||
@@ -199,6 +218,10 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
const body = parseResult(res);
|
||||
expect(body.error_code).toBe("map_not_found");
|
||||
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
|
||||
// 2026-08-08:404 現在的語意是「查無此庫」(地圖是即時核對重算的,已知庫即使空也回 200),
|
||||
// 不再是舊版那句「從未 recompute」的曖昧說法。
|
||||
expect(String(body.human_message)).toContain("查無庫");
|
||||
expect(String(body.human_message)).toContain("從沒出現過");
|
||||
});
|
||||
|
||||
it("binding throws → internal_error, not an unhandled crash", async () => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# ── arcrun-mcp STAGE(youlin 帳號)────────────────────────────────────────────────
|
||||
#
|
||||
# 這支存在的理由(2026-08-10,Leo/mira#4):
|
||||
# `arcrun-mcp` 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有測試場,
|
||||
# 要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)去冒險。這支把 stage 補上:
|
||||
# youlin = stage(agent-memory「兩台分工」:AI 測 youlin、leo 核實 prod)。
|
||||
#
|
||||
# 用法(在 mcp/ 目錄下跑,-c 必須與 main="src/index.ts" 同目錄,否則找不到入口):
|
||||
# cd mcp
|
||||
# CLOUDFLARE_ACCOUNT_ID=1129efd7df2e8899d537e9c8fbabb6cb \
|
||||
# CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN_YOULIN_CC_USE" \
|
||||
# npx wrangler deploy -c wrangler.stage.toml
|
||||
#
|
||||
# 與出貨用的 wrangler.toml 的差別,就是「手動直推 arcrun-mcp 到自架帳號」的兩個坑
|
||||
# (agent-memory 已記,這裡是把它變成檔案而不是靠人記得):
|
||||
# ① 拿掉 [[routes]] —— `mcp.arcrun.dev` 那個 zone 在 uncle6,自架帳號沒有,帶著會部署失敗
|
||||
# ② OAUTH_KV 填真 id —— 出貨 toml 是佔位符 REPLACE_WITH_REAL_KV_ID,只有走 acr init/update
|
||||
# 才會被自動注入;直推必須自己填(沒填 → /authorize 回 503,OAuth 整條死)
|
||||
#
|
||||
# 🔴 [vars] 必須列**全**:wrangler deploy 會用本檔的 vars 整組取代線上的,漏一個就是靜默清掉。
|
||||
# 下方七個 var 是 2026-08-10 從線上 arcrun-mcp 抓下來的原樣值。
|
||||
# Secrets(KBDB_INTERNAL_TOKEN / MCP_OWNER_SECRET)不進本檔也不會被 deploy 洗掉。
|
||||
|
||||
name = "arcrun-mcp"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-11-27"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
workers_dev = true
|
||||
|
||||
[vars]
|
||||
MULTI_TENANT = "false"
|
||||
CF_ACCOUNT_ID = "1129efd7df2e8899d537e9c8fbabb6cb"
|
||||
WORKER_SUBDOMAIN = "youlin-hsieh-dev"
|
||||
CONSOLE_TENANT = "yuga3bse"
|
||||
MCP_OWNER_NAMESPACE = "yuga3bse"
|
||||
KBDB_BASE_URL = "https://arcrun-kbdb.youlin-hsieh-dev.workers.dev"
|
||||
UI_ORIGINS = "https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev"
|
||||
# 部署標記,GET /health 原樣回報。每次重推 stage 就換一個,別讓它跟線上脫節。
|
||||
MCP_BUILD = "stage-2026-08-10+45f1c09"
|
||||
|
||||
[[services]]
|
||||
binding = "COMPONENT_REGISTRY"
|
||||
service = "arcrun-registry"
|
||||
|
||||
[[services]]
|
||||
binding = "CYPHER_EXECUTOR"
|
||||
service = "arcrun-cypher-executor"
|
||||
|
||||
[[services]]
|
||||
binding = "KBDB"
|
||||
service = "arcrun-kbdb"
|
||||
|
||||
# youlin 的 OAuth KV(安裝器建的 arcrun-rag-yuga3bse-kv-oauth_kv)。
|
||||
[[kv_namespaces]]
|
||||
binding = "OAUTH_KV"
|
||||
id = "6a8d4dd621994607b3998ade4c7e9944"
|
||||
+42
-3
@@ -26,9 +26,29 @@ t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2";
|
||||
# tn = 不換行版(給 prompt 用)
|
||||
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
|
||||
|
||||
REPO_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/template"
|
||||
# 來源預設=公開 GitHub(leo 2026-07-21:「要發佈的正稿,從頭就不要用奇怪的網址,
|
||||
# 以免改來改去」)。舊的 uncle6me-web 帳號已被 suspend(讀 404)=自動更新靜默失效,
|
||||
# 這顆雷已在 system-dev-template 本體修過(正解=改指 youlinhsieh 這個公開帳號),
|
||||
# 這裡只是把同一個修法補進 Arcrun 這份沒跟到的 copy。
|
||||
# 內部要指私有草稿源時用環境變數覆寫,不改檔:
|
||||
# TEMPLATE_SOURCE=https://<私有 raw base> bash scripts/install.sh
|
||||
TEMPLATE_SOURCE="${TEMPLATE_SOURCE:-https://raw.githubusercontent.com/youlinhsieh/system-dev-template/main}"
|
||||
REPO_URL="$TEMPLATE_SOURCE/template"
|
||||
CREATED=()
|
||||
SKIPPED=()
|
||||
FAILED=()
|
||||
|
||||
# 404 頁面常常「非空」(GitHub raw 對不存在的路徑回 "404: Not Found"),
|
||||
# 只判斷 [ -s file ] 會把錯誤頁內容當成檔案寫進去且完全不報錯(template issue #13 撞過的雷)。
|
||||
# 這裡量身做一個輕量判斷:檔案很短又長得像錯誤訊息 → 當失敗。
|
||||
looks_like_error_page() {
|
||||
local f="$1"
|
||||
[ -s "$f" ] || return 0
|
||||
if [ "$(wc -l < "$f" | tr -d ' ')" -le 2 ] && head -c 200 "$f" | grep -qiE '40[0-9]|not found|<html'; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 解析模組參數 ──────────────────────────────────
|
||||
MODULE=""
|
||||
@@ -211,8 +231,12 @@ download_if_missing() {
|
||||
local dest="$1" src="$2"
|
||||
if [ ! -f "$dest" ]; then
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
curl -sSL "$src" -o "$dest"
|
||||
CREATED+=("$dest")
|
||||
if curl -sSL "$src" -o "$dest" 2>/dev/null && ! looks_like_error_page "$dest"; then
|
||||
CREATED+=("$dest")
|
||||
else
|
||||
rm -f "$dest"
|
||||
FAILED+=("$dest $(tn "(來源抓不到:$src)" "(source unreachable: $src)")")
|
||||
fi
|
||||
else
|
||||
SKIPPED+=("$dest $(tn '(已存在,跳過)' '(already exists, skipped)')")
|
||||
fi
|
||||
@@ -332,6 +356,16 @@ if [ ${#SKIPPED[@]} -gt 0 ]; then
|
||||
for item in "${SKIPPED[@]}"; do echo " - $item"; done
|
||||
fi
|
||||
|
||||
# 來源抓不到要出聲,不能安靜吞掉——404 內容不會被寫進檔案(已在 download_if_missing 擋掉),
|
||||
# 但使用者必須知道「這幾個檔沒裝到」,不然會誤以為裝完整了。
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "❌ 抓取失敗(來源不可達,未寫入任何檔案):" "❌ Fetch failed (source unreachable, no file was written):"
|
||||
for item in "${FAILED[@]}"; do echo " x $item"; done
|
||||
t " 請檢查網路,或用 TEMPLATE_SOURCE=<其他來源> 重跑。" \
|
||||
" Check your network, or rerun with TEMPLATE_SOURCE=<alternate source>."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "─────────────────────────────────"
|
||||
|
||||
@@ -429,3 +463,8 @@ fi
|
||||
t " GitHub issue:CC 可直接 /issue-handle 讀回自己 repo 的 issue(禁自動輪詢)" \
|
||||
" GitHub issues: CC can use /issue-handle to read issues from its own repo (no auto-polling)"
|
||||
echo ""
|
||||
|
||||
# 有任何來源抓取失敗 → 用非零 exit code 出聲,不能靜靜地當「裝完了」收工。
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+45
-4
@@ -23,18 +23,43 @@ esac
|
||||
t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; }
|
||||
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
|
||||
|
||||
REPO_RAW="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main"
|
||||
# 來源預設=公開 GitHub(leo 2026-07-21:「要發佈的正稿,從頭就不要用奇怪的網址,
|
||||
# 以免改來改去」)。舊的 uncle6me-web 帳號已被 suspend(讀 404)=自動更新靜默失效,
|
||||
# 這顆雷已在 system-dev-template 本體修過(正解=改指 youlinhsieh 這個公開帳號),
|
||||
# 這裡只是把同一個修法補進 Arcrun 這份沒跟到的 copy。
|
||||
# 內部要指私有草稿源時用環境變數覆寫,不改檔:
|
||||
# TEMPLATE_SOURCE=https://<私有 raw base> bash scripts/update.sh
|
||||
REPO_RAW="${TEMPLATE_SOURCE:-https://raw.githubusercontent.com/youlinhsieh/system-dev-template/main}"
|
||||
TEMPLATE_URL="$REPO_RAW/template"
|
||||
|
||||
UPDATED=()
|
||||
KEPT=()
|
||||
NEW=()
|
||||
TEMPLATED=()
|
||||
FAILED=()
|
||||
|
||||
# 404 頁面常常「非空」(GitHub raw 對不存在的路徑回 "404: Not Found"),
|
||||
# 只判斷 [ -s file ] 會把錯誤頁內容當成檔案寫進去且完全不報錯(template issue #13 撞過的雷)。
|
||||
looks_like_error_page() {
|
||||
local f="$1"
|
||||
[ -s "$f" ] || return 0
|
||||
if [ "$(wc -l < "$f" | tr -d ' ')" -le 2 ] && head -c 200 "$f" | grep -qiE '40[0-9]|not found|<html'; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 版本比對:先看本機 vs 遠端,給使用者「值不值得更新」的判斷 ──
|
||||
LOCAL_VER="$(tn '(未知)' '(unknown)')"
|
||||
[ -f ".claude/VERSION" ] && LOCAL_VER="$(tr -d '[:space:]' < .claude/VERSION)"
|
||||
REMOTE_VER="$(curl -sSL "$TEMPLATE_URL/.claude/VERSION" 2>/dev/null | tr -d '[:space:]' || echo '')"
|
||||
# 容錯:curl 對 404 會把「404: Not Found」當內容輸出(非空),舊版直接把它寫進 REMOTE_VER
|
||||
# 拿去跟本機版本比對,比對邏輯不會報錯、只會靜靜判斷「版本不同」或誤判「已最新」。
|
||||
# 這裡驗證必須像版號(X.Y.Z),否則一律視為取不到。
|
||||
case "$REMOTE_VER" in
|
||||
[0-9]*.[0-9]*.[0-9]*) : ;; # 形如 1.9.0 → 合法
|
||||
*) REMOTE_VER="" ;; # 404 / HTML 錯誤頁 / 其他 → 當作沒抓到
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "🔄 system-dev-template updater"
|
||||
@@ -64,7 +89,7 @@ update_file() {
|
||||
local dest="$1" src="$2"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
if [ -f "$dest" ]; then
|
||||
if curl -sSL "$src" -o "$dest.tmp" 2>/dev/null && [ -s "$dest.tmp" ]; then
|
||||
if curl -sSL "$src" -o "$dest.tmp" 2>/dev/null && ! looks_like_error_page "$dest.tmp"; then
|
||||
if cmp -s "$dest" "$dest.tmp"; then
|
||||
rm -f "$dest.tmp" # 內容相同,不算更新
|
||||
else
|
||||
@@ -73,13 +98,15 @@ update_file() {
|
||||
fi
|
||||
else
|
||||
rm -f "$dest.tmp"
|
||||
FAILED+=("$dest")
|
||||
t " ⚠️ 抓取失敗,保留原檔:$dest" " ⚠️ Download failed, keeping the original: $dest"
|
||||
fi
|
||||
else
|
||||
if curl -sSL "$src" -o "$dest" 2>/dev/null && [ -s "$dest" ]; then
|
||||
if curl -sSL "$src" -o "$dest" 2>/dev/null && ! looks_like_error_page "$dest"; then
|
||||
NEW+=("$dest") # 新功能:舊版沒有的檔
|
||||
else
|
||||
rm -f "$dest"
|
||||
FAILED+=("$dest")
|
||||
t " ⚠️ 抓取失敗:$dest" " ⚠️ Download failed: $dest"
|
||||
fi
|
||||
fi
|
||||
@@ -98,7 +125,7 @@ keep_with_template() {
|
||||
if [ -f "$dest" ]; then
|
||||
KEPT+=("$dest")
|
||||
local tmpl="${dest%.sh}.template.sh"
|
||||
if curl -sSL "$src" -o "$tmpl.tmp" 2>/dev/null && [ -s "$tmpl.tmp" ]; then
|
||||
if curl -sSL "$src" -o "$tmpl.tmp" 2>/dev/null && ! looks_like_error_page "$tmpl.tmp"; then
|
||||
if [ -f "$tmpl" ] && cmp -s "$tmpl" "$tmpl.tmp"; then
|
||||
rm -f "$tmpl.tmp" # 模板版沒變,不重複提示
|
||||
else
|
||||
@@ -227,8 +254,22 @@ if [ -f ".claude/settings.json" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 有任何檔案抓取失敗要出聲,不能安靜吞掉(404 內容已被 looks_like_error_page 擋掉、
|
||||
# 不會污染既有檔案,但使用者必須知道「這幾個檔沒更新到」)。
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "❌ 抓取失敗(來源不可達,原檔已保留不動):" "❌ Fetch failed (source unreachable, original files kept untouched):"
|
||||
for f in "${FAILED[@]}"; do echo " x $f"; done
|
||||
t " 請檢查網路,或用 TEMPLATE_SOURCE=<其他來源> 重跑。" \
|
||||
" Check your network, or rerun with TEMPLATE_SOURCE=<alternate source>."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
t "🚀 更新完成:${LOCAL_VER} → ${REMOTE_VER}" "🚀 Update complete: ${LOCAL_VER} → ${REMOTE_VER}"
|
||||
t " 下次更新直接跑:bash scripts/update.sh" " Next time, just run: bash scripts/update.sh"
|
||||
t " 改了什麼看:CHANGELOG.md" " See what changed: CHANGELOG.md"
|
||||
echo ""
|
||||
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -21,6 +21,18 @@ degree 排序/predicate 統計/跨庫 join 是聚合 SQL——**D6 鐵律:
|
||||
|
||||
ingest 完成 → 取本次 commit diff 涉及的庫集合 → 逐庫呼 `/map/recompute`。無 cron 全量。首次 backfill=對每個既有庫手動各呼一次(installer/腳本一行)。
|
||||
|
||||
> **2026-08-08 更正(matrix/arcrun CC)**:上面這條「ingest 尾端接鏈」的路徑本身沒錯(`arcrun-rag`
|
||||
> 那條管線也確實接了,`670f38a`),但它**只覆蓋接了鏈的那一條 ingest**——這個 repo 內(kbdb/
|
||||
> cypher-executor/mcp)從沒有任何呼叫點會打 `/map/recompute`,導致沒手動 backfill 過的租戶
|
||||
> (絕大多數)恆空,且拖了三週沒人發現/接上(見 `system-dev/wiki/mistakes.md` 08-08 段)。
|
||||
> **改法**(leo 否決「降級成即時聚合、不維護快取」的提案,因為那會丟失 narrative 這類摘要
|
||||
> 本體):`GET /map`/`GET /map/:library` 讀端自己核對即時三元組數,落差就地呼叫既有的
|
||||
> `recomputeLibraryMap` 補算(`kbdb/src/actions/library-map.ts` `ensureFreshLibraryMaps`)。
|
||||
> 聚合 SQL 仍只住 kbdb base(沒有違反 §2 的歸屬裁定),只是觸發時機從「等外部呼叫」改成
|
||||
> 「讀的當下順手核對」——這條讀端機制本身就是「無 cron 全量」的自動 backfill,取代了
|
||||
> 「首次 backfill=對每個既有庫手動各呼一次」這句手動步驟。細節見
|
||||
> `system-dev/docs/3-specs/library-map/tasks.md` M3 段。
|
||||
|
||||
## 4. 注入(leo spec §5,本功能重點)
|
||||
|
||||
- **MCP instructions**:arcrun-mcp 啟動組 instructions 時拉 `GET /map` 嵌入(快取+TTL,或每次連線現拉——量數百 token,現拉可接受)。
|
||||
|
||||
@@ -6,8 +6,41 @@
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | `library_map` Template+slots 定義(含 triplet 按庫過濾現況核實;不足則 Triplet template 加 optional library slot) | B | — | ✅ 07-19(PR#72 merge) | D6 零建表。核實:triplet 無 library slot → 已走預案(design §1 核實結果) |
|
||||
| M2 | kbdb base `POST /map/recompute?library=`+`GET /map`/`GET /map/:library`(聚合 SQL 住基本盤;交易式 supersede) | B | M1 | ✅ 07-19(PR#72 merge;leo21c `33d88016`+demo `699018af` 已部署+backfill:leo21c kb 111/notes 108、demo general 23) | PR+測試(真 SQLite 驗聚合);merge 後 gated 部署(leo 閘)+逐庫 backfill recompute |
|
||||
| M3 | ingest 尾端接鏈:diff 涉及庫 → 逐庫呼 recompute(rag-ingest-cards v2+個人庫 ingest 同款改版) | A | M2 | ✅ 07-19(arcrun-rag `670f38a`;demo e2e 雙向通過:push/刪卡皆自動 recompute;leo21c 等 T-flip) | workflow 改版走 bundle 分發 |
|
||||
| M3 | 讓地圖跟得上資料、全租戶自動 backfill(原訂做法:ingest 尾端接鏈逐庫呼 recompute) | B | M2 | 🔁 **07-19 標的 ✅ 是誤報,08-08 更正並改法重做** | 見下方 08-08 段 |
|
||||
| M4 | MCP:instructions 注入全館地圖+`get_map` 工具 | B | M2 | ✅ 07-19(PR#73 merge;leo21c `1e73da90` 部署,MCP instructions 實載地圖) | 與 #68 同族薄殼;`kbdb_get_map`+connect 時注入(isolate TTL 快取,失敗靜默略過不擋連線);merge 後 gated redeploy arcrun-mcp(leo 閘) |
|
||||
| M5 | GUI 首頁:全館地圖 render(console+portal) | B | M2 | ✅ 07-19(PR#74 merge;leo21c cypher `9bc3a1f3` 部署) | 取代空白搜尋框 |
|
||||
| M6 | D30 連動:map 層 embed+semantic 庫路由第一跳 | B | M2 | ⬜ | #58/#59/#60 家族的第一片治本 |
|
||||
| M7 | dogfood:leo 庫(leo21c)首個實例 backfill+驗收(requirements 驗收段全項) | A | M3-M5 | 🔄 demo 側實質驗過;leo21c 正式驗收單(requirements 全項)待做 | 過了才進 demo/客戶 |
|
||||
|
||||
### M3 更正(2026-08-08,matrix/arcrun CC,總管交辦)
|
||||
|
||||
**07-19 標 ✅ 是誤報**:`arcrun-rag 670f38a` 只接了 rag-ingest-cards 那一條管線,**repo 內
|
||||
(`grep -rn "map/recompute" --include=*.ts` 排除 node_modules/.github-public)查無任何呼叫點**,
|
||||
三週來沒手動 backfill 過的租戶(絕大多數、含 youlin 與所有新租戶)`GET /map` 恆回空,
|
||||
`kbdb_get_map` 的 MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算(M3)」——那是假話(詳見
|
||||
`system-dev/wiki/mistakes.md` 08-08 段「藏書地圖 `kbdb_get_map` 對多數租戶永遠是空的」)。
|
||||
|
||||
**leo 裁示**:總管原提兩案(①接上 M3 原訂設計/②降級成只算 count 的即時聚合,不維護快取),
|
||||
leo 否決②——「**藏書地圖就是 arcrun 的最重要功能,讓 AI 一眼看到所有庫的摘要**」,
|
||||
即時聚合算得出 count、算不出 narrative,降級等於砍功能。
|
||||
|
||||
**改法(非①非②,第三案)**:不再依賴任何外部呼叫者(ingest workflow)記得呼
|
||||
`POST /map/recompute`——那條線跨 repo/跨租戶,已證實三週沒人接上,天生脆弱。改成
|
||||
`GET /map`/`GET /map/:library` 讀端自己核對即時三元組數,落差就地呼叫既有的
|
||||
`recomputeLibraryMap`(`kbdb/src/actions/library-map.ts` `ensureFreshLibraryMaps`)補算。
|
||||
聚合 SQL 沒有第二套、narrative/relation_profile/bridges 這些摘要欄位原封不動——不是砍成
|
||||
只算數字,只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。同時解掉:
|
||||
① 全租戶自動 backfill(不需要任何人做任何事)② 跟得上資料(下一筆 ingest 進來,
|
||||
下一次讀就反映)③ 不再依賴跨 repo 的 ingest 接鏈。
|
||||
|
||||
`narrative` 欄位有一個已知誠實限制:這個機制只**保留**既有 narrative(不會被自動重算洗掉),
|
||||
但不會**生成**新的 narrative——沒人手動填過、也沒有 ingest 端寫過的庫,narrative 仍是空的
|
||||
(`content` 顯示「(narrative 待 ingest 補寫)」)。這是 design §1 本來就承認的缺口
|
||||
(narrative 抽自 wiki 首段,屬內容語意萃取,非聚合 SQL 能生出來),非本次新增。
|
||||
|
||||
**驗證**:`kbdb/tests/library-map.test.ts` 新增 6 案(全綠,18/18)涵蓋:從未手動 recompute
|
||||
即自動補齊/跟得上新資料(不手動重算,數字自動更新)/narrative 不被靜默洗掉/
|
||||
`GET /map/:library` 誠實分辨查無此庫(404) vs 已知空庫(200)/owner 隔離/無 triplet template
|
||||
不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。
|
||||
tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
|
||||
`kbdb_get_map` 回 `{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。
|
||||
|
||||
@@ -487,3 +487,32 @@ leo 的判準(2026-07-30):
|
||||
- **影響分析**:新公開端點 1 個(租戶 key 認證,吐的是該租戶自己部署的 workflow 定義;
|
||||
無跨租戶讀);CLI 新命令 2 個(薄殼,能力全在既有 API);無 schema migration、無新金鑰面。
|
||||
- **狀態**:⏳ 待 leo confirm 後 3.9b 接線。
|
||||
|
||||
|
||||
## 提案:Arcrun App ↔ Portal 掛載協定 v0(2026-08-09,Leo/Arcrun#82)
|
||||
|
||||
- **設計全文住在票上,本檔只留指針**(leo 08-09 立:設計寫在 issue 上,別讓下一個人再翻一次源碼)
|
||||
→ `Leo/Arcrun#82` 的「設計提案:Arcrun App ↔ Portal 掛載協定 v0」留言。
|
||||
- **觸發**:leo 08-09——「要變成是一個 Arcrun App,含前端、template、slots 的設置,任何人可以
|
||||
安裝後他的 Arcrun 就跳出筆記功能」+「Mira 的那些功能萃取都要變成可安裝」+追加指示
|
||||
「等你搞清楚 Mira 的功能時直接改成 App 模式,**不要分兩段**,不然開發好了又要一次遷移」。
|
||||
- **病根(實查)**:Portal 是單檔 HTML,「有哪些頁」在 `console-ui/public/portal/index.html`
|
||||
裡寫了四遍(側欄 :253-259/tab :510-516/VIEWS :718/allowedViews :721-729);出貨時整包
|
||||
內嵌成單檔 worker(`build-ui-bundle.mjs:154`),安裝器只會「整顆換掉」、無任何掛載點概念。
|
||||
⇒ 加一個能力=改核心+重出 release。`Leo/mira#2` 那批能力若照現況搬,就是逐個焊死。
|
||||
- **變更面**:① KBDB seed 一列 `arcrun_app` template(零新表,加進 `lib/portal-seeds.ts:21`)
|
||||
② `/portal/session`(`routes/portal.ts:495-515`)多回唯讀 `apps`
|
||||
③ 新增泛用端點 `POST /portal/apps/:id/actions/:name`(server 側代打既有 named webhook,
|
||||
金鑰不外流,形狀同既有 `/portal/data/upload`,但**泛用**)
|
||||
④ Portal 一次性泛用 loader(約 60 行)⑤ `acr app build|install|remove|list`
|
||||
⑥ `arcrun-app.yaml` 的 JSON Schema(repo 目前無「可安裝單位」schema,這是第一份)。
|
||||
- **不動的牆**:KBDB 零 SQL、永不加表;App 前端不進 `arcrun-rag-ui` bundle、不進
|
||||
`bundle-components.mjs`(否則回到「改核心才能加能力」);App 拿不到 session token
|
||||
與租戶字串,資料一律走既有 `/portal/data/*` 的 owner_id+library enforce;
|
||||
App 自帶 workflow 必須自帶預編圖(冷實例即時編圖 25.7s > 15s timeout 的既有教訓)。
|
||||
- **影響分析**:現行 active SDD=`workflow-discovery`,本提案**不作廢它任何任務**,屬新增一卷;
|
||||
新公開端點 1 個(portal session 認證);新 template 1 列;無 schema migration、無新金鑰面;
|
||||
既有 `/portal/data/upload` 可事後收編成一個 App,不必第一天做。
|
||||
- **⏸ 等 leo 裁的兩題(票上 §七)**:① App 前端跑 Portal origin(ES module)還是 iframe
|
||||
——短期 App 是否都由我們自己寫?② v0 只開 `nav` 一個掛載點夠不夠?
|
||||
- **狀態**:⏳ 待 confirm。沒 confirm 前照現行 SDD 走,不開新 SDD、不動功能程式碼。
|
||||
|
||||
@@ -325,6 +325,57 @@
|
||||
已登記庫帶 stats/auto 庫帶 stats/空庫 card_count=triplet_count=0)。
|
||||
vitest + node --check 待 leo 驗收環境跑(本機無 Workers runtime)。
|
||||
|
||||
- [x] **P8-資源搭配:節點輸出 KV 寫入只服務 PIPE 讀者(2026-08-09,任務層小改)**:
|
||||
來源=arcrun-rag `system-dev/docs/3-specs/pending-changes.md` P8「短板齊平」(leo 08-08 裁決通過)
|
||||
+ leo 08-09「不需要換模型,要調整每個 CF 數字搭配」(模型切換已 revert `894d9ab`,本項不碰模型)。
|
||||
病:BUILD-006 讓**每個節點**(含 FOREACH 每一圈)把輸出 put 進 EXEC_CONTEXT KV,
|
||||
但全 codebase 唯一讀點是 PIPE 邊的 `kvGetNodeOutput`——rag 系工作流(ON_SUCCESS+對每個)
|
||||
完全沒有 PIPE 邊 ⇒ 一張卡 15 次 put 全是寫了沒人讀的。
|
||||
KV 免費層 1,000 write/日 ÷ 15 ≈ **66 檔/日=全系統真正最短的板**(比 neurons 的 119 檔/日更短,
|
||||
且免金鑰路與 Gemini 路都吃這條)。§3.8.2 原盤點只算到 execution-logger 那 1 筆,漏了這 15 筆。
|
||||
修:`graph-executor.ts` 節點輸出寫 KV 前檢查「該節點有 PIPE 出邊」才寫;
|
||||
PIPE 工作流(含「完成後」與未知語意詞預設 PIPE)行為不變,resume 路徑(`resumeFromPaused`)不動。
|
||||
實測(youlin stage,兩張同構測試卡 5 blocks+6 triplets):
|
||||
修前 run `rag_ingest_card-1786208572245` 留 6 個 node key(15 次 put);
|
||||
修後同形狀執行 EXEC_CONTEXT **零 key**;blocks/triplets 照樣寫入成功(KBDB record id 全數回傳)。
|
||||
單元測試:`tests/executor.test.ts`「P8:節點輸出 KV 寫入只服務 PIPE 讀者」——
|
||||
無 PIPE 圖 put=0/PIPE 圖照舊寫且 `_kv_outputs` 傳遞不變(12 passed;既有 1 failed 為
|
||||
「零件不存在」文案舊測試,修法前後皆失敗,與本項無關)。
|
||||
一併修復:08-08 deploy-all 重部把 youlin 的 `[ai]` binding 洗掉(`/portal/daemon/extract` 501)
|
||||
——本次以 `KEEP_AI=true` 重部復原(501→200 實測,模型仍 scout 未動)。
|
||||
|
||||
- [x] **t218 設定頁補顯示 MCP 連接網址(2026-08-09,任務層小改,arcrun-rag#7)**:
|
||||
來源=封測者原話「說明叫我把 MCP 加進 claude.ai connector,但我找不到網址」。文件
|
||||
(`docs-site/use/mcp.md`)一直寫「登入 portal → 設定頁,那裡可以直接複製」,但畫面上
|
||||
從沒真的顯示過——用戶照著文件走一定撲空(總管實查:`console-ui/public/portal/index.html`
|
||||
grep `mcp|MCP` 0 命中)。
|
||||
修:`console-ui/public/portal/index.html` 設定頁新增一塊面板「接上你的 AI(MCP)」,
|
||||
純前端字串轉換(不需後端新端點、不需安裝器多寫設定):MCP 網址=apiBase 把
|
||||
worker 名字從 `arcrun-cypher-executor` 換成 `arcrun-mcp`,同一顆自架帳號的 workers.dev
|
||||
子網域(與 `cli/src/lib/deploy.ts:386-392` 部署時組出這兩個網址的邏輯完全對齊,非另立
|
||||
一套猜法);不是這個網址形狀時誠實顯示「尚未偵測到」+停用複製鈕,不亂猜一個連不到的網址。
|
||||
複製按鈕沿用既有 `st-copy-url` 的 clipboard 邏輯,抽出共用 `copyText(btn, url)`(原
|
||||
`copyOriginUrl` 只能複製 `location.origin`,MCP 網址不是 origin,需要能傳任意字串)。
|
||||
文件(`products/arcrun-rag` docs-site `use/mcp.md`)核對過:"不確定是哪一串?登入 portal
|
||||
→ 設定頁,那裡可以直接複製" 這句在本次修完後才是真的,故文件本身不必改字。
|
||||
**端到端實測**(本機真瀏覽器,非 curl):起 kbdb + cypher-executor 兩個真 `wrangler dev`
|
||||
(local D1 套 migrations 0001-0006,兩邊 `KBDB_INTERNAL_TOKEN` 對齊解開 t115 fail-closed
|
||||
閘)+本機靜態伺服 portal(`UI_ORIGINS` 解 CORS);真的走 `/console/setup`→`/console/login`
|
||||
→`/portal/admin/bootstrap` 產生第一個 admin,瀏覽器真登入 `/portal/login`,導到設定頁——
|
||||
面板正確顯示、apiBase 是 localhost(不符自架網址形狀)時誠實顯示「尚未偵測到」+複製鈕
|
||||
停用(沒有亂猜一個假網址)。再用瀏覽器 console 把 `window.ARCRUN_API_BASE` 換成貼近真實
|
||||
自架形狀的字串(`https://arcrun-cypher-executor.fake9xyz.workers.dev`,故意用假 subdomain,
|
||||
不使用任何真實租戶字串以免誤觸真實實例),對照組獨立驗算與 index.html 內同一份公式,
|
||||
結果與 `deploy.ts:391` 的組法逐字相同(`https://arcrun-mcp.<sub>.workers.dev/mcp`);
|
||||
真的把這個值餵回頁面 DOM(`#st-mcp-url`),畫面正確顯示、複製鈕從停用變可按。
|
||||
複製鈕點擊後 clipboard 沒有變化——經比對**既有、本次未改動**的 `st-copy-url`(知識庫網址
|
||||
複製鈕)在同一自動化瀏覽器環境下點擊也是同樣結果,確認是這套瀏覽器自動化工具本身的
|
||||
clipboard-write 權限限制(非真人操作,`navigator.clipboard.writeText` 需要真使用者手勢),
|
||||
不是本次新增程式碼的迴歸——兩顆鈕行為一致。
|
||||
**未測**:真實自架 workers.dev 網址(未取得任何測試帳密,且紅線禁碰 youlin/uncle6);
|
||||
真人用滑鼠點擊複製鈕(受限於自動化環境,上述已用既有鈕做過同構對照)。
|
||||
執行範圍:`console-ui/public/portal/index.html`(新增面板 + JS)。未動後端、未部署。
|
||||
|
||||
## 第二波(不在本 SDD 動工範圍,掛號)
|
||||
|
||||
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
||||
|
||||
@@ -512,6 +512,41 @@ Workers Secret 也還在)。正解=刪除鈕標「即將開通」等 T9,**
|
||||
|
||||
---
|
||||
|
||||
## 25. 組態全綠,介面卻是舊世代——「驗了組態」被當成「驗了線上」(2026-08-08)
|
||||
|
||||
**leo 原話**:「已經發生過一次這個錯誤,**把舊版界面上到 prod,你要確定不可再犯**。」
|
||||
|
||||
**現場**:實測三個對外網址,`apiBase` / `profile.views` / `profile.home` **三項全過**,
|
||||
而它們跑的是 07-22 那一代的 portal(82,911 bytes、舊金色 serif 品牌、`Songti` 12 處);
|
||||
repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。
|
||||
⇒ **一個網址可以組態完全正確、同時對外展示一套早就被淘汰的介面,而所有機械檢查都說它綠。**
|
||||
|
||||
**兩個根因,分開記**:
|
||||
|
||||
1. **驗證的維度少了一個**。組態(連去哪、開哪幾頁)與世代(跑的是哪一版前端)是**兩件事**,
|
||||
只驗前者會得到有害的綠燈——它讓人以為驗過了。
|
||||
解:`console-ui/scripts/verify-live.mjs` 加第二層「世代指紋」=逐一抓線上資產、
|
||||
遮掉本來就該隨部署目標不同的那兩行(VIEWS/HOME),**其餘按位元組比對 repo `public/`**。
|
||||
位元組比對是刻意的:**不用關鍵字清單**——清單要人維護,而舊世代能無聲上線,
|
||||
正是因為沒有人記得維護它。
|
||||
|
||||
2. **手工維護的關鍵字閘會腐爛,而且會反過來咬你**。t160 那道世代閘寫的是
|
||||
「portal 全文含『登記新庫』就拒部」。08-03(`66f1b59`)有人在 portal 加了一則
|
||||
**說明「已經把登記新庫拿掉了」的 HTML 註解** ⇒ 這道閘從那天起每次都誤判,
|
||||
`npm run deploy:personal` 連續五天推不出去,而錯誤訊息說的是「你的 UI 是舊代」。
|
||||
解:文字閘比對前先剝掉 HTML 註解(只看使用者看得到的內容),並降級成輔助——主判準是指紋。
|
||||
|
||||
**判準(下次照用)**:問「線上這一份**是不是我們手上這一份**」,
|
||||
不要問「線上這幾個設定值對不對」。前者一句話涵蓋後者答不出來的東西。
|
||||
|
||||
**還有一半是「有沒有人記得跑」**。再好的檢查放在沒人執行的腳本裡等於不存在(這個 repo 已有數支那種)。
|
||||
故三處接死:① `deploy.mjs` 推完自動回頭驗線上,不過就算本次部署失敗;
|
||||
② `.deploy-state.json` **只在線上實測通過後**才寫(不是跑過指令就寫);
|
||||
③ Stop hook(`stop-check-sync.sh`)每回合離線比對「手上這一代 vs 最後一次驗過的部署」,
|
||||
在 CC 要說「做完了」的那一刻出聲。
|
||||
|
||||
---
|
||||
|
||||
## 快速檢查清單(做新功能前)
|
||||
|
||||
- [ ] 這是工作流還是零件?問「有必要嗎?」
|
||||
@@ -531,3 +566,5 @@ Workers Secret 也還在)。正解=刪除鈕標「即將開通」等 T9,**
|
||||
- [ ] 退役/降級某零件?同步清「AI 搜尋零件的三個源」=示例 yaml + parts.ts 硬編碼清單 + (validate 跳過是設計);別只改一處宣布完成(#22)
|
||||
- [ ] 沒對應 recipe?誠實留 TODO + 發 issue 補 seed,別硬塞語意不符的 canonical_id 充數(假綠,#22)
|
||||
- [ ] 本地/Gitea 改完 code 想 `acr update` 部署?先確認:它抓的是 GitHub codeload tarball,不是你剛改的目錄(#23)
|
||||
- [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25)
|
||||
- [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25)
|
||||
|
||||
+115
-1
@@ -3,7 +3,7 @@ name: status
|
||||
description: 當前進度、進行中 Phase、已知問題、下一步(動態文件,每 session 更新)
|
||||
metadata:
|
||||
type: project
|
||||
last_updated: 2026-07-19
|
||||
last_updated: 2026-08-07
|
||||
---
|
||||
|
||||
# 當前進度(動態)
|
||||
@@ -15,6 +15,84 @@ metadata:
|
||||
|
||||
## 📍 當前位置
|
||||
|
||||
> **2026-08-09(執行紀錄保留期補前端,arcrun-rag#21 交辦,commit `889b70b` main,已 push gitea)**:
|
||||
> P7 後端(4ca23c2)早已完成,但 portal 管理頁零命中「保留期」相關字樣——leo 只能自己 curl。
|
||||
> 補 `console-ui/public/portal/index.html` 管理頁「執行紀錄保留期」卡片(純薄殼,呼叫既有
|
||||
> `GET/PUT /portal/admin/execution-log-retention`,未動後端)。**本機真瀏覽器 E2E 驗證**:
|
||||
> 起 kbdb+cypher-executor 兩個真 `wrangler dev`(local D1 套用 migrations 0001-0006)+本機
|
||||
> 靜態伺服 portal+`UI_ORIGINS` 解 CORS,走真實首次設定流程建帳號登入 → 改天數/勾「不刪除」
|
||||
> 存檔、reload 頁面值仍持久(雙向都測過:90→45→90、數字→不刪除→數字),Network 每筆
|
||||
> GET/PUT 皆 200,console 無新增紅字。**未部署**(紅線:只 commit+push,未動任何 Cloudflare
|
||||
> 實例/未重打 arcrun-rag-ui bundle)——已在 arcrun-rag#21 comment 說明,要讓管理者在正式
|
||||
> portal 看到需另走 build-ui-bundle+出貨流程。**順手發現**:console-ui 無本機 dev/test script
|
||||
> (package.json 只有 deploy/verify),此次是手工兜 wrangler dev + python http.server,
|
||||
> 建議後續補 `npm run preview:local`。
|
||||
|
||||
> **2026-08-09(語意搜尋「故障要照實說是故障」,leo 直令,local commit main)**:
|
||||
> leo 看到 portal 橫幅「語意搜尋還沒開通⋯匯出診斷檔給我們幫你打開」原話痛罵:
|
||||
> 「**語義搜尋已經確定是一安裝就提供的功能⋯我沒有不開通這個功能,是壞了,
|
||||
> 沒有人會把 bug 美化成沒提供沒開通**」。本輪修四層:
|
||||
> 1. **文案**:portal(`console-ui/public/portal/index.html`)+console 兩處橫幅與設定頁
|
||||
> 全改「語意搜尋目前故障/我們的問題/你不用做任何事」,禁「開通/尚未啟用」框架;
|
||||
> kbdb `capability_hint` 同步改(`degraded_reason: module_off`)。
|
||||
> 2. **查詢向量化失敗不再偽裝成空結果**(leo 點名的謊):`embed.ts` `semanticSearch`
|
||||
> 改丟 `EmbedQueryFailedError`(舊行為 `if(!vec) return []`=「額度用完」被顯示成
|
||||
> 「查無資料」);route 層接住 → 誠實降級 keyword+`degraded_reason: embed_query_failed`。
|
||||
> 瀏覽器實測:真 AI binding + bogus 模型(5007 No such model)→ 橫幅照實說暫時故障。
|
||||
> 3. **源頭機制修掉**(為什麼裝好的實例會失去語意搜尋):
|
||||
> ① `cli update.ts` `kbdb_embed === true`→`!== false`——config 缺欄位時 redeploy 會把
|
||||
> [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy 整份覆蓋);init 預設同步翻成 Y/n。
|
||||
> ② `arcrun-rag deploy-all.mjs`:`ensureVectorizeIndex` 失敗以前只印 warning 續行
|
||||
> (youlin 07-20 那輪「本輪跳過」就這樣出貨)→ 改**致命中止**+失敗時 GET 複核
|
||||
> index 是否其實已存在。
|
||||
> 4. **順手自癒**:搜尋 hydrate 時發現孤兒向量/下架殘影 → 背景 deleteByIds+is_embedded
|
||||
> 歸零(0.971 殘影病原不再累積);空結果且 pending>0 → 背景 backfill 一批(embedOnWrite
|
||||
> fire-and-forget 失敗以前沒有任何機制會回來補)。no_index 拆兩態:pending>0=故障文案、
|
||||
> pending=0=誠實說「還沒有資料」(新裝未同步不是故障)。
|
||||
> **測試**:kbdb 146/146 綠(新增 `search-semantic-degraded.test.ts` 6 案+selftest 1 案);
|
||||
> cli tsc 乾淨+10/10;deploy-all DRY_RUN 24 worker 斷言 PASS。瀏覽器端到端(local wrangler
|
||||
> dev 18787/18788+portal 真登入)兩種故障畫面截圖驗過。**未部署 prod(D20 閘)**;
|
||||
> 別 session 未 commit 的 `.component-builds/*` 與 `graph-executor.ts` 未動未代提。
|
||||
|
||||
> **2026-08-07 晚(檢修孔第一版,local commit 未 push,main)**:leo 直接指令「先把檢修孔做出來
|
||||
> 發版,不必先知道 Oscar 的病是什麼」——解掉「查不出封測者的病,因為拿不到他那邊資料;拿不到
|
||||
> 資料是因為沒有檢修孔」的死結。**規格中途被 leo 簡化過一次**:從「免授權層/同意後才交」兩層
|
||||
> 機制,收斂成「設定頁一顆按鈕、按下去下載一個 JSON 檔、用戶自己把檔案傳出去」(同意天然內建
|
||||
> 在「他自己按、自己傳」這個動作裡)。
|
||||
>
|
||||
> **已完成(3 個 local commit,`matrix/arcrun`,sha `9344562`→`83aa1f6`→`5388f40`)**:
|
||||
> 1. `kbdb/src/embed.ts` `embedSelfTest()` + `GET /embed/selftest?owner_id=`——挑一筆已標記
|
||||
> 「已嵌入」的卡片拿自己的內容查自己,只回 `{enabled,tested,passed,note}`(不回內容/id)。
|
||||
> 這是唯一能分辨「從沒嵌過」vs「嵌了但 index 查不到自己」(Arcrun#11 那種故障)的方法,
|
||||
> 單看 backfillStatus 的 pending/embedded 計數看不出後者。
|
||||
> 2. `cypher-executor/src/routes/portal-data.ts` `GET /portal/data/diagnostics`——聚合
|
||||
> embed 健康狀態+`library_count`/`triplet_count`(只讀 `/map` 回應的數字,`narrative`/
|
||||
> `top_entities` 讀完即丟)+`bundle_version`+`instance_url`。沿用既有 `requirePortalUser`。
|
||||
> 3. `console-ui/public/portal/index.html` 設定頁「疑難排解」panel+「匯出診斷檔給我們看」按鈕。
|
||||
>
|
||||
> **端到端實測方式**(真跑不是模擬):本地起兩個真 `wrangler dev`(kbdb:18787/
|
||||
> cypher-executor:18788,local D1+KV,真的跑過 migrations),HTTP API 種一個 portal_user
|
||||
> +一筆 triplet_count=67 的 library_map;瀏覽器工具**真的登入、真的點按鈕**,Network 面板
|
||||
> 看到 `GET /portal/data/diagnostics → 200`,頁面狀態列顯示「已下載」。兩種故障情境(module
|
||||
> 未開/embedded=0;module 開了但 self_test.found_itself=false)都各自產出可判讀的 JSON,
|
||||
> 貼給 leo 核對過。
|
||||
>
|
||||
> **測試**:kbdb 12 新測試+既有 110 全綠;cypher-executor 3 新測試綠(既有 1 個 `/portal`
|
||||
> HTML 殼 404 失敗案用 `git stash` 驗證是既有問題非本次造成);console-ui 既有輕量測試
|
||||
> 18/18 綠。隱私紅線有機械測試守(斷言回應 JSON 不含卡片內容/entity 名/entry id)。
|
||||
>
|
||||
> **⏸ 未完成/待人**:**尚未 push GitHub**(D20 閘,需 leo 跑 `scripts/github-arm.sh`)→
|
||||
> push 後版本要走 `products/arcrun-rag/installer/scripts/release.mjs` 注版號 → Oscar 在他
|
||||
> 自己實例的設定頁按「立即更新」重跑安裝才會真的拿到這顆按鈕(self-hosted,不會自動生效)。
|
||||
> **這件事沒有 SDD**(leo 直接指令的臨時檢修孔,非任何 active SDD 的 task;`workflow-discovery`
|
||||
> 是目前唯一 active SDD,本次改動與它無關,屬於「有人閘直接授權」的例外路徑,未走 pending-changes
|
||||
> 提案流程——如需要補一份小 SDD 記錄,下個 session 可以評估)。
|
||||
>
|
||||
> **收工時意外發現**(誠實記錄):本機有既有背景機制會把 local commit 自動鏡到 Gitea
|
||||
> (`gitea/main` 在最後一次 commit 後幾秒就更新到同一個 sha,`git reflog` 時間戳對得上),
|
||||
> 全程沒有手動下過 `git push`。與 CLAUDE.md 記載的「Gitea 永 private,只有 GitHub 走 D20」
|
||||
> 模型一致,非本次操作觸發,僅供下個 session 知悉。
|
||||
|
||||
> **2026-07-28(t95+t96 搜尋缺陷修復,main)**:portal 搜尋兩缺陷修復——t95 CJK/ASCII
|
||||
> 邊界自動補空白(`normalizeCjkQuery`,查詢端,不動索引);t96 graph 節點精確 0 鄰居
|
||||
> → fuzzy fallback(`findBestNodeMatch`/`fuzzyFindNode`,contains 比對+最短名優先)。
|
||||
@@ -350,3 +428,39 @@ arcrun 不自管加密金鑰,`crypto_decrypt` host function 已成永遠回失
|
||||
|------|------|
|
||||
| 2026-06-08 | 初建。MCP bug 修正完成、wiki 系統搭建、壓測 Haiku 進行中 |
|
||||
| 2026-06-08(補) | Haiku 壓測發現 Cold 驗證缺陷:init 無強制檢查點 → 假綠風險。記入 mistakes.md §11 |
|
||||
|
||||
## ✅ 2026-08-08 深夜|leo 的 youlin 登入修好了(瀏覽器實證)
|
||||
|
||||
📍 **repo**:`matrix/arcrun`(`cypher-executor/src/index.ts` CORS 自動放行,commit `07cc7f5`)
|
||||
+ `matrix/arcrun/.github-public/installer/scripts/deploy-all.mjs`(有注入的部署路徑)
|
||||
|
||||
三元組:`登入從斷到通 >> 靠 >> matrix/arcrun:cypher-executor/src/index.ts 從 WORKER_SUBDOMAIN 自動推導 portal origin`
|
||||
|
||||
### 決定性的一個差別
|
||||
```
|
||||
修復前 按登入 → 「連線中斷——請檢查網路後重試」 ← 請求根本送不出去(CORS 擋掉)
|
||||
修復後 按登入 → 「email 或密碼錯誤」 ← 請求送到了、後端回答了
|
||||
```
|
||||
**「連線中斷」vs「密碼錯誤」就是全部**——我用假帳號,被拒絕才是正確行為。
|
||||
|
||||
### 對外四件(stage 環境,`*.workers.dev` 依 leo 08-07 判準屬例外)
|
||||
```
|
||||
② DNS 172.67.138.117
|
||||
③ apiBase https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev ← 已注入
|
||||
④ CORS access-control-allow-origin: https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev
|
||||
頁內實測 fetch /console/auth-status → {ok:true, status:200}
|
||||
```
|
||||
📌 **console 那幾行紅字是部署前的殘留**——同一個分頁不會清空。
|
||||
用**頁內 `fetch` 實跑**才是可信的判準,不是讀 console 歷史。
|
||||
|
||||
### 部署方式與它的誠實限制
|
||||
用 `deploy-all.mjs`(**有注入**的腳本路徑,非手動 wrangler):
|
||||
`KV 9/9 齊、D1 有、subdomain ✓、24 顆全部成功、3 個 secret 完好未被洗掉`。
|
||||
收工後 `git checkout` 清掉它弄髒的 15 個 `wrangler.toml`(已知行為,工作區歸零)。
|
||||
|
||||
🔴 **限制要說清楚**:這驗的是「**程式碼修對了**」,**不是「安裝器裝出來的結果**」。
|
||||
**環境分離(t217)沒做完之前,這是能做到的最接近的驗證。**
|
||||
|
||||
### 今天真正被根治的
|
||||
不是「補上 UI_ORIGINS」,是**讓它不再需要被注入**——portal 與 cypher 是同一子網域的兄弟,
|
||||
位址推導得出來。**少一個必須注入的變數,就少一個會被漏掉的東西。**
|
||||
|
||||
Reference in New Issue
Block a user