Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2897ba68b | |||
| 9ff933ba98 | |||
| 2fcae722e7 | |||
| cac874601f | |||
| b223a69884 | |||
| fad5da0e17 | |||
| 13155a1d7b | |||
| bb548b6fdf | |||
| e05518a2b4 | |||
| 21293568d5 | |||
| 53b05c6d3d | |||
| f87d0e92f4 | |||
| ba152bc83a | |||
| 89b80ff90e | |||
| 10d150ac2b | |||
| a24f2912eb | |||
| 1791ffa497 | |||
| 296fb01247 | |||
| f1370e2275 | |||
| d58a6e152d | |||
| 793a94ecb5 | |||
| cbeddf7535 | |||
| d7c6bd0680 | |||
| a5e4caf5cb | |||
| 45a546a686 | |||
| e69d6bbc03 | |||
| b302c03ea8 | |||
| c497ec418e | |||
| 6985bf4850 | |||
| 8e10f1d83e | |||
| 3eb8b31f2b | |||
| 8cee9c9f76 | |||
| 7e3ca4c1a1 | |||
| af3edff856 | |||
| c5d696556e | |||
| 5919c6b90f | |||
| 525faaf5d0 | |||
| 37e13fc9bf | |||
| 674e1b4fa2 | |||
| b6ef0f07dc | |||
| 1d6dde4a01 | |||
| 507620e313 | |||
| 1e94f8451e | |||
| dcb6ad693b | |||
| 3b0238bb28 | |||
| 788295ed71 | |||
| 797e7f751c | |||
| d1c44a5878 | |||
| a7e23badf2 | |||
| c76e10d314 | |||
| 9a29eb5af6 | |||
| 8d49d883c0 |
@@ -142,6 +142,37 @@ SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/`
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 第六類:租戶字串來源(Arcrun#108/#105 同族)
|
||||
|
||||
### 6.1 靜態租戶字串不得用於資料面過濾
|
||||
**知識資料面的 `owner_id`(三元組/entries/records/藏書地圖/工作流 KV)必須與寫入端同源。**
|
||||
寫入端只有一個真相源=使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace,
|
||||
CLI push/小幫手上傳/MCP 都用它)。讀取端拿另一份手抄的環境變數預設值 → 全被過濾掉。
|
||||
|
||||
實害:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` 讓 leo 的 **1854 條三元組被過濾成 0 個庫**
|
||||
(#108);前一天 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` 是同一句話(#105)。
|
||||
|
||||
**規則**:
|
||||
1. `cypher-executor/src/lib/tenant.ts` 是租戶字串的**唯一產地**。
|
||||
`CONSOLE_TENANT` / `ARCRUN_NAMESPACE` 只能在該檔被讀取。
|
||||
2. 知識資料面用 `knowledgeOwner(env)`(回 `TenantId`),過濾一律經
|
||||
`ownerQuery()` / `ownerField()`——它們只吃 `TenantId`,`tsc` 就擋掉「隨手一個 string」。
|
||||
3. 帳號層用 `accountTenant(env)`(回 `string`,**刻意不是 TenantId**):帳號子 namespace
|
||||
`{tenant}::portal` 與 cypher 自己寫的設定用它,型別上不可能流進知識資料面。
|
||||
4. 身分解析路徑上**不准有字面預設值**。解析不到 → 丟 `TenantUnresolvedError`,
|
||||
誠實回「讀不到」(不是「你沒有」,#100 同一條)。
|
||||
|
||||
**機械強制**(規則存在但沒機制驗證=它會再犯第三次):
|
||||
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編 tier2 成品前先掃,違規 → **編不出成品**。
|
||||
- 本機自查:`cd cypher-executor && npm run check:tenant`(`npm test` 也會先跑它)。
|
||||
- 規則本體:`cypher-executor/scripts/tenant-source-rules.mjs`(純函式);
|
||||
閘自己的測試:`cypher-executor/tests/tenant-gate.test.ts`(壞例子會擋+合法寫法零誤攔)。
|
||||
|
||||
> 尚未接上 PreToolUse hook(`.claude/hooks/` 為受保護檔案,需人類加入)。
|
||||
> 要加的話:檢查器已備妥 `--stdin <相對路徑>` 模式,可在寫入前擋。
|
||||
|
||||
## Hook Block 訊息格式
|
||||
|
||||
當 hook 擋住一個操作時,訊息格式統一為:
|
||||
|
||||
@@ -99,6 +99,38 @@ CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」
|
||||
|
||||
---
|
||||
|
||||
## 3.6 自舉例外:能力該「只實作一次」,但不一定要是 HTTP API(2026-08-12 立)
|
||||
|
||||
> 立這條的原因:`Arcrun#97`(更新把使用者的工作流與登入弄不見)的修法一開始寫在
|
||||
> `cli/src/lib/resource-resolver.ts` ——**能力住在介面層,違反 §0**。
|
||||
> 後果不是理論:**安裝器(arcrun-rag)拿不到它,於是同一個 bug 只修了一半**,
|
||||
> 走 `acr` 的人有保護、走 `install.arcrun.dev` 的人沒有——**而所有真實用戶走後者**。
|
||||
> leo 2026-08-12:「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
|
||||
|
||||
修法(PR #111)把它搬到 **`shared/resource-rule/`:一份零依賴 ESM**,
|
||||
`acr` 與安裝器共用。**它刻意不是 cypher 的 API 端點**,三個理由:
|
||||
|
||||
| 為什麼不放 API | 說明 |
|
||||
|---|---|
|
||||
| **自舉** | 這條規則要在「決定怎麼裝」的當下用得到,而安裝器的工作正是把 cypher 生出來。放進 cypher = 要先有雞才能有蛋。 |
|
||||
| **輸入是使用者自己的帳號狀態** | 判斷依據是使用者 CF 帳號上的綁定。送去平台託管的 worker 換答案 ⇒ ①「能不能安裝」綁在平台是否活著 ②使用者的帳號拓撲交給第三方。 |
|
||||
| **它根本不需要是服務** | 這是**純函式**,唯一的 IO 由呼叫端注入。**§0 要求「能力只實作一次」,不是「能力一定要是 HTTP」。** |
|
||||
|
||||
🔴 **所以本檔 §0 的正確讀法是**:能力**只准有一份**,且**不准住在任何單一介面裡**。
|
||||
「放 API」是達成它的**常見手段**,不是唯一手段。
|
||||
**判準仍然是那句口訣**:「這段邏輯換一個介面要不要重寫?」要 → 它是能力。
|
||||
|
||||
📌 **給下一個人**:看到 `shared/` 底下的純函式**不要「修正」成 API 端點**——
|
||||
先讀 `shared/resource-rule/README.md §2`,那裡記著評估過並否決的其他形態
|
||||
(共用 npm 套件=自舉問題換位置;做成零件=要用 TinyGo 重寫一次,那才是第二份實作)。
|
||||
|
||||
📌 **打包例外**:`acr` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒
|
||||
`cli/` 下必須有一份**逐位元組副本**。那不是第二份實作——
|
||||
`scripts/sync-resource-rule.mjs --check` 一有漂移就 exit 1,且 `build`/`test` 都會先跑它
|
||||
(同 `cli/harness/` 的既有慣例)。**手改副本 = build 紅 = publish 擋下。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 統一帳號來源(薄殼共用同一身份)
|
||||
|
||||
所有薄殼讀**同一份**身份設定:
|
||||
|
||||
+12
@@ -6,6 +6,10 @@ dist/
|
||||
# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署
|
||||
# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6)
|
||||
!.component-builds/**/component.wasm
|
||||
# 例外:Arcrun#80 tier2 worker 官方編譯成品(cypher-executor/kbdb/http_request/code/mcp 的
|
||||
# esbuild bundle + 隨附 wasm part)——commit 進 repo 同一套理由:固定位置、any clone 都拿得到,
|
||||
# 不必自己再編一次(見 scripts/build-worker-artifacts.mjs)。
|
||||
!.worker-builds/**/*.wasm
|
||||
# 例外:code 零件(自足 Worker)的 vendored quickjs.wasm 同屬部署物 —— acr init/update 從
|
||||
# repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件
|
||||
# @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs
|
||||
@@ -48,6 +52,14 @@ scripts/__pycache__/
|
||||
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
|
||||
*.sql
|
||||
backup-*.sql
|
||||
# 🔴 但 migration 不是備份,它是**要出貨的程式碼**(2026-08-12 實撞):
|
||||
# 上面那條 `*.sql` 的用意是擋 D1 匯出(整庫全量資料=機敏),卻連 migration 一起吃掉。
|
||||
# 後果:0001-0004 因為在該規則之前就 commit 所以還在,**0005/0006 從此沒進過版控**
|
||||
# ⇒ 更新指令從 Gitea 抓 main,那兩個檔根本不在那裡 ⇒ 每個用戶都會收到
|
||||
# 「✗ D1 migration: 部署物缺 kbdb/migrations/0005…」——**不是誰忘了推,是規則吃掉的**。
|
||||
# ⇒ 與 `.component-builds/**/component.wasm` 同慣例(見 rules/05-deploy-convention.md
|
||||
# 「WASM 來源」段),用否定規則放行。備份檔仍由 `backup-*.sql` 與目錄位置擋住。
|
||||
!kbdb/migrations/*.sql
|
||||
|
||||
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
||||
.github-public/
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-12T16:25:19.210Z",
|
||||
"repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 588587,
|
||||
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"EXEC_CONTEXT",
|
||||
"WEBHOOKS",
|
||||
"CREDENTIALS_KV",
|
||||
"ANALYTICS_KV",
|
||||
"RECIPES",
|
||||
"USERS_KV",
|
||||
"SESSIONS_KV"
|
||||
],
|
||||
"d1": [
|
||||
{
|
||||
"binding": "CREDENTIALS_DB",
|
||||
"database_name": "arcrun-kbdb"
|
||||
}
|
||||
],
|
||||
"vectorize": 0,
|
||||
"ai": true,
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production",
|
||||
"CF_ACCOUNT_ID": "",
|
||||
"WORKER_SUBDOMAIN": "uncle6-me",
|
||||
"KBDB_BASE_URL": "https://arcrun-kbdb.uncle6-me.workers.dev",
|
||||
"CONSOLE_TENANT": "leo",
|
||||
"PORTAL_SESSION_TTL": "604800",
|
||||
"PORTAL_SHOW_WORKFLOWS": "admin",
|
||||
"GITEA_BASE_URL": "https://git.uncle6.me",
|
||||
"GITEA_SPRINT_REPO": "Leo/InkStoneCo",
|
||||
"GITEA_SPRINT_DIR": "system-dev/docs/3-specs/autonomy-dispatch"
|
||||
}
|
||||
},
|
||||
"stripped": {
|
||||
"services": 13
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 149797,
|
||||
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "arcrun-kbdb"
|
||||
}
|
||||
],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-http-request",
|
||||
"source_dir": ".component-builds/http_request",
|
||||
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-http-request/worker.mjs",
|
||||
"js_bytes": 80079,
|
||||
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-http-request/component.wasm",
|
||||
"sha256": "cc15cc785703e7bbb8dbff2d38dc84a4ac24e2f44316182730abae0f170ef133"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "http_request"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1179487,
|
||||
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"notes": []
|
||||
}
|
||||
+3
-2
@@ -8,11 +8,12 @@
|
||||
"main": "./dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:harness && npm run check:harness && tsc",
|
||||
"build": "npm run build:harness && npm run check:harness && npm run check:rule && tsc",
|
||||
"build:harness": "node scripts/build-harness-skill.mjs",
|
||||
"check:harness": "node scripts/check-harness-generation.mjs",
|
||||
"check:rule": "node ../scripts/sync-resource-rule.mjs --check",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node --test \"tests/**/*.test.ts\"",
|
||||
"test": "npm run check:rule && node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
|
||||
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+23
-40
@@ -10,7 +10,6 @@ import chalk from 'chalk';
|
||||
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
|
||||
import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
downloadAndDeploy,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
@@ -135,7 +134,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
|
||||
|
||||
/**
|
||||
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
|
||||
* 驗 token → 建 KV(冪等,數量見 REQUIRED_KV_NAMESPACES)→ 查 subdomain → 下載 release 部署 Worker
|
||||
* 驗 token → 查 subdomain → 下載部署物 → 解析資源(沿用既有/必要才新建)→ 部署 Worker
|
||||
* → seed auth+api recipe → 寫 config → 印手動 secret 提示。
|
||||
* SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
|
||||
*/
|
||||
@@ -185,41 +184,13 @@ async function initSelfHosted(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 建 KV namespace(冪等)
|
||||
// 2. KV / D1 / Vectorize 不在這裡預先建(Arcrun#97)。
|
||||
// 舊版在這一步「照名字 ensure」一輪再往下傳,acr update 沿用同一段程式碼
|
||||
// ⇒ 對一台安裝器裝出來的實例(資源名字不同)等於每次更新都重建一整套空的綁上去。
|
||||
// 現在資源解析統一在 downloadAndDeploy 內:**先看已部署的 worker 綁著什麼**,
|
||||
// 對得上就沿用、確定沒人綁過才建、說不準就停手。init 走 mode:'init'(允許從零建起)。
|
||||
// 不建 R2:R2 是 dead storage(registry-canon Phase 1.5),且 CF R2 首次啟用強制綁信用卡,
|
||||
// 違背 arcrun「開源免費自架,Workers + KV 免費額度即可運行」核心理念(壓測 2026-06-04 #3)。
|
||||
const kvNamespaceIds: Record<string, string> = {};
|
||||
try {
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
process.stdout.write(chalk.gray(` → KV ${title}...`));
|
||||
const id = await cf.ensureKvNamespace(title, existing);
|
||||
kvNamespaceIds[title] = id;
|
||||
console.log(chalk.green(' ✓'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
|
||||
// (kbdb-base SDD Q4). idempotent: reuse if exists.
|
||||
let d1DatabaseId = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
|
||||
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
const em = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
|
||||
if (/auth/i.test(em)) {
|
||||
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
|
||||
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
|
||||
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
|
||||
} else {
|
||||
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用)
|
||||
let workerSubdomain = '';
|
||||
@@ -245,8 +216,20 @@ async function initSelfHosted(
|
||||
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
|
||||
// selfHosted: true → deploy 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 MCP 401)。
|
||||
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
|
||||
const deploy = await downloadAndDeploy(deployCtx);
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, selfHosted: true, kbdbEmbed };
|
||||
const deploy = await downloadAndDeploy(deployCtx, 'main', { mode: 'init', api: cf });
|
||||
|
||||
// 資源解析喊停(例:這台其實已經裝過、但某顆綁著的資源不見了)→ 什麼都沒建、什麼都沒部。
|
||||
if (deploy.blocked) {
|
||||
console.log(chalk.yellow('\n ⚠ 安裝沒有進行,你的 Cloudflare 帳號維持原樣。\n'));
|
||||
console.log(' ' + deploy.message.split('\n').join('\n '));
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 實際用上的資源(沿用既有的,或這次新建的)——寫 config / 驗收都以這份為準,不再自己查名字。
|
||||
const kvNamespaceIds = deployCtx.kvNamespaceIds ?? {};
|
||||
const d1DatabaseId = deployCtx.d1DatabaseId ?? '';
|
||||
const cypherUrl = deploy.cypherExecutorUrl
|
||||
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
|
||||
// self-hosted 自己的 MCP worker URL(mcp-account-source §3:.mcp.json 指自己,不 fallback 官方)。
|
||||
@@ -290,8 +273,8 @@ async function initSelfHosted(
|
||||
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
|
||||
const verify = await verifyInstall({
|
||||
cf,
|
||||
requiredKv: REQUIRED_KV_NAMESPACES,
|
||||
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
|
||||
kvNamespaceIds,
|
||||
d1DatabaseId: d1DatabaseId || undefined,
|
||||
cypherUrl,
|
||||
});
|
||||
printPreflight('安裝驗收(裝完檢查)', verify.items);
|
||||
@@ -301,7 +284,7 @@ async function initSelfHosted(
|
||||
}
|
||||
|
||||
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
|
||||
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
|
||||
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${Object.keys(kvNamespaceIds).length} KV,免費額度即可,無需綁卡)`));
|
||||
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
|
||||
|
||||
|
||||
+44
-36
@@ -14,11 +14,10 @@
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
namespaceHasKnowledge,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
|
||||
@@ -44,43 +43,15 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
|
||||
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
|
||||
|
||||
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個。
|
||||
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
|
||||
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥。
|
||||
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
|
||||
const kvNamespaceIds: Record<string, string> = {};
|
||||
try {
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// D1(KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限)
|
||||
// → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。
|
||||
// update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。
|
||||
let d1DatabaseId = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...'));
|
||||
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
const em = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(` ⚠ ${em}`));
|
||||
if (/auth/i.test(em)) {
|
||||
console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update'));
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Arcrun#97:這裡**曾經**先「照名字 ensure」一輪 KV + D1 再往下傳。
|
||||
// binding 名(WEBHOOKS)被當成 CF 上的資源標題去找,安裝器建的資源不叫那個名字
|
||||
// ⇒ 每次都對不上 ⇒ 每次都新建一顆空的綁上去 ⇒ 使用者的工作流/登入/子庫從畫面上消失。
|
||||
// 現在資源解析整段搬進 downloadAndDeploy:先讀「你已部署的 worker 現在綁著什麼」再決定,
|
||||
// 而且是**下載完、看得到這版要哪些 binding 之後**才決定,不再由這裡預先造一批。
|
||||
const ctx: DeployContext = {
|
||||
accountId: config.cloudflare_account_id,
|
||||
apiToken: config.cf_api_token,
|
||||
workerSubdomain: extractSubdomain(config.cypher_executor_url),
|
||||
kvNamespaceIds,
|
||||
d1DatabaseId: d1DatabaseId || undefined,
|
||||
// self-hosted → 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
|
||||
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
|
||||
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
|
||||
@@ -93,7 +64,44 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
kbdbEmbed: config.kbdb_embed !== false,
|
||||
};
|
||||
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
|
||||
// Arcrun#108:把「你的知識住在哪個命名空間」同步給雲端——但**先驗再寫**。
|
||||
//
|
||||
// 病灶:你 push 工作流、小幫手上傳知識、MCP 查詢,用的都是 config 的 `api_key`;
|
||||
// 而 cypher 讀藏書地圖/搜尋/工作流時,過濾用的 owner_id 來自 worker 的環境變數
|
||||
// (repo toml 帶的官方預設 `CONSOLE_TENANT = "leo"`)。兩個來源對不上 ⇒ 你的東西全被濾掉。
|
||||
//
|
||||
// 為什麼不無條件寫:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。
|
||||
// 無條件蓋成本機 api_key,會把一台**原本正常**的實例指向空的那一格
|
||||
// ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」。所以查得到才寫,查不到就不碰。
|
||||
if (config.api_key && config.cypher_executor_url) {
|
||||
process.stdout.write(chalk.gray(' → 核對雲端要用哪個知識命名空間...'));
|
||||
const hasKnowledge = await namespaceHasKnowledge(config.cypher_executor_url, config.api_key);
|
||||
if (hasKnowledge === true) {
|
||||
ctx.knowledgeNamespace = config.api_key;
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_NAMESPACE = ${config.api_key}(這個命名空間底下查得到你的知識庫)`));
|
||||
} else if (hasKnowledge === false) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(` ${config.api_key} 底下目前查不到任何知識庫 → 這趟不動雲端的命名空間設定`));
|
||||
console.log(chalk.gray(' (若藏書地圖是空的,請把這行連同 acr update 的輸出一起回報)'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(' 問不到實例(可能正在啟動或版本較舊)→ 這趟不動雲端的命名空間設定'));
|
||||
}
|
||||
}
|
||||
|
||||
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
|
||||
//(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
|
||||
|
||||
// 資源解析階段喊停:什麼都沒建、什麼都沒部。原文照印,然後非零離開——
|
||||
// 不能混進「部分失敗」的黃字裡帶過(那正是使用者不會發現的那種失敗)。
|
||||
if (result.blocked) {
|
||||
console.log(chalk.yellow('\n ⚠ 更新沒有進行,你的實例維持原樣。\n'));
|
||||
console.log(' ' + result.message.split('\n').join('\n '));
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.implemented) {
|
||||
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
|
||||
|
||||
+59
-58
@@ -3,6 +3,9 @@
|
||||
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
|
||||
*/
|
||||
|
||||
import { createCloudflareResourceApi } from './resource-rule/cf-resource-api.mjs';
|
||||
import type { ResourceApi, ScriptBindings } from './resource-resolver.js';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
export interface CfKvClientOptions {
|
||||
@@ -83,31 +86,26 @@ export class CfKvClient {
|
||||
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
|
||||
*/
|
||||
export class CfAccountClient {
|
||||
private accountBase: string;
|
||||
private headers: Record<string, string>;
|
||||
export class CfAccountClient implements ResourceApi {
|
||||
/**
|
||||
* `ResourceApi` 的七個方法**全部委派**給共用規則附的那支 client
|
||||
* (`shared/resource-rule/cf-resource-api.mjs`)。
|
||||
*
|
||||
* 🔴 為什麼不是在這裡自己實作一份:判斷一致還不夠,**看到的東西**也要一致。
|
||||
* 兩條路各自寫一份 CF client,只要有一邊把 404 當錯誤、漏了 per_page、少認一種
|
||||
* 欄位名,那一邊就會「看不到既有綁定」——而看不到既有綁定的下一步,依規則就是新建。
|
||||
* Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。
|
||||
*/
|
||||
private readonly rule: ReturnType<typeof createCloudflareResourceApi>;
|
||||
|
||||
constructor(accountId: string, apiToken: string) {
|
||||
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
this.rule = createCloudflareResourceApi({ accountId, apiToken });
|
||||
}
|
||||
|
||||
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...this.headers, ...(init?.headers ?? {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => null) as
|
||||
| { success: boolean; result: T; errors?: Array<{ message: string }> }
|
||||
| null;
|
||||
if (!res.ok || !data?.success) {
|
||||
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
|
||||
throw new Error(`CF API ${path} 失敗:${msg}`);
|
||||
}
|
||||
return data.result;
|
||||
const { ok, status, result, error } = await this.rule.cfRaw(path, init);
|
||||
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
|
||||
@@ -116,51 +114,54 @@ export class CfAccountClient {
|
||||
await this.cf<{ id: string; name: string }>('');
|
||||
}
|
||||
|
||||
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
|
||||
async listKvNamespaces(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ id: string; title: string }>>(
|
||||
'/storage/kv/namespaces?per_page=100',
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
|
||||
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listKvNamespaces());
|
||||
const found = known.get(title);
|
||||
if (found) return found;
|
||||
|
||||
const result = await this.cf<{ id: string; title: string }>(
|
||||
'/storage/kv/namespaces',
|
||||
{ method: 'POST', body: JSON.stringify({ title }) },
|
||||
);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||||
async getWorkersSubdomain(): Promise<string> {
|
||||
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
|
||||
return result.subdomain;
|
||||
}
|
||||
|
||||
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified).
|
||||
async listD1Databases(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
|
||||
const map = new Map<string, string>();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
// ── 以下七支=`ResourceApi`,一律委派共用規則,**這個檔案不得自己實作** ────────────
|
||||
// (`shared/resource-rule/cf-resource-api.mjs`;委派而非複製的理由見本 class 開頭)
|
||||
|
||||
/** 讀一顆已部署 worker 現在綁著哪些資源——使用者那側的事實(Arcrun#97 的唯一真相源)。 */
|
||||
getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
return this.rule.getScriptBindings(script);
|
||||
}
|
||||
|
||||
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listD1Databases());
|
||||
const found = known.get(name);
|
||||
if (found) return found;
|
||||
const result = await this.cf<{ uuid: string; name: string }>(
|
||||
'/d1/database',
|
||||
{ method: 'POST', body: JSON.stringify({ name }) },
|
||||
);
|
||||
return result.uuid;
|
||||
/** 帳號上現有的 KV namespace(title → id)。判斷「綁著的那顆還在不在」用。 */
|
||||
listKvNamespaces(): Promise<Map<string, string>> {
|
||||
return this.rule.listKvNamespaces();
|
||||
}
|
||||
|
||||
/** 帳號上現有的 D1(name → uuid)。 */
|
||||
listD1Databases(): Promise<Map<string, string>> {
|
||||
return this.rule.listD1Databases();
|
||||
}
|
||||
|
||||
/** 帳號上現有的 Vectorize index 名單。 */
|
||||
listVectorizeIndexes(): Promise<string[]> {
|
||||
return this.rule.listVectorizeIndexes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 無條件新建一顆 KV namespace。
|
||||
*
|
||||
* 🔴 Arcrun#97:**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路。
|
||||
* 要不要建,一律先經過 planResources;那裡只有在「確定沒有任何已部署的 worker
|
||||
* 綁過這個 binding」時才會排進 create。
|
||||
*/
|
||||
createKvNamespace(title: string): Promise<string> {
|
||||
return this.rule.createKvNamespace(title);
|
||||
}
|
||||
|
||||
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */
|
||||
createD1Database(name: string): Promise<string> {
|
||||
return this.rule.createD1Database(name);
|
||||
}
|
||||
|
||||
/** 新建 KBDB embed 用的 Vectorize index。沒有 ensure 版本,理由同上(Arcrun#97)。 */
|
||||
createVectorizeIndex(name: string): Promise<string> {
|
||||
return this.rule.createVectorizeIndex(name);
|
||||
}
|
||||
}
|
||||
|
||||
+542
-70
@@ -20,6 +20,19 @@ import { tmpdir, homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
import { CfAccountClient } from './cf-api.js';
|
||||
import {
|
||||
applyResourcePlan,
|
||||
bindingKey,
|
||||
parseWranglerRequirements,
|
||||
planResources,
|
||||
ResourcePlanBlocked,
|
||||
TABLE_KIND,
|
||||
type BindingRequirement,
|
||||
type ResourceApi,
|
||||
type ResourceKind,
|
||||
type ResolvedResource,
|
||||
} from './resource-resolver.js';
|
||||
|
||||
/** 部署狀態 manifest:記錄上次成功部署每個 worker 的內容指紋(content hash),
|
||||
* 讓 acr update 跳過未變動的 worker(壓測 2026-06-12:22/23 成功後重跑仍全部
|
||||
@@ -85,6 +98,119 @@ function giteaToken(): string | undefined {
|
||||
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本標籤的「發行頻道」來源(Arcrun#106)。
|
||||
*
|
||||
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
|
||||
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
|
||||
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
|
||||
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
|
||||
*/
|
||||
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
|
||||
|
||||
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
|
||||
export const CLI_MANAGED_VARS = [
|
||||
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
|
||||
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
|
||||
'MULTI_TENANT', // 由 selfHosted 注入
|
||||
'KBDB_BASE_URL', // 由 workerSubdomain 組
|
||||
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp)
|
||||
'ARCRUN_BUNDLE_COMMIT',
|
||||
] as const;
|
||||
|
||||
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
|
||||
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
|
||||
|
||||
/** 這趟部署要烙上去的版本標籤。 */
|
||||
export interface BundleStamp {
|
||||
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
|
||||
version: string;
|
||||
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
|
||||
commit?: string;
|
||||
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
|
||||
*
|
||||
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
|
||||
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
|
||||
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
|
||||
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
|
||||
*
|
||||
* 誠實邊界(mindset §7,這段要留著):
|
||||
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
|
||||
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
|
||||
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
|
||||
* (`ARCRUN_BUNDLE_COMMIT`/`/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
|
||||
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
|
||||
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
|
||||
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
|
||||
*/
|
||||
export async function resolveBundleStamp(
|
||||
ref: string,
|
||||
commit?: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<BundleStamp> {
|
||||
const short = commit ? commit.slice(0, 7) : ref;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { release?: string } | null;
|
||||
const release = String(body?.release ?? '').trim();
|
||||
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver(${release || '空'})`);
|
||||
return {
|
||||
version: release,
|
||||
commit,
|
||||
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''})`,
|
||||
};
|
||||
} catch (e) {
|
||||
const version = `${today}+${short}`;
|
||||
return {
|
||||
version,
|
||||
commit,
|
||||
note:
|
||||
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)})` +
|
||||
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `ref`(branch / tag / sha)解析成確切的 commit sha(Arcrun#106)。
|
||||
*
|
||||
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
|
||||
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
|
||||
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
|
||||
*/
|
||||
export async function resolveGiteaCommit(
|
||||
ref: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string | undefined> {
|
||||
const headers = buildDownloadHeaders();
|
||||
const tryUrls = [
|
||||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
|
||||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
|
||||
];
|
||||
for (const url of tryUrls) {
|
||||
try {
|
||||
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) continue;
|
||||
const body = (await res.json()) as
|
||||
| { commit?: { id?: string } }
|
||||
| Array<{ sha?: string }>
|
||||
| null;
|
||||
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
|
||||
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
|
||||
} catch {
|
||||
/* 換下一種問法;全都問不到就回 undefined */
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
|
||||
* Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
|
||||
@@ -107,7 +233,15 @@ export function buildDownloadHeaders(token = giteaToken()): Record<string, strin
|
||||
}
|
||||
|
||||
/**
|
||||
* init 要建立的 KV namespace(title)。
|
||||
* arcrun 各 worker 會用到的 KV **binding 名**清單。
|
||||
*
|
||||
* 🔴 Arcrun#97 之後,這份清單**不再是「要去 CF 上建的資源標題」**——
|
||||
* 真正要哪些綁定,是部署當下從每份 wrangler.toml 讀出來的(parseWranglerRequirements),
|
||||
* 要不要建則由 resource-resolver 依「已部署的 worker 綁著什麼」決定。
|
||||
* 這裡保留成一份**文件與離線測試用的期望清單**(測試會比對 toml 沒有漏綁),
|
||||
* 不再被任何執行路徑拿去「照名字 ensure」。
|
||||
*
|
||||
* 原始出處保留如下:
|
||||
* 前 7 個權威來源:.claude/rules/01-tech-stack.md 資料儲存表(cypher-executor 用)。
|
||||
* SUBMISSIONS_KV:registry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
|
||||
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
|
||||
@@ -151,8 +285,11 @@ export interface DeployContext {
|
||||
accountId: string;
|
||||
apiToken: string;
|
||||
workerSubdomain: string;
|
||||
kvNamespaceIds: Record<string, string>; // title → id
|
||||
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
|
||||
/** binding → KV namespace id。**由 downloadAndDeploy 內部的資源解析填入,呼叫端不要自己給**
|
||||
* (Arcrun#97:呼叫端「照名字 ensure 一輪再傳進來」正是把使用者實例洗空的那條路)。*/
|
||||
kvNamespaceIds?: Record<string, string>;
|
||||
/** KBDB Base D1 id;同上,由資源解析填入。*/
|
||||
d1DatabaseId?: string;
|
||||
// self-hosted 單租戶旗標。true(self-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars],
|
||||
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
|
||||
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
|
||||
@@ -161,6 +298,44 @@ export interface DeployContext {
|
||||
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
|
||||
// base 維持 LIKE keyword(free-tier 友善)。
|
||||
kbdbEmbed?: boolean;
|
||||
/**
|
||||
* Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`),
|
||||
* 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。
|
||||
*
|
||||
* **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)——
|
||||
* 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。
|
||||
*/
|
||||
knowledgeNamespace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」)
|
||||
*
|
||||
* 打的是實例自己的 `GET /kbdb/map?owner_id=<ns>`(cypher 既有的純轉發端點,CLI 平常就在用
|
||||
* 這條路 + `X-Arcrun-API-Key`)。回傳:
|
||||
* true = 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
|
||||
* false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空)
|
||||
* null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事
|
||||
*
|
||||
* 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
|
||||
*/
|
||||
export async function namespaceHasKnowledge(
|
||||
cypherUrl: string,
|
||||
namespace: string,
|
||||
): Promise<boolean | null> {
|
||||
if (!cypherUrl || !namespace) return null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
|
||||
{ headers: { 'X-Arcrun-API-Key': namespace } },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
|
||||
if (!body || !Array.isArray(body.libraries)) return null;
|
||||
return body.libraries.length > 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +365,11 @@ export interface DeployResult {
|
||||
cypherExecutorUrl?: string;
|
||||
mcpUrl?: string; // self-hosted 自己的 MCP worker URL(mcp-account-source §3)
|
||||
message: string;
|
||||
/** true = 資源解析階段就喊停(Arcrun#97),**一顆資源沒建、一個 worker 沒部**。
|
||||
* 呼叫端要以非零結束並把 message 原文印出來,不要當成一般部分失敗帶過。*/
|
||||
blocked?: boolean;
|
||||
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
|
||||
resources?: Map<string, ResolvedResource>;
|
||||
}
|
||||
|
||||
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
|
||||
@@ -219,12 +399,17 @@ export function wranglerAvailable(): boolean {
|
||||
export async function downloadAndDeploy(
|
||||
ctx: DeployContext,
|
||||
ref = 'main',
|
||||
opts: { force?: boolean } = {},
|
||||
opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
|
||||
): Promise<DeployResult> {
|
||||
const mode = opts.mode ?? 'update';
|
||||
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
|
||||
// 1. 下載 + 解壓 Gitea archive tarball
|
||||
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
|
||||
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
|
||||
const commit = await resolveGiteaCommit(ref);
|
||||
let root: string;
|
||||
try {
|
||||
root = await downloadRepoTarball(ref);
|
||||
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
|
||||
} catch (e) {
|
||||
return {
|
||||
implemented: true,
|
||||
@@ -262,28 +447,184 @@ export async function downloadAndDeploy(
|
||||
}
|
||||
|
||||
const failures: string[] = [];
|
||||
const allDirs = [...tier1, ...tier2];
|
||||
|
||||
// 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等),
|
||||
// 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
|
||||
// 失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
|
||||
if (ctx.kbdbEmbed) {
|
||||
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
|
||||
//
|
||||
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」。
|
||||
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
|
||||
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
|
||||
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
|
||||
//
|
||||
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
|
||||
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
|
||||
// → 整趟停手,**在動任何東西之前**。
|
||||
//
|
||||
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
|
||||
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
|
||||
const requirements: BindingRequirement[] = [];
|
||||
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
|
||||
const dirScript = new Map<string, string>(); // dir → worker script 名(#106:var 沿用要逐顆對號)
|
||||
for (const dir of allDirs) {
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
if (!existsSync(tomlPath)) continue;
|
||||
const raw = readFileSync(tomlPath, 'utf8');
|
||||
tomlPreviews.set(dir, raw);
|
||||
const preview = renderWranglerToml(raw, ctx, new Map());
|
||||
const parsed = parseWranglerRequirements(preview);
|
||||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||||
dirScript.set(dir, parsed.script);
|
||||
for (const b of parsed.bindings) {
|
||||
requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = new Map<string, ResolvedResource>();
|
||||
let liveVars = new Map<string, Record<string, string>>();
|
||||
if (requirements.length > 0) {
|
||||
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
|
||||
let plan;
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
|
||||
await ensureVectorizeIndex(ctx);
|
||||
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
|
||||
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
|
||||
await ensureVectorizeMetadataIndexes(ctx);
|
||||
plan = await planResources(api, requirements, mode);
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message:
|
||||
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
|
||||
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
if (plan.blockers.length > 0) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message:
|
||||
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
|
||||
plan.blockers.map((b) => ` • ${b}`).join('\n') +
|
||||
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
resolved = await applyResourcePlan(api, plan);
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
const detail = e instanceof ResourcePlanBlocked
|
||||
? e.blockers.map((b) => ` • ${b}`).join('\n')
|
||||
: ` • ${raw}`;
|
||||
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
|
||||
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
|
||||
const hint = /d1/i.test(raw) && /auth/i.test(raw)
|
||||
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
|
||||
: '';
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
liveVars = plan.liveVars;
|
||||
console.log(chalk.green(' ✓'));
|
||||
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
|
||||
const created = [...resolved.values()].filter((r) => r.origin === 'created');
|
||||
if (adopted.length > 0) {
|
||||
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
|
||||
for (const r of adopted) console.log(chalk.gray(` = ${r.binding} → ${r.value}(讀自 ${r.from})`));
|
||||
}
|
||||
if (created.length > 0) {
|
||||
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
|
||||
for (const r of created) console.log(chalk.yellow(` + ${r.binding} → ${r.value}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
|
||||
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
|
||||
ctx.kvNamespaceIds = Object.fromEntries(
|
||||
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
|
||||
);
|
||||
ctx.d1DatabaseId =
|
||||
resolved.get(bindingKey('d1', 'DB'))?.value
|
||||
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
|
||||
|
||||
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
|
||||
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
|
||||
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
|
||||
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
|
||||
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
|
||||
if (vectorizeIndex) {
|
||||
try {
|
||||
process.stdout.write(chalk.gray(` → 語義查詢 metadata index(${vectorizeIndex})...`));
|
||||
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2.8 var(plain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
|
||||
//
|
||||
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
|
||||
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
|
||||
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
|
||||
//
|
||||
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
|
||||
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
|
||||
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
|
||||
//
|
||||
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
|
||||
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
|
||||
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
|
||||
const extraVarsByDir = new Map<string, Record<string, string>>();
|
||||
let stamp: BundleStamp | undefined;
|
||||
if (dirScript.size > 0) {
|
||||
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
|
||||
if (needStamp) {
|
||||
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
|
||||
stamp = await resolveBundleStamp(ref, commit);
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
|
||||
}
|
||||
const preservedTotal: string[] = [];
|
||||
for (const [dir, script] of dirScript) {
|
||||
const raw = tomlPreviews.get(dir);
|
||||
if (!raw) continue;
|
||||
const keep = preservedVars(liveVars.get(script), raw);
|
||||
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
|
||||
const vars: Record<string, string> = { ...keep };
|
||||
if (stamp && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
|
||||
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
|
||||
}
|
||||
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
|
||||
//
|
||||
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
|
||||
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
|
||||
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
|
||||
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
|
||||
//
|
||||
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
|
||||
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
|
||||
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
|
||||
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
|
||||
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
|
||||
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||||
}
|
||||
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
|
||||
}
|
||||
if (preservedTotal.length > 0) {
|
||||
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
|
||||
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
|
||||
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
|
||||
const allDirs = [...tier1, ...tier2];
|
||||
let deployed = 0;
|
||||
let skipped = 0;
|
||||
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
|
||||
@@ -296,7 +637,7 @@ export async function downloadAndDeploy(
|
||||
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
|
||||
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
|
||||
try {
|
||||
injectWranglerConfig(tomlPath, ctx);
|
||||
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
|
||||
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
|
||||
const hash = dirContentHash(dir, ctx.accountId);
|
||||
if (manifest[label] === hash) {
|
||||
@@ -434,35 +775,6 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=1024 / metric=cosine,對齊 bge-m3)。
|
||||
* ⚠️ 這行別寫成 `**dimensions=1024**/metric`——`*` 緊接 `/` 會提早關掉 block comment(實撞 TS1127)。
|
||||
* 維度必須與 `kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 一致——不一致時 upsert 直接被 CF 拒絕。
|
||||
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
|
||||
*/
|
||||
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: KBDB_VECTORIZE_INDEX,
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (res.ok) return;
|
||||
// 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
|
||||
| null;
|
||||
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) return;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
|
||||
|
||||
@@ -471,9 +783,12 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
|
||||
* Vectorize v2:要對某 metadata 欄位下 filter,必須先為該欄建 metadata index,否則帶過濾的語意查詢一律回 0。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`(indexType=string)。
|
||||
* 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。
|
||||
*
|
||||
* 🔴 index 名由呼叫端傳入(= 資源解析沿用到的那顆),**不是**寫死 KBDB_VECTORIZE_INDEX:
|
||||
* 使用者實例上那顆 index 叫什麼是他那側的事實,我們把 metadata index 建到「他真的在用的那顆」上。
|
||||
*/
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
|
||||
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -499,11 +814,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void>
|
||||
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
|
||||
*
|
||||
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
|
||||
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
|
||||
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const url = buildArchiveUrl(ref, bust);
|
||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`));
|
||||
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
|
||||
const label = fromRef ? `${fromRef} → ${ref.slice(0, 7)}` : ref;
|
||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 10–30 秒,視網速)...`));
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。
|
||||
@@ -596,21 +913,115 @@ export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: stri
|
||||
* - 每個 worker toml 都有 `workers_dev = true` → strip routes 後純靠 workers.dev URL,自架可達。
|
||||
* - R2(`[[r2_buckets]]`)是 dead storage(registry-canon Phase 1.5),且綁卡違背開源免費 → 一併移除。
|
||||
*/
|
||||
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
function injectWranglerConfig(
|
||||
tomlPath: string,
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
original?: string,
|
||||
extraVars: Record<string, string> = {},
|
||||
): void {
|
||||
if (!existsSync(tomlPath)) return;
|
||||
let toml = readFileSync(tomlPath, 'utf8');
|
||||
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
|
||||
const toml = original ?? readFileSync(tomlPath, 'utf8');
|
||||
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
|
||||
}
|
||||
|
||||
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
|
||||
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
|
||||
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
|
||||
if (!id) continue;
|
||||
const re = new RegExp(
|
||||
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
|
||||
'g',
|
||||
);
|
||||
toml = toml.replace(re, `$1${id}$2`);
|
||||
/**
|
||||
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var(Arcrun#106)。
|
||||
*
|
||||
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
|
||||
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
|
||||
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
|
||||
* 沿用等於拿舊值蓋掉正解。
|
||||
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
|
||||
*
|
||||
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
|
||||
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"`/`WORKER_SUBDOMAIN` 之類是**官方 prod 的值**,
|
||||
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
|
||||
*/
|
||||
export function preservedVars(
|
||||
live: Record<string, string> | undefined,
|
||||
toml: string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!live) return out;
|
||||
const managed = new Set<string>(CLI_MANAGED_VARS);
|
||||
for (const key of Object.keys(live).sort()) {
|
||||
if (managed.has(key)) continue;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
|
||||
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
|
||||
out[key] = live[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
|
||||
function readVar(toml: string, key: string): string | undefined {
|
||||
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
|
||||
function tomlEscape(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一組 var 寫進 toml 的 `[vars]`(Arcrun#106)。純函式。
|
||||
*
|
||||
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
|
||||
* 1. 已有未註解的同名行 → 換值
|
||||
* 2. 只有被註解掉的同名行 → 取消註解並填值
|
||||
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
|
||||
*/
|
||||
export function applyVars(toml: string, vars: Record<string, string>): string {
|
||||
let out = toml;
|
||||
for (const key of Object.keys(vars).sort()) {
|
||||
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
|
||||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
|
||||
const value = tomlEscape(vars[key]);
|
||||
// 🔴 一律用「函式版 replace」:值裡若有 `$&`/`$1` 這種字元,字串版 replace 會把它當成
|
||||
// 反向參照展開,寫出來的就不是使用者那個值了。
|
||||
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
|
||||
out = out.replace(
|
||||
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
|
||||
(_m, head: string, tail: string) => `${head}${value}${tail}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
|
||||
out = out.replace(
|
||||
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
|
||||
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\[vars\]\s*$/m.test(out)) {
|
||||
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
|
||||
continue;
|
||||
}
|
||||
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
|
||||
*
|
||||
* 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是
|
||||
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
|
||||
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
|
||||
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
|
||||
*
|
||||
* `extraVars`(Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
|
||||
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
|
||||
*/
|
||||
export function renderWranglerToml(
|
||||
toml: string,
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
extraVars: Record<string, string> = {},
|
||||
): string {
|
||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
@@ -629,14 +1040,6 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
|
||||
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
/(database_id\s*=\s*")[^"]*(")/,
|
||||
`$1${ctx.d1DatabaseId}$2`,
|
||||
);
|
||||
}
|
||||
|
||||
// self-hosted:注入 MULTI_TENANT="false" 到 [vars](mcp-account-source §5.5)。
|
||||
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
|
||||
// 只對有 [vars] 的 worker(mcp / cypher-executor)生效;其餘無 [vars] 的不動。
|
||||
@@ -668,7 +1071,76 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
|
||||
}
|
||||
|
||||
writeFileSync(tomlPath, toml, 'utf8');
|
||||
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
|
||||
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
|
||||
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
|
||||
toml = applyVars(toml, extraVars);
|
||||
|
||||
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
|
||||
// 空 map = 預覽模式,這步什麼也不做。
|
||||
return applyResolvedBindings(toml, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解析好的資源 id 寫進對應的 binding 區塊。
|
||||
*
|
||||
* 逐個 `[[table]]` 區塊掃:先在區塊內找 `binding = "X"`,再改同一區塊裡的值欄位
|
||||
* (KV→`id`、D1→`database_id`、Vectorize→`index_name`)。
|
||||
* 🔴 刻意**不用**「全檔第一個 database_id」這種寫法:cypher(`CREDENTIALS_DB`)與
|
||||
* kbdb(`DB`)各有自己的 D1 綁定,盲換會把兩邊當成同一個東西——而使用者的實例
|
||||
* 完全可以兩邊指向不同庫。誰綁誰是使用者那側的事實,我們只是原樣搬過去。
|
||||
*/
|
||||
export function applyResolvedBindings(
|
||||
toml: string,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
): string {
|
||||
if (resolved.size === 0) return toml;
|
||||
|
||||
const VALUE_KEY: Record<ResourceKind, string> = {
|
||||
kv_namespace: 'id',
|
||||
d1: 'database_id',
|
||||
vectorize: 'index_name',
|
||||
};
|
||||
|
||||
const out: string[] = [];
|
||||
let block: string[] = [];
|
||||
let kind: ResourceKind | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (kind) {
|
||||
const binding = block
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => !l.startsWith('#'))
|
||||
.map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
|
||||
.find((b): b is string => !!b);
|
||||
const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
|
||||
if (hit) {
|
||||
const key = VALUE_KEY[kind];
|
||||
const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
|
||||
const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
|
||||
if (at >= 0) {
|
||||
block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
|
||||
} else {
|
||||
// 區塊裡本來沒有這個欄位(例如新版 toml 只寫 binding)→ 補一行,不要靜默略過。
|
||||
block.push(`${key} = "${hit.value}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(...block);
|
||||
block = [];
|
||||
};
|
||||
|
||||
for (const line of toml.split('\n')) {
|
||||
const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
|
||||
if (table) {
|
||||
flush();
|
||||
kind = TABLE_KIND[table[1]] ?? null;
|
||||
}
|
||||
block.push(line);
|
||||
}
|
||||
flush();
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-20
@@ -6,9 +6,11 @@
|
||||
* 不是假設齊備直接動手 → 缺一個就卡(test_arcrun/4 的 D1 大跑去讀原始碼自己想辦法)。
|
||||
* - **裝完驗收**:部署後逐項確認(KV / D1 / migration / cypher 可達),缺哪項明確報哪項
|
||||
* + 給一鍵補裝指令。不是靜默印灰字(原本 harness/MCP 失敗只 console.log 灰字,用戶不知道)。
|
||||
* - **冪等**:重跑檢查後「什麼也沒動」(ensureKvNamespace / ensureD1Database 本就冪等)。
|
||||
* - **冪等**:重跑檢查後「什麼也沒動」。
|
||||
*
|
||||
* 本檔只做「偵測 + 報告」,不自己建資源(建資源仍走 cf-api 的 ensure*,由 init 編排)。
|
||||
* 本檔只做「偵測 + 報告」,不自己建資源(要不要建由 resource-resolver 判斷,deploy.ts 編排)。
|
||||
* 🔴 Arcrun#97:報告裡的 fix 指令也算「產品的一部分」——一句「acr update(冪等重建)」
|
||||
* 接在誤報的「缺 KV」後面,就是把使用者直接推去執行那個把實例洗空的動作。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -77,42 +79,58 @@ export function printPreflight(title: string, items: PreflightItem[]): void {
|
||||
*/
|
||||
export async function verifyInstall(opts: {
|
||||
cf: CfAccountClient;
|
||||
requiredKv: readonly string[];
|
||||
expectD1Name?: string;
|
||||
/** binding → KV namespace id(部署實際用上的那幾顆)。*/
|
||||
kvNamespaceIds: Record<string, string>;
|
||||
/** 部署實際用上的 D1 id(沒有 D1 就不傳)。*/
|
||||
d1DatabaseId?: string;
|
||||
cypherUrl?: string;
|
||||
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
|
||||
const items: PreflightItem[] = [];
|
||||
|
||||
// KV:實查 CF 上現有 namespace,比對必需清單
|
||||
// KV:核對「部署實際綁上去的那幾顆 id」在帳號上還在不在。
|
||||
// 🔴 Arcrun#97:這裡**不能**用「帳號上有沒有叫 WEBHOOKS 的 namespace」來驗。
|
||||
// 安裝器裝出來的實例,資源名字是 arcrun-rag-<instance>-kv-webhooks——照名字驗會誤報「缺」,
|
||||
// 而那句誤報底下就寫著「fix: acr update(冪等重建)」⇒ 使用者照做,就被重建成空的。
|
||||
// 驗的對象永遠是 id(我們真的綁上去的那顆),不是名字。
|
||||
const kvBindings = Object.entries(opts.kvNamespaceIds);
|
||||
try {
|
||||
const existing = await opts.cf.listKvNamespaces();
|
||||
const have = new Set(existing.keys());
|
||||
const missing = opts.requiredKv.filter((t) => !have.has(t));
|
||||
const ids = new Set((await opts.cf.listKvNamespaces()).values());
|
||||
const missing = kvBindings.filter(([, id]) => !ids.has(id)).map(([b]) => b);
|
||||
items.push(
|
||||
missing.length === 0
|
||||
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
|
||||
: { name: 'KV namespaces', ok: false, detail: `缺 ${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
|
||||
? { name: `KV namespaces (${kvBindings.length})`, ok: true }
|
||||
: {
|
||||
name: 'KV namespaces',
|
||||
ok: false,
|
||||
detail: `這幾個 binding 綁著的 namespace 在帳號上找不到:${missing.join(', ')}`,
|
||||
fix: '先確認那幾顆是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
|
||||
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: '檢查 CF token 的 KV 讀取權限' });
|
||||
}
|
||||
|
||||
// D1:實查 CF 上是否有該庫
|
||||
if (opts.expectD1Name) {
|
||||
// D1:同理,核對實際綁上去的那顆 id 還在不在(不是核對有沒有叫 arcrun-kbdb 的庫)。
|
||||
if (opts.d1DatabaseId) {
|
||||
try {
|
||||
const dbs = await opts.cf.listD1Databases();
|
||||
const ids = new Set((await opts.cf.listD1Databases()).values());
|
||||
items.push(
|
||||
dbs.has(opts.expectD1Name)
|
||||
? { name: `D1 ${opts.expectD1Name}`, ok: true }
|
||||
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' },
|
||||
ids.has(opts.d1DatabaseId)
|
||||
? { name: `D1 ${opts.d1DatabaseId}`, ok: true }
|
||||
: {
|
||||
name: `D1 ${opts.d1DatabaseId}`,
|
||||
ok: false,
|
||||
detail: '這顆 D1 在帳號上找不到',
|
||||
fix: '先確認它是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
|
||||
// D1 讀不到最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
|
||||
const m = msg(e);
|
||||
const fix = /auth/i.test(m)
|
||||
? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update'
|
||||
: 'acr update(冪等重試)';
|
||||
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix });
|
||||
: '檢查 CF token 的 D1 讀取權限';
|
||||
items.push({ name: `D1 ${opts.d1DatabaseId}`, ok: false, detail: m, fix });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* resource-resolver.ts — **這裡沒有邏輯**,只是把共用規則接到 CLI 的既有 import 路徑上。
|
||||
*
|
||||
* 「這個實例該用哪些資源」的規則住在 `shared/resource-rule/`(repo 根目錄),
|
||||
* 那是**唯一一份人手維護的實作**;`./resource-rule/` 是該目錄的逐位元組鏡射
|
||||
* (`scripts/sync-resource-rule.mjs` 產生,`npm run build` / `npm test` 會跑 `--check` 擋漂移)。
|
||||
* 之所以要有這份鏡射:`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案。
|
||||
*
|
||||
* 為什麼規則不在 CLI(leo 2026-08-12):
|
||||
* 「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
|
||||
* ——`acr` 有這條規則、安裝器沒有,結果就是 Arcrun#97:
|
||||
* 安裝器照名字找、找不到就建一顆空的綁上去,使用者的工作流與登入狀態整片消失。
|
||||
* 規則搬到共用層之後,安裝器直接 import 同一份原稿,**不再有第二種答案**。
|
||||
*
|
||||
* 🔴 不要把任何判斷寫回這個檔案。要改規則 → 改 `shared/resource-rule/rule.mjs`。
|
||||
*/
|
||||
|
||||
export {
|
||||
planResources,
|
||||
applyResourcePlan,
|
||||
parseWranglerRequirements,
|
||||
normalizeLiveBindings,
|
||||
normalizeLiveVars,
|
||||
bindingKey,
|
||||
ResourcePlanBlocked,
|
||||
KIND_LABEL,
|
||||
TABLE_KIND,
|
||||
} from './resource-rule/rule.mjs';
|
||||
|
||||
export type {
|
||||
ResourceKind,
|
||||
LiveBinding,
|
||||
ScriptBindings,
|
||||
ResourceApi,
|
||||
BindingRequirement,
|
||||
PlannedAdopt,
|
||||
PlannedCreate,
|
||||
ResourcePlan,
|
||||
ResolvedResource,
|
||||
WranglerRequirements,
|
||||
RawWorkerBinding,
|
||||
} from './resource-rule/rule.mjs';
|
||||
@@ -0,0 +1,202 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* cf-resource-api.mjs — 規則的**眼睛與手**:對 Cloudflare 帳號的那七個動作,也只有一份。
|
||||
*
|
||||
* `rule.mjs` 是純判斷,IO 由呼叫端注入(`ResourceApi`)。本檔就是那個注入物的正貨:
|
||||
* 用 CF REST API 實作 `ResourceApi`,零依賴、只用 global `fetch`
|
||||
* ⇒ Node 18+ 與 Cloudflare Workers runtime 都能直接跑。
|
||||
*
|
||||
* 【為什麼連這層也要共用】
|
||||
* 判斷一致還不夠——**看到的東西**也要一致。
|
||||
* 「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的;
|
||||
* 如果兩條路各自寫一份 client,隨便一個差異(打錯端點、把 404 當錯誤、漏了 per_page、
|
||||
* 少認一種欄位名)都會讓其中一條路「看不到既有綁定」——而看不到既有綁定的下一步,
|
||||
* 依規則就是**新建**。Arcrun#97 的災情不需要規則寫錯,只要眼睛不一樣就會重演。
|
||||
*
|
||||
* 這裡**故意只有 `ResourceApi` 那七個方法**。verifyAccess / 查 subdomain / KV 讀寫
|
||||
* 這些跟「該用哪些資源」無關的帳號操作留在各自的呼叫端,不往共用層堆。
|
||||
*
|
||||
* 🔴 除了同目錄的 `./rule.mjs`,這支不准 import 任何東西——共用層的價值在於
|
||||
* 「整個目錄複製到哪個 runtime 都能直接跑」,多一個外部依賴就少一條路吃得到。
|
||||
*/
|
||||
|
||||
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
/**
|
||||
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
|
||||
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
|
||||
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CfResourceApiOptions
|
||||
* @property {string} accountId
|
||||
* @property {string} apiToken
|
||||
* @property {typeof globalThis.fetch} [fetch]
|
||||
* 注入用(離線測試餵假帳號、或宿主要用自己的 fetch)。預設 global fetch。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 建一個打真實 Cloudflare 的 `ResourceApi`。
|
||||
*
|
||||
* @param {CfResourceApiOptions} options
|
||||
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
|
||||
*/
|
||||
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
|
||||
const doFetch = fetchImpl ?? globalThis.fetch;
|
||||
if (typeof doFetch !== 'function') {
|
||||
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
|
||||
}
|
||||
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
|
||||
*/
|
||||
async function cfRaw(path, init) {
|
||||
const res = await doFetch(`${accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data?.success) {
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error:
|
||||
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
|
||||
`HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, status: res.status, result: data.result };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function cf(path, init) {
|
||||
const { ok, status, result, error } = await cfRaw(path, init);
|
||||
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
cfRaw,
|
||||
|
||||
/**
|
||||
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
|
||||
*
|
||||
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
|
||||
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
|
||||
* 把查不到當成不存在,就是 #97 的根因。
|
||||
*
|
||||
* @param {string} script
|
||||
* @returns {Promise<ScriptBindings>}
|
||||
*/
|
||||
async getScriptBindings(script) {
|
||||
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
|
||||
const res = await cfRaw(path);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
|
||||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||||
}
|
||||
/** @type {RawWorkerBinding[]} */
|
||||
const raw = res.result?.bindings ?? [];
|
||||
return {
|
||||
deployed: true,
|
||||
bindings: normalizeLiveBindings(raw),
|
||||
vars: normalizeLiveVars(raw),
|
||||
};
|
||||
},
|
||||
|
||||
/** @returns {Promise<Map<string, string>>} title → id */
|
||||
async listKvNamespaces() {
|
||||
/** @type {Array<{id: string, title: string}>} */
|
||||
const result = await cf('/storage/kv/namespaces?per_page=100');
|
||||
const map = new Map();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
},
|
||||
|
||||
/** @returns {Promise<Map<string, string>>} name → uuid */
|
||||
async listD1Databases() {
|
||||
/** @type {Array<{uuid: string, name: string}>} */
|
||||
const result = await cf('/d1/database?per_page=100');
|
||||
const map = new Map();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
},
|
||||
|
||||
/** @returns {Promise<string[]>} */
|
||||
async listVectorizeIndexes() {
|
||||
/** @type {Array<{name: string}>} */
|
||||
const result = await cf('/vectorize/v2/indexes');
|
||||
return (result ?? []).map((i) => i.name);
|
||||
},
|
||||
|
||||
/**
|
||||
* 無條件新建一顆 KV namespace。
|
||||
*
|
||||
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
|
||||
* (安裝器取的名字跟 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
|
||||
* 要不要建一律先過 `planResources`。
|
||||
*
|
||||
* @param {string} title
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createKvNamespace(title) {
|
||||
const result = await cf('/storage/kv/namespaces', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
return result.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。
|
||||
* @param {string} name
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createD1Database(name) {
|
||||
const result = await cf('/d1/database', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return result.uuid;
|
||||
},
|
||||
|
||||
/**
|
||||
* 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**)。
|
||||
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。
|
||||
* 沒有 ensure 版本:「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createVectorizeIndex(name) {
|
||||
const res = await cfRaw('/vectorize/v2/indexes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
});
|
||||
if (res.ok) return name;
|
||||
const detail = (res.error ?? '').toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
|
||||
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* installer-entry.mjs — 安裝器那條路的**唯一入口**。
|
||||
*
|
||||
* 安裝器(arcrun-rag `installer/oauth-prototype/worker.js`)不必、也不准自己判斷
|
||||
* 「該建哪些資源」——它只要呼叫這一支,拿回「每個 binding 該用哪顆資源」。
|
||||
*
|
||||
* ```js
|
||||
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
|
||||
*
|
||||
* const r = await resolveInstanceResources({
|
||||
* accountId, apiToken,
|
||||
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
|
||||
* mode: isUpdate ? 'update' : 'init',
|
||||
* });
|
||||
* if (r.blocked) {
|
||||
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
|
||||
* return showAndStop(r.blockers);
|
||||
* }
|
||||
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
|
||||
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
|
||||
* ```
|
||||
*
|
||||
* 為什麼安裝器不需要副本:安裝器本來就會下載本 repo 的 archive 當部署來源
|
||||
* (見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
|
||||
* `shared/resource-rule/` 就在那份 archive 裡,直接 import 即可——
|
||||
* **不必再編一次、不必貼一份、也就不會有第二種答案。**
|
||||
*/
|
||||
|
||||
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
|
||||
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolveOptions
|
||||
* @property {string} accountId
|
||||
* @property {string} apiToken
|
||||
* @property {string[]} wranglerTomls 各 worker 的 wrangler.toml **內容**(不是路徑)。
|
||||
* @property {'update' | 'init'} mode 這台照定義裝過了沒。
|
||||
* @property {typeof globalThis.fetch} [fetch] 注入用(測試/宿主自帶 fetch)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolveResult
|
||||
* @property {boolean} blocked true = 什麼都沒建、什麼都不該部署。
|
||||
* @property {string[]} blockers blocked 時的原因原文(要原樣轉給使用者)。
|
||||
* @property {Record<string, string>} bindings `${kind}:${binding}` → 資源 id/index 名。
|
||||
* @property {Record<string, 'adopted'|'created'>} origin 同上 key → 這顆是沿用還是新建。
|
||||
* @property {Record<string, Record<string, string>>} liveVars script → 現有 plain_text var(#106)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 決定這台實例每個 binding 該用哪顆資源;照規則沿用既有、只在確定沒人綁過時才新建。
|
||||
*
|
||||
* @param {ResolveOptions} options
|
||||
* @returns {Promise<ResolveResult>}
|
||||
*/
|
||||
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
|
||||
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
|
||||
|
||||
/** @type {import('./rule.mjs').BindingRequirement[]} */
|
||||
const requirements = [];
|
||||
for (const toml of wranglerTomls) {
|
||||
const parsed = parseWranglerRequirements(toml);
|
||||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||||
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
|
||||
/** @param {string[]} blockers @returns {ResolveResult} */
|
||||
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
|
||||
|
||||
if (requirements.length === 0) {
|
||||
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
|
||||
}
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = await planResources(api, requirements, mode);
|
||||
} catch (e) {
|
||||
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
|
||||
}
|
||||
if (plan.blockers.length > 0) return stop(plan.blockers);
|
||||
|
||||
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await applyResourcePlan(api, plan);
|
||||
} catch (e) {
|
||||
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const bindings = {};
|
||||
/** @type {Record<string, 'adopted'|'created'>} */
|
||||
const origin = {};
|
||||
for (const [key, r] of resolved) {
|
||||
bindings[key] = r.value;
|
||||
origin[key] = r.origin;
|
||||
}
|
||||
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* rule.mjs — 「這個實例該用哪些資源」的**唯一一份**規則。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 這份檔案為什麼在這裡(`shared/`),不在 `cli/`
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
|
||||
*
|
||||
* `.claude/rules/07-thin-shell.md` 的判準口訣:
|
||||
* 「這段邏輯換一個介面要不要重寫?」要重寫 → 它是能力,該在共用層。
|
||||
*
|
||||
* 「該沿用哪幾顆資源」換到安裝器就得重寫一次 ⇒ 它是**能力**,不是薄殼的事。
|
||||
* 而它原本住在 `cli/src/lib/resource-resolver.ts` ⇒ 那本身就是違規,
|
||||
* 後果也真的發生了:`acr` 那條有這條規則、安裝器那條沒有,於是安裝器照名字找、
|
||||
* 找不到就建新的空的 ⇒ Arcrun#97「我按了更新,工作流和登入全不見了」。
|
||||
*
|
||||
* ── 為什麼不是 cypher-executor 的 API 端點(薄殼原則的標準答案)────────────
|
||||
* **自舉**:這條規則要在「決定怎麼裝/怎麼更新」的當下就用得到,而那個當下
|
||||
* cypher 可能還不存在(安裝器的工作正是把它生出來),或正要被覆蓋。
|
||||
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**——
|
||||
* 把它送去一顆平台託管的 worker 換一個答案,等於①讓「能不能安裝」綁在平台是否活著,
|
||||
* ②把使用者的帳號拓撲交給第三方。兩件都不該為了形式上的漂亮而做。
|
||||
*
|
||||
* 薄殼原則要求的是「能力只實作一次」,不是「能力一定要是 HTTP」。
|
||||
* 這條規則是**純函式**(唯一的 IO 由呼叫端注入 `ResourceApi`),
|
||||
* 所以它用不著變成服務——一份零依賴的 ESM 就能讓每條路吃到同一份判斷。
|
||||
*
|
||||
* ── 怎麼讓兩條路吃到「同一份」而不是各留一份 ───────────────────────────────
|
||||
* 本檔是**唯一被人手維護的實作**,零依賴、不吃任何 node 內建、Workers runtime 可直接跑。
|
||||
* · `acr`:`cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**,
|
||||
* 由 `scripts/sync-resource-rule.mjs` 產生(CLI 要能單獨 npm publish,
|
||||
* 套件目錄外的檔案打不進 tarball,故必須有這一份)。
|
||||
* `npm run build` / `npm test` 都會跑 `--check`,內容一漂就紅。
|
||||
* ——同 `cli/harness/`(產生物+世代閘)的既有慣例。
|
||||
* · 安裝器 / 任何 Worker:安裝器本來就會下載本 repo 的 archive(部署來源,
|
||||
* 見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
|
||||
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可,**不需要再編一次、也不留副本**。
|
||||
* 用法見同目錄 README.md。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 規則本身(leo 的兩句話)
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 「如果你沒有裝,就是新的;如果你已經有,原來叫什麼名字就繼續用下去。」
|
||||
*
|
||||
* 判準是「**這顆 worker 現在綁著誰**」,不是「有沒有叫這個名字的資源」:
|
||||
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
|
||||
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
|
||||
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
|
||||
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
|
||||
*
|
||||
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
|
||||
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
|
||||
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
|
||||
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
|
||||
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
|
||||
*
|
||||
* 🔴 這份檔案沒有 import、也不准有。任何依賴都會讓某一條路吃不到它。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
|
||||
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。
|
||||
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
|
||||
*/
|
||||
|
||||
/**
|
||||
* 從已部署 worker 上讀回來的一條綁定。`value`:KV/D1 是資源 id,Vectorize 是 index 名。
|
||||
* @typedef {object} LiveBinding
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ScriptBindings
|
||||
* @property {boolean} deployed
|
||||
* false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。
|
||||
* @property {LiveBinding[]} bindings
|
||||
* @property {Record<string, string>} [vars]
|
||||
* 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。
|
||||
*
|
||||
* 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize),
|
||||
* plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
|
||||
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)——
|
||||
* 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。
|
||||
* **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 規則需要的 CF 能力(收窄成介面,方便離線測試餵假帳號,也讓安裝器用自己的 fetch 實作)。
|
||||
* @typedef {object} ResourceApi
|
||||
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
|
||||
* @property {() => Promise<Map<string, string>>} listKvNamespaces title → id
|
||||
* @property {() => Promise<Map<string, string>>} listD1Databases name → uuid
|
||||
* @property {() => Promise<string[]>} listVectorizeIndexes
|
||||
* @property {(title: string) => Promise<string>} createKvNamespace
|
||||
* @property {(name: string) => Promise<string>} createD1Database
|
||||
* @property {(name: string) => Promise<string>} createVectorizeIndex
|
||||
*/
|
||||
|
||||
/**
|
||||
* 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。
|
||||
* @typedef {object} BindingRequirement
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
|
||||
* @property {string} createName
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} PlannedAdopt
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {string} from 從哪顆已部署的 worker 上讀到的
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} PlannedCreate
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} createName
|
||||
* @property {string[]} wantedBy
|
||||
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ResourcePlan
|
||||
* @property {PlannedAdopt[]} adopt
|
||||
* @property {PlannedCreate[]} create
|
||||
* @property {string[]} blockers 非空 = 整趟停手。applyResourcePlan 會拒絕執行。
|
||||
* @property {Map<string, Record<string, string>>} liveVars
|
||||
* 每顆**已部署** worker 現在掛著的 plain_text var(script → 名/值)。未部署的不在裡面。
|
||||
*
|
||||
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
|
||||
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
|
||||
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolvedResource
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {'adopted' | 'created'} origin
|
||||
* @property {string} [from]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} WranglerRequirements
|
||||
* @property {string} script worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。
|
||||
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
|
||||
*/
|
||||
|
||||
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
|
||||
export class ResourcePlanBlocked extends Error {
|
||||
/** @param {string[]} blockers */
|
||||
constructor(blockers) {
|
||||
super(`資源解析被擋下(${blockers.length} 項)`);
|
||||
this.name = 'ResourcePlanBlocked';
|
||||
/** @type {string[]} */
|
||||
this.blockers = blockers;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ResourceKind} kind
|
||||
* @param {string} binding
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bindingKey(kind, binding) {
|
||||
return `${kind}:${binding}`;
|
||||
}
|
||||
|
||||
/** @type {Record<ResourceKind, string>} */
|
||||
export const KIND_LABEL = {
|
||||
kv_namespace: 'KV namespace',
|
||||
d1: 'D1 資料庫',
|
||||
vectorize: 'Vectorize index',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {unknown} e
|
||||
* @returns {string}
|
||||
*/
|
||||
function msg(e) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
|
||||
*
|
||||
* @param {ResourceApi} api
|
||||
* @param {readonly BindingRequirement[]} requirements
|
||||
* @param {'update' | 'init'} mode
|
||||
* 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
|
||||
* @returns {Promise<ResourcePlan>}
|
||||
*/
|
||||
export async function planResources(api, requirements, mode) {
|
||||
/** @type {string[]} */
|
||||
const blockers = [];
|
||||
/** @type {PlannedAdopt[]} */
|
||||
const adopt = [];
|
||||
/** @type {PlannedCreate[]} */
|
||||
const create = [];
|
||||
|
||||
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
|
||||
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
|
||||
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
|
||||
/** @type {Map<string, LiveBinding[]>} */
|
||||
const live = new Map();
|
||||
/** @type {Map<string, Record<string, string>>} */
|
||||
const liveVars = new Map();
|
||||
let readFailed = false;
|
||||
for (const script of scripts) {
|
||||
try {
|
||||
const res = await api.getScriptBindings(script);
|
||||
if (res.deployed) {
|
||||
live.set(script, res.bindings);
|
||||
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
|
||||
liveVars.set(script, res.vars ?? {});
|
||||
}
|
||||
} catch (e) {
|
||||
readFailed = true;
|
||||
blockers.push(
|
||||
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
|
||||
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
|
||||
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
|
||||
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
|
||||
blockers.push(
|
||||
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
|
||||
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
|
||||
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
|
||||
`已停手,沒有新建任何資源。`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
|
||||
/** @type {Map<string, BindingRequirement[]>} */
|
||||
const byKey = new Map();
|
||||
for (const req of requirements) {
|
||||
const key = bindingKey(req.kind, req.binding);
|
||||
const list = byKey.get(key);
|
||||
if (list) list.push(req);
|
||||
else byKey.set(key, [req]);
|
||||
}
|
||||
|
||||
/** @type {Map<ResourceKind, Set<string>>} */
|
||||
const existingCache = new Map();
|
||||
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
|
||||
const listExisting = async (kind) => {
|
||||
const hit = existingCache.get(kind);
|
||||
if (hit) return hit;
|
||||
/** @type {Set<string>} */
|
||||
let set;
|
||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
||||
else set = new Set(await api.listVectorizeIndexes());
|
||||
existingCache.set(kind, set);
|
||||
return set;
|
||||
};
|
||||
|
||||
for (const [, reqs] of byKey) {
|
||||
const { kind, binding } = reqs[0];
|
||||
|
||||
/** @type {Array<{value: string, script: string}>} */
|
||||
const found = [];
|
||||
for (const [script, bindings] of live) {
|
||||
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
|
||||
if (hit) found.push({ value: hit.value, script });
|
||||
}
|
||||
const distinct = [...new Set(found.map((f) => f.value))];
|
||||
|
||||
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
|
||||
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
|
||||
if (distinct.length > 1) {
|
||||
blockers.push(
|
||||
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
|
||||
`(${found.map((f) => `${f.script} → ${f.value}`).join('、')})。` +
|
||||
`分不出哪一顆才是你在用的,不猜——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
||||
if (distinct.length === 1) {
|
||||
const value = distinct[0];
|
||||
/** @type {Set<string>} */
|
||||
let existing;
|
||||
try {
|
||||
existing = await listExisting(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||
`(${msg(e)})。不確定就不動——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!existing.has(value)) {
|
||||
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
||||
blockers.push(
|
||||
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
||||
`但這顆在你的 Cloudflare 帳號上找不到了。` +
|
||||
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
|
||||
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
adopt.push({ kind, binding, value, from: found[0].script });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
|
||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
||||
create.push({
|
||||
kind,
|
||||
binding,
|
||||
createName: reqs[0].createName,
|
||||
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
||||
alsoBind: [],
|
||||
});
|
||||
}
|
||||
|
||||
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
|
||||
}
|
||||
|
||||
/**
|
||||
* 收斂「不同 binding 其實是同一顆資源」的情況。
|
||||
*
|
||||
* 判準是 **toml 自己宣告的名字**(`database_name` / `index_name`),不是使用者那側的資源名——
|
||||
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`,
|
||||
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
|
||||
*
|
||||
* 沒有這一步會出兩種錯:
|
||||
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
|
||||
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
|
||||
*
|
||||
* @param {PlannedAdopt[]} adopt
|
||||
* @param {PlannedCreate[]} create
|
||||
* @param {Map<string, BindingRequirement[]>} byKey
|
||||
* @returns {PlannedCreate[]}
|
||||
*/
|
||||
function shareSameResource(adopt, create, byKey) {
|
||||
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
|
||||
const declaredName = (kind, binding) =>
|
||||
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
|
||||
|
||||
/** @type {PlannedCreate[]} */
|
||||
const out = [];
|
||||
/** @type {Map<string, PlannedCreate>} */
|
||||
const groups = new Map();
|
||||
|
||||
for (const c of create) {
|
||||
const groupKey = `${c.kind} ${c.createName}`;
|
||||
|
||||
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
|
||||
const twin = adopt.find(
|
||||
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
|
||||
);
|
||||
if (twin) {
|
||||
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
|
||||
const head = groups.get(groupKey);
|
||||
if (head) {
|
||||
head.alsoBind.push(c.binding);
|
||||
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
|
||||
continue;
|
||||
}
|
||||
groups.set(groupKey, c);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 照 plan 動手:沿用的原樣帶出來,該建的才建。
|
||||
* 有任何 blocker 直接丟 ResourcePlanBlocked,**一顆都不建**。
|
||||
*
|
||||
* @param {ResourceApi} api
|
||||
* @param {ResourcePlan} plan
|
||||
* @returns {Promise<Map<string, ResolvedResource>>}
|
||||
*/
|
||||
export async function applyResourcePlan(api, plan) {
|
||||
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
|
||||
|
||||
/** @type {Map<string, ResolvedResource>} */
|
||||
const out = new Map();
|
||||
for (const a of plan.adopt) {
|
||||
out.set(bindingKey(a.kind, a.binding), {
|
||||
kind: a.kind,
|
||||
binding: a.binding,
|
||||
value: a.value,
|
||||
origin: 'adopted',
|
||||
from: a.from,
|
||||
});
|
||||
}
|
||||
/** @type {string[]} */
|
||||
const madeSoFar = [];
|
||||
for (const c of plan.create) {
|
||||
/** @type {string} */
|
||||
let value;
|
||||
try {
|
||||
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
|
||||
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
|
||||
else value = await api.createVectorizeIndex(c.createName);
|
||||
} catch (e) {
|
||||
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
|
||||
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
|
||||
const orphans = madeSoFar.length > 0
|
||||
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
|
||||
: '';
|
||||
throw new Error(`建 ${KIND_LABEL[c.kind]}「${c.createName}」失敗:${msg(e)}${orphans}`);
|
||||
}
|
||||
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
|
||||
for (const binding of [c.binding, ...c.alsoBind]) {
|
||||
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// wrangler.toml → 需求清單
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。
|
||||
* @type {Record<string, ResourceKind>}
|
||||
*/
|
||||
export const TABLE_KIND = {
|
||||
kv_namespaces: 'kv_namespace',
|
||||
d1_databases: 'd1',
|
||||
vectorize: 'vectorize',
|
||||
};
|
||||
|
||||
/**
|
||||
* 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
|
||||
*
|
||||
* 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作,
|
||||
* 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
|
||||
* (kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。
|
||||
*
|
||||
* 也是「零依賴」的一部分:不引 TOML parser ⇒ 安裝器 import 這支不必多裝任何東西。
|
||||
*
|
||||
* @param {string} toml
|
||||
* @returns {WranglerRequirements}
|
||||
*/
|
||||
export function parseWranglerRequirements(toml) {
|
||||
let script = '';
|
||||
let seenTable = false;
|
||||
/** @type {WranglerRequirements['bindings']} */
|
||||
const bindings = [];
|
||||
|
||||
/** @type {ResourceKind | null} */
|
||||
let kind = null;
|
||||
let binding = '';
|
||||
let createName = '';
|
||||
|
||||
const flush = () => {
|
||||
if (kind && binding) {
|
||||
bindings.push({ kind, binding, createName: createName || binding });
|
||||
}
|
||||
kind = null;
|
||||
binding = '';
|
||||
createName = '';
|
||||
};
|
||||
|
||||
for (const raw of toml.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line === '' || line.startsWith('#')) continue;
|
||||
|
||||
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
|
||||
if (table) {
|
||||
flush();
|
||||
seenTable = true;
|
||||
kind = TABLE_KIND[table[1]] ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (!kv) continue;
|
||||
const [, key, value] = kv;
|
||||
|
||||
if (!seenTable && key === 'name') {
|
||||
script = value;
|
||||
continue;
|
||||
}
|
||||
if (!kind) continue;
|
||||
if (key === 'binding') binding = value;
|
||||
// 只有 D1/Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
|
||||
else if (key === 'database_name' || key === 'index_name') createName = value;
|
||||
}
|
||||
flush();
|
||||
|
||||
return { script, bindings };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
|
||||
* (同一種資源在不同 API 版本欄位名不一,故全都收)。
|
||||
*
|
||||
* @typedef {object} RawWorkerBinding
|
||||
* @property {string} [type]
|
||||
* @property {string} [name]
|
||||
* @property {string} [namespace_id]
|
||||
* @property {string} [id]
|
||||
* @property {string} [database_id]
|
||||
* @property {string} [index_name]
|
||||
* @property {string} [text] `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 把 CF 的 binding 陣列收斂成規則認得的三種資源。不認得的型別直接略過。
|
||||
*
|
||||
* 🔴 這支**刻意放在規則裡**,不留在各自的 CF client:
|
||||
* 「什麼才算『這顆 worker 綁著某顆資源』」是規則的一部分。
|
||||
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來(例如一邊認 `namespace_id`、
|
||||
* 另一邊只認 `id`,於是一邊看得到綁定、另一邊看不到 → 後者又去新建了)。
|
||||
*
|
||||
* @param {RawWorkerBinding[]} raw
|
||||
* @returns {LiveBinding[]}
|
||||
*/
|
||||
export function normalizeLiveBindings(raw) {
|
||||
/** @type {LiveBinding[]} */
|
||||
const out = [];
|
||||
for (const b of raw) {
|
||||
if (!b?.name) continue;
|
||||
if (b.type === 'kv_namespace') {
|
||||
const value = b.namespace_id ?? b.id;
|
||||
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
|
||||
} else if (b.type === 'd1' || b.type === 'd1_database') {
|
||||
const value = b.id ?? b.database_id;
|
||||
if (value) out.push({ kind: 'd1', binding: b.name, value });
|
||||
} else if (b.type === 'vectorize') {
|
||||
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抽出已部署 worker 上的 `plain_text` var(#106)。
|
||||
*
|
||||
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被搬來搬去;
|
||||
* wrangler deploy 不會動 secret,它們自己會留著)。
|
||||
*
|
||||
* @param {RawWorkerBinding[]} raw
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function normalizeLiveVars(raw) {
|
||||
/** @type {Record<string, string>} */
|
||||
const out = {};
|
||||
for (const b of raw) {
|
||||
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Arcrun#108 迴歸守衛 —— 「雲端讀資料用的命名空間,要跟你寫資料用的那個一致」
|
||||
*
|
||||
* 2026-08-12 實害:leo 的藏書地圖回 0 個庫,實際有 1854 條三元組。
|
||||
* 根因:你 push 工作流、小幫手上傳知識、MCP 查詢都用 `~/.arcrun/config.yaml` 的 `api_key`
|
||||
* (leo = `bfezv28v`),但 cypher 讀取時的 owner_id 來自 worker 環境變數
|
||||
* ——而那個變數是 repo toml 帶的**官方 prod 值** `CONSOLE_TENANT = "leo"`。
|
||||
* 寫在 A、讀在 B,全被過濾掉。
|
||||
*
|
||||
* 這份測試守兩件相反的事(本次的核心判斷):
|
||||
* · 驗得到知識 → **寫** `ARCRUN_NAMESPACE`,讓讀寫兩端對齊
|
||||
* · 驗不到 / 問不到 → **一個字都不動**,既有值原封保留
|
||||
* (無條件覆蓋會把一台「知識本來就寫在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
|
||||
* ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟)
|
||||
*
|
||||
* 全部離線跑:真的 wrangler.toml + 真的 render 程式碼,fetch 用假的,不碰任何實例。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
renderWranglerToml,
|
||||
preservedVars,
|
||||
namespaceHasKnowledge,
|
||||
VERSION_STAMP_WORKER,
|
||||
type DeployContext,
|
||||
} from '../src/lib/deploy.ts';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||||
|
||||
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
|
||||
const LEO_NS = 'bfezv28v';
|
||||
|
||||
const CTX: DeployContext = {
|
||||
accountId: 'acc-user-123',
|
||||
apiToken: 'token',
|
||||
workerSubdomain: 'user-sub',
|
||||
selfHosted: true,
|
||||
kbdbEmbed: true,
|
||||
};
|
||||
|
||||
function readVars(toml: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
let inVars = false;
|
||||
for (const line of toml.split('\n')) {
|
||||
if (/^\s*\[vars\]/.test(line)) { inVars = true; continue; }
|
||||
if (/^\s*\[/.test(line)) { inVars = false; continue; }
|
||||
if (!inVars) continue;
|
||||
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (m) out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 模擬 downloadAndDeploy 那段:沿用既有 var,再疊上這趟 CLI 算出來的值。 */
|
||||
function deployedVars(ctx: DeployContext, live: Record<string, string>): Record<string, string> {
|
||||
const keep = preservedVars(live, CYPHER_TOML);
|
||||
const extra: Record<string, string> = { ...keep };
|
||||
if (ctx.knowledgeNamespace) extra.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||||
return readVars(renderWranglerToml(CYPHER_TOML, ctx, new Map(), extra));
|
||||
}
|
||||
|
||||
// ── ① 驗得到知識 → 寫進去 ────────────────────────────────────────────────────
|
||||
|
||||
test('#108 給了 knowledgeNamespace → cypher [vars] 出現 ARCRUN_NAMESPACE(讀寫兩端終於同一個值)', () => {
|
||||
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, {});
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
|
||||
// CONSOLE_TENANT 一個字都不能動——它同時是帳號子 namespace 的組成,改了舊實例登不進去
|
||||
assert.equal(vars.CONSOLE_TENANT, 'leo');
|
||||
});
|
||||
|
||||
test('#108 蓋得過 worker 上的舊值(改名/搬遷後 acr update 要能修正,不是永遠沿用第一次那個)', () => {
|
||||
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, { ARCRUN_NAMESPACE: 'stale-ns' });
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
|
||||
});
|
||||
|
||||
// ── ② 驗不到 → 什麼都不動(比 bug 更糟的是把人家原本正常的實例弄空)──────────────
|
||||
|
||||
test('#108 沒給 knowledgeNamespace → 既有的 ARCRUN_NAMESPACE 原封保留(不因為這趟驗不到就洗掉)', () => {
|
||||
const vars = deployedVars(CTX, { ARCRUN_NAMESPACE: 'user-existing-ns' });
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, 'user-existing-ns');
|
||||
});
|
||||
|
||||
test('#108 沒給、worker 上也沒有 → 不注入(回退 CONSOLE_TENANT,舊實例行為一字不變)', () => {
|
||||
const vars = deployedVars(CTX, {});
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, undefined);
|
||||
assert.equal(vars.CONSOLE_TENANT, 'leo');
|
||||
});
|
||||
|
||||
test('#108 ARCRUN_NAMESPACE 不在 CLI_MANAGED_VARS:它「不是每趟重算」而是「驗到才寫」,' +
|
||||
'列進去會讓驗不到的那趟把既有值一起洗掉', async () => {
|
||||
const { CLI_MANAGED_VARS } = await import('../src/lib/deploy.ts');
|
||||
assert.equal((CLI_MANAGED_VARS as readonly string[]).includes('ARCRUN_NAMESPACE'), false);
|
||||
});
|
||||
|
||||
test('#108 只烙在 cypher 這顆 worker(其他 worker 不需要知識命名空間)', () => {
|
||||
assert.equal(VERSION_STAMP_WORKER, 'arcrun-cypher-executor');
|
||||
});
|
||||
|
||||
// ── ③ 「先驗再寫」那支探針的三態 ───────────────────────────────────────────────
|
||||
|
||||
test('namespaceHasKnowledge:這個命名空間底下查得到庫 → true(可以安全寫進去)', async () => {
|
||||
const calls: string[] = [];
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
calls.push(String(url));
|
||||
assert.equal((init?.headers as Record<string, string>)['X-Arcrun-API-Key'], LEO_NS);
|
||||
return new Response(JSON.stringify({ success: true, libraries: [{ library: 'kb' }], count: 1 }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), true);
|
||||
assert.equal(calls[0], `https://cypher.example.dev/kbdb/map?owner_id=${LEO_NS}`);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:查得到但是空的 → false(知識可能在別的命名空間,不准蓋)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: true, libraries: [], count: 0 }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), false);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:問不到(實例沒起來/舊版沒這條路/網路斷)→ null,不宣稱任何事', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:回應形狀不對 → null(讀不出來 ≠ 沒有資料,禁假綠)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:缺 url 或缺 namespace → null(不打任何請求)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () => { throw new Error('不該被呼叫'); }) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('', LEO_NS), null);
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', ''), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** `node --import ./tests/register-ts-hooks.mjs --test ...` 的進入點:註冊 ts-hooks.mjs。 */
|
||||
import { register } from 'node:module';
|
||||
|
||||
register('./ts-hooks.mjs', import.meta.url);
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Arcrun#97 迴歸守衛 —— 「跑完更新,使用者的東西還在原地」
|
||||
*
|
||||
* 2026-08-12 實害:leo 跑了一次例行更新,跑完工作流一支都沒有、portal 把他登出、
|
||||
* 總圖是空的、子庫全不見。資料沒被刪,但 worker 被重新綁到 9 顆新建的空 KV + 1 顆空 D1 上。
|
||||
*
|
||||
* 根因:更新「照名字」確保資源存在——拿 binding 名(WEBHOOKS)當 CF 上的資源標題去找,
|
||||
* 安裝器建的資源叫 `arcrun-rag-<instance>-kv-webhooks`,永遠對不上 ⇒ 每次更新都新建一顆綁上去。
|
||||
*
|
||||
* 這份測試用一台**照安裝器命名慣例**的假實例(不是 leo 的實例,不碰 leo21c)跑真正的
|
||||
* 解析程式碼(planResources / applyResourcePlan / renderWranglerToml),對照更新前後:
|
||||
* ① 工作流數、登入狀態、子庫數 ② 帳號上的資源顆數 ③ 找不到既有資源時要停手
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
planResources,
|
||||
applyResourcePlan,
|
||||
parseWranglerRequirements,
|
||||
bindingKey,
|
||||
ResourcePlanBlocked,
|
||||
type BindingRequirement,
|
||||
type ResourceApi,
|
||||
type ScriptBindings,
|
||||
type LiveBinding,
|
||||
type ResolvedResource,
|
||||
} from '../src/lib/resource-resolver.ts';
|
||||
import {
|
||||
renderWranglerToml,
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
type DeployContext,
|
||||
} from '../src/lib/deploy.ts';
|
||||
import { CfAccountClient } from '../src/lib/cf-api.ts';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
|
||||
/** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */
|
||||
const WORKER_TOMLS = [
|
||||
'cypher-executor/wrangler.toml',
|
||||
'registry/wrangler.toml',
|
||||
'mcp/wrangler.toml',
|
||||
'kbdb/wrangler.toml',
|
||||
];
|
||||
|
||||
const CTX: DeployContext = {
|
||||
accountId: 'acct-test',
|
||||
apiToken: 'tok-test',
|
||||
workerSubdomain: 'yuga3bse',
|
||||
selfHosted: true,
|
||||
kbdbEmbed: true,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const INSTANCE = 'yuga3bse';
|
||||
|
||||
interface FakeOpts {
|
||||
/** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */
|
||||
failBindingsFor?: string;
|
||||
/** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */
|
||||
deleteKvTitle?: string;
|
||||
/** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */
|
||||
nothingDeployed?: boolean;
|
||||
}
|
||||
|
||||
class FakeCloudflare implements ResourceApi {
|
||||
/** title → id */
|
||||
kv = new Map<string, string>();
|
||||
/** name → uuid */
|
||||
d1 = new Map<string, string>();
|
||||
vectorize: string[] = [];
|
||||
/** script → bindings */
|
||||
scripts = new Map<string, LiveBinding[]>();
|
||||
|
||||
/** 使用者的東西:kvId → (key → value) */
|
||||
kvData = new Map<string, Map<string, string>>();
|
||||
/** d1Id → 子庫名單 */
|
||||
d1Libraries = new Map<string, string[]>();
|
||||
|
||||
/** 這趟總共建立了什麼(驗「顆數不增加」用)。 */
|
||||
createdKv: string[] = [];
|
||||
createdD1: string[] = [];
|
||||
createdVectorize: string[] = [];
|
||||
|
||||
constructor(private opts: FakeOpts = {}) {
|
||||
// 安裝器的命名慣例:arcrun-rag-<instance>-kv-<binding 小寫>
|
||||
for (const binding of REQUIRED_KV_NAMESPACES) {
|
||||
const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`;
|
||||
const id = `kvid-${binding.toLowerCase()}`;
|
||||
this.kv.set(title, id);
|
||||
this.kvData.set(id, new Map());
|
||||
}
|
||||
this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb');
|
||||
this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`);
|
||||
|
||||
// 使用者的東西
|
||||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}');
|
||||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}');
|
||||
this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}');
|
||||
this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}');
|
||||
this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']);
|
||||
|
||||
if (!opts.nothingDeployed) {
|
||||
const kvB = (b: string): LiveBinding =>
|
||||
({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` });
|
||||
this.scripts.set('arcrun-cypher-executor', [
|
||||
kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'),
|
||||
kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'),
|
||||
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' },
|
||||
]);
|
||||
this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]);
|
||||
this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]);
|
||||
this.scripts.set('arcrun-kbdb', [
|
||||
{ kind: 'd1', binding: 'DB', value: 'd1id-kbdb' },
|
||||
{ kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` },
|
||||
]);
|
||||
}
|
||||
|
||||
if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle);
|
||||
}
|
||||
|
||||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)');
|
||||
const b = this.scripts.get(script);
|
||||
return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] };
|
||||
}
|
||||
async listKvNamespaces(): Promise<Map<string, string>> { return new Map(this.kv); }
|
||||
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
|
||||
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
|
||||
async createKvNamespace(title: string): Promise<string> {
|
||||
const id = `NEW-kvid-${this.createdKv.length}`;
|
||||
this.kv.set(title, id);
|
||||
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
|
||||
this.createdKv.push(title);
|
||||
return id;
|
||||
}
|
||||
async createD1Database(name: string): Promise<string> {
|
||||
const id = `NEW-d1id-${this.createdD1.length}`;
|
||||
this.d1.set(name, id);
|
||||
this.d1Libraries.set(id, []);
|
||||
this.createdD1.push(name);
|
||||
return id;
|
||||
}
|
||||
async createVectorizeIndex(name: string): Promise<string> {
|
||||
this.vectorize.push(name);
|
||||
this.createdVectorize.push(name);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
|
||||
const requirements: BindingRequirement[] = [];
|
||||
const tomls = new Map<string, string>();
|
||||
for (const rel of WORKER_TOMLS) {
|
||||
const raw = readFileSync(join(REPO, rel), 'utf8');
|
||||
tomls.set(rel, raw);
|
||||
const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
|
||||
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
return { requirements, tomls };
|
||||
}
|
||||
|
||||
/** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */
|
||||
function deployAndReadBindings(
|
||||
tomls: Map<string, string>,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
): Map<string, Map<string, string>> {
|
||||
const out = new Map<string, Map<string, string>>();
|
||||
for (const [rel, raw] of tomls) {
|
||||
const rendered = renderWranglerToml(raw, CTX, resolved);
|
||||
const script = parseWranglerRequirements(rendered).script;
|
||||
const bound = new Map<string, string>();
|
||||
let kind: string | null = null;
|
||||
let binding = '';
|
||||
for (const line of rendered.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith('#')) continue;
|
||||
const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
|
||||
if (table) { kind = table[1]; binding = ''; continue; }
|
||||
const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (!m) continue;
|
||||
if (m[1] === 'binding') binding = m[2];
|
||||
else if (binding && (
|
||||
(kind === 'kv_namespaces' && m[1] === 'id')
|
||||
|| (kind === 'd1_databases' && m[1] === 'database_id')
|
||||
|| (kind === 'vectorize' && m[1] === 'index_name')
|
||||
)) bound.set(binding, m[2]);
|
||||
}
|
||||
out.set(script, bound);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
const { requirements, tomls } = collectRequirements();
|
||||
|
||||
const before = {
|
||||
workflows: cf.kvData.get('kvid-webhooks')!.size,
|
||||
sessions: cf.kvData.get('kvid-sessions_kv')!.size,
|
||||
libraries: cf.d1Libraries.get('d1id-kbdb')!.length,
|
||||
};
|
||||
assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好');
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker');
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
|
||||
const bound = deployAndReadBindings(tomls, resolved);
|
||||
|
||||
// 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要)
|
||||
const cypher = bound.get('arcrun-cypher-executor')!;
|
||||
assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks');
|
||||
assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv');
|
||||
assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb');
|
||||
assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb');
|
||||
assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv');
|
||||
assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv');
|
||||
assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`);
|
||||
|
||||
const after = {
|
||||
workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size,
|
||||
sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size,
|
||||
libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length,
|
||||
};
|
||||
assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣');
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97 ②:更新不會在帳號上多生任何資源', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
const kvBefore = cf.kv.size;
|
||||
const d1Before = cf.d1.size;
|
||||
const vecBefore = cf.vectorize.length;
|
||||
assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]);
|
||||
|
||||
const { requirements } = collectRequirements();
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
await applyResourcePlan(cf, plan);
|
||||
|
||||
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
|
||||
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
|
||||
assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index');
|
||||
assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]);
|
||||
});
|
||||
|
||||
test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => {
|
||||
// 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與
|
||||
// cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。
|
||||
// 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。
|
||||
const cf = new FakeCloudflare();
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建
|
||||
}
|
||||
const d1s = await cf.listD1Databases();
|
||||
if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb');
|
||||
|
||||
assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)');
|
||||
assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆');
|
||||
assert.equal(cf.kv.size, 18, '9 → 18');
|
||||
assert.equal(cf.d1.size, 2, '1 → 2');
|
||||
// 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的
|
||||
assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97 ③-a:worker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => {
|
||||
const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` });
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.ok(plan.blockers.length > 0, '要有 blocker');
|
||||
const said = plan.blockers.join('\n');
|
||||
assert.match(said, /WEBHOOKS/, '要指名是哪個綁定');
|
||||
assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源');
|
||||
assert.match(said, /找不到/, '要說清楚發生什麼事');
|
||||
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」');
|
||||
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來');
|
||||
assert.deepEqual(cf.createdD1, []);
|
||||
});
|
||||
|
||||
test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => {
|
||||
const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' });
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/);
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, []);
|
||||
});
|
||||
|
||||
test('#97 ③-c:update 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/);
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, []);
|
||||
});
|
||||
|
||||
test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
// registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊)
|
||||
cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other';
|
||||
cf.kv.set('some-other-kv', 'kvid-other');
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/);
|
||||
assert.deepEqual(cf.createdKv, []);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 合法的新建:只有「確定沒人綁過」時才准
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的)
|
||||
cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`);
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.deepEqual(plan.blockers, []);
|
||||
assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建');
|
||||
await applyResourcePlan(cf, plan);
|
||||
assert.deepEqual(cf.createdKv, ['OAUTH_KV']);
|
||||
assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆');
|
||||
});
|
||||
|
||||
test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;
|
||||
const { requirements } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'init');
|
||||
assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手');
|
||||
await applyResourcePlan(cf, plan);
|
||||
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length);
|
||||
assert.deepEqual(cf.createdD1, ['arcrun-kbdb']);
|
||||
assert.equal(cf.createdVectorize.length, 1);
|
||||
});
|
||||
|
||||
test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => {
|
||||
const cf = new FakeCloudflare();
|
||||
cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb)
|
||||
const { requirements, tomls } = collectRequirements();
|
||||
|
||||
const plan = await planResources(cf, requirements, 'update');
|
||||
assert.deepEqual(plan.blockers, []);
|
||||
assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆');
|
||||
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
const bound = deployAndReadBindings(tomls, resolved);
|
||||
assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb',
|
||||
'credential 目錄要跟 KBDB 在同一顆庫');
|
||||
assert.deepEqual(cf.createdD1, []);
|
||||
});
|
||||
|
||||
test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => {
|
||||
// repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入,
|
||||
// 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。
|
||||
const cf = new FakeCloudflare();
|
||||
const { requirements, tomls } = collectRequirements();
|
||||
const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update'));
|
||||
|
||||
const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f';
|
||||
for (const [rel, raw] of tomls) {
|
||||
const rendered = renderWranglerToml(raw, CTX, resolved);
|
||||
assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`);
|
||||
assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`);
|
||||
}
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 做法本身的看守:不准再出現「照名字 ensure」這種原語
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => {
|
||||
const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8');
|
||||
assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/,
|
||||
'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。'
|
||||
+ '要建資源一律先過 resource-resolver 的 planResources。');
|
||||
});
|
||||
|
||||
test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => {
|
||||
for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) {
|
||||
const src = readFileSync(join(REPO, rel), 'utf8');
|
||||
assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/,
|
||||
`${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`);
|
||||
}
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 底層零件
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => {
|
||||
const toml = [
|
||||
'name = "arcrun-kbdb" # 註解不影響',
|
||||
'',
|
||||
'[[d1_databases]]',
|
||||
'binding = "DB"',
|
||||
'database_name = "arcrun-kbdb"',
|
||||
'database_id = "placeholder"',
|
||||
'',
|
||||
'[vars]',
|
||||
'ENVIRONMENT = "production"',
|
||||
'',
|
||||
'# [[vectorize]]',
|
||||
'# binding = "VECTORIZE"',
|
||||
'# index_name = "arcrun-kbdb-embed-m3"',
|
||||
].join('\n');
|
||||
const r = parseWranglerRequirements(toml);
|
||||
assert.equal(r.script, 'arcrun-kbdb');
|
||||
assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]);
|
||||
});
|
||||
|
||||
test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => {
|
||||
const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"');
|
||||
assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]);
|
||||
});
|
||||
|
||||
test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => {
|
||||
const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8');
|
||||
const resolved = new Map<string, ResolvedResource>([
|
||||
[bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }],
|
||||
[bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }],
|
||||
]);
|
||||
const out = renderWranglerToml(cypher, CTX, resolved);
|
||||
const bound = parseWranglerRequirements(out);
|
||||
assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB'));
|
||||
assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/);
|
||||
assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/);
|
||||
// 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved)
|
||||
assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/);
|
||||
});
|
||||
|
||||
test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => {
|
||||
for (const rel of WORKER_TOMLS) {
|
||||
const raw = readFileSync(join(REPO, rel), 'utf8');
|
||||
const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
|
||||
const resolved = new Map<string, ResolvedResource>(
|
||||
preview.bindings.map((b) => [
|
||||
bindingKey(b.kind, b.binding),
|
||||
{ kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const },
|
||||
]),
|
||||
);
|
||||
const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved));
|
||||
assert.deepEqual(
|
||||
after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
|
||||
preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
|
||||
`${rel}: 預覽與實際注入看到的綁定必須一致`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => {
|
||||
const { requirements } = collectRequirements();
|
||||
const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))];
|
||||
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
|
||||
});
|
||||
|
||||
test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
|
||||
) as typeof fetch;
|
||||
const cf = new CfAccountClient('a', 't');
|
||||
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [], vars: {} });
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
|
||||
) as typeof fetch;
|
||||
await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
success: true,
|
||||
result: {
|
||||
bindings: [
|
||||
{ type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' },
|
||||
{ type: 'd1', name: 'DB', id: 'db1' },
|
||||
{ type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' },
|
||||
{ type: 'plain_text', name: 'ENVIRONMENT', text: 'production' },
|
||||
{ type: 'service', name: 'SVC_SET', service: 'arcrun-set' },
|
||||
],
|
||||
},
|
||||
}), { status: 200 })) as typeof fetch;
|
||||
const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor');
|
||||
assert.equal(res.deployed, true);
|
||||
assert.deepEqual(res.bindings, [
|
||||
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' },
|
||||
{ kind: 'd1', binding: 'DB', value: 'db1' },
|
||||
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
|
||||
]);
|
||||
// #106:plain_text 也要收下來(service 這種不認得的仍略過)。
|
||||
assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 「只有一份」的機械證明。
|
||||
*
|
||||
* leo 的驗收條件:「改完之後,`grep` 得出『決定用哪些資源』的邏輯**只有一個地方**。
|
||||
* 兩個以上呼叫端各自有一份 ⇒ 不算完成。」
|
||||
*
|
||||
* 這份測試就是把那個 grep 寫成會紅的東西:
|
||||
* ① 規則的每一支函式,全 repo 只有 `shared/resource-rule/` 有實作
|
||||
* (`cli/src/lib/resource-rule/` 是它的逐位元組鏡射,由 sync 腳本產生並看守,不算第二份)
|
||||
* ② 鏡射與原稿逐位元組相同(sync --check 的同一道閘,這裡再測一次讓 `npm test` 也擋得住)
|
||||
* ③ 共用層不准長出依賴——有依賴就會有某條路吃不到它
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
|
||||
const MIRROR_DIR = join(REPO, 'cli/src/lib/resource-rule');
|
||||
|
||||
/** 規則的實作特徵:這些**宣告**只准出現在原稿目錄(與它的鏡射)裡。 */
|
||||
const RULE_DECLARATIONS = [
|
||||
'function planResources',
|
||||
'function applyResourcePlan',
|
||||
'function shareSameResource',
|
||||
'function parseWranglerRequirements',
|
||||
'function normalizeLiveBindings',
|
||||
'function normalizeLiveVars',
|
||||
'function createCloudflareResourceApi',
|
||||
];
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', '.wrangler', '.worker-builds', '.component-builds',
|
||||
'.github-public', 'coverage',
|
||||
]);
|
||||
|
||||
/** 只掃「人會寫程式的地方」;產生物與二進位不掃。 */
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const abs = join(dir, name);
|
||||
const st = statSync(abs);
|
||||
if (st.isDirectory()) walk(abs, out);
|
||||
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) out.push(abs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sha256 = (b: Buffer): string => createHash('sha256').update(b).digest('hex');
|
||||
|
||||
test('① 規則的實作全 repo 只有一份(原稿目錄 + 它的鏡射,沒有第三處)', () => {
|
||||
const files = walk(REPO);
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const abs of files) {
|
||||
const rel = relative(REPO, abs);
|
||||
// 原稿與鏡射本來就該有;測試檔在講規則、不是實作規則
|
||||
if (rel.startsWith('shared/resource-rule/')) continue;
|
||||
if (rel.startsWith('cli/src/lib/resource-rule/')) continue;
|
||||
if (rel.startsWith('cli/tests/')) continue;
|
||||
if (rel === 'scripts/sync-resource-rule.mjs') continue;
|
||||
|
||||
const src = readFileSync(abs, 'utf8');
|
||||
for (const decl of RULE_DECLARATIONS) {
|
||||
if (src.includes(decl)) offenders.push(`${rel} → ${decl}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(offenders, [],
|
||||
'「決定用哪些資源」的實作出現在共用層之外——這正是本票要消滅的東西:\n' +
|
||||
offenders.map((o) => ` • ${o}`).join('\n') +
|
||||
'\n要改規則就改 shared/resource-rule/,呼叫端只准 import。');
|
||||
|
||||
console.log(`\n ① 掃過 ${files.length} 個原始碼檔,${RULE_DECLARATIONS.length} 支規則函式的實作` +
|
||||
' 全部只出現在 shared/resource-rule/(+機械鏡射)');
|
||||
});
|
||||
|
||||
test('② CLI 帶的那份與原稿逐位元組相同(漂移=第二份實作偷偷長出來)', () => {
|
||||
const files = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
|
||||
assert.ok(files.length > 0, 'shared/resource-rule/ 裡沒有任何 .mjs 原稿');
|
||||
|
||||
const mirrored = readdirSync(MIRROR_DIR).filter((f) => f.endsWith('.mjs')).sort();
|
||||
assert.deepEqual(mirrored, files, '鏡射目錄的檔案清單與原稿不一致');
|
||||
|
||||
for (const f of files) {
|
||||
const a = sha256(readFileSync(join(SOURCE_DIR, f)));
|
||||
const b = sha256(readFileSync(join(MIRROR_DIR, f)));
|
||||
assert.equal(b, a, `cli/src/lib/resource-rule/${f} 與原稿不一致——不要手改產生物,` +
|
||||
'改 shared/resource-rule/ 後跑 node scripts/sync-resource-rule.mjs');
|
||||
console.log(` ② ${f.padEnd(24)} sha256 ${a.slice(0, 16)} 原稿 = 鏡射`);
|
||||
}
|
||||
});
|
||||
|
||||
test('③ 共用層零外部依賴(只准 import 同目錄的兄弟檔)', () => {
|
||||
for (const f of readdirSync(SOURCE_DIR).filter((x) => x.endsWith('.mjs'))) {
|
||||
const src = readFileSync(join(SOURCE_DIR, f), 'utf8');
|
||||
const imports = [...src.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1]);
|
||||
for (const spec of imports) {
|
||||
assert.ok(spec.startsWith('./'),
|
||||
`shared/resource-rule/${f} import 了 "${spec}"——共用層一旦有外部依賴,` +
|
||||
'就會有某條路(Workers runtime/安裝器)吃不到它。');
|
||||
}
|
||||
assert.doesNotMatch(src, /require\(|from\s+['"]node:/,
|
||||
`shared/resource-rule/${f} 用到 node 專屬 API——Cloudflare Workers 上跑不起來。`);
|
||||
console.log(` ③ ${f.padEnd(24)} import: ${imports.length ? imports.join(', ') : '(無)'}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 測試用 resolve hook:把 `./x.js` 這種 import 指回同名的 `./x.ts`(Arcrun#106 附帶修復)。
|
||||
*
|
||||
* 為什麼需要:`src/` 內部的 import 一律寫成 `.js`(NodeNext 慣例,編譯後才會有那個檔),
|
||||
* 但測試是**直接載入 `src/**\/*.ts`**、不經過 tsc(`outDir: dist`,所以 `src/` 底下永遠不會有 .js)。
|
||||
* Node 的型別剝離不會自己把 `.js` 對回 `.ts` ⇒ 三份測試在 node 22 上**一支都跑不起來**
|
||||
* (`ERR_MODULE_NOT_FOUND: .../src/lib/cf-api.js`)——包含 #97 那份「使用者的東西還在不在」的迴歸守衛。
|
||||
* 跑不起來的守衛等於沒有守衛,所以這裡補上。
|
||||
*
|
||||
* 只在「預設解析失敗」時才動作,且只換副檔名 → 對本來就解析得到的環境(新版 node / 已編譯)零影響。
|
||||
*/
|
||||
export async function resolve(specifier, context, next) {
|
||||
try {
|
||||
return await next(specifier, context);
|
||||
} catch (err) {
|
||||
if (typeof specifier === 'string' && specifier.endsWith('.js')) {
|
||||
return next(specifier.slice(0, -3) + '.ts', context);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 兩條路必須得出同一個答案 —— 本票的核心驗收。
|
||||
*
|
||||
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
|
||||
*
|
||||
* 後果已經真的發生過:`acr` 那條有 Arcrun#97 的修法、安裝器那條沒有,
|
||||
* 於是安裝器照名字找、找不到就建一顆空的綁上去 ⇒ 使用者的工作流與登入狀態整片消失。
|
||||
*
|
||||
* 這份測試把**同一個帳號狀態**餵給兩條路:
|
||||
* A. `acr` 那條:`CfAccountClient` + `resource-resolver`(CLI 真正跑的 import 鏈)
|
||||
* B. 安裝器那條:只 import `shared/resource-rule/`(安裝器唯一該碰的入口)
|
||||
* 然後比對它們選出的 **resource id 必須相同**。
|
||||
*
|
||||
* 假的是 `fetch`,不是 `ResourceApi`——所以兩條路都真的走完 HTTP → 解析 → 判斷整條鏈。
|
||||
* 只測判斷會漏掉「怎麼把 CF 回應讀成事實」,而 #97 的重演只要眼睛不一樣就夠了。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// ── A:acr 那條(CLI 真正用的東西)
|
||||
import { CfAccountClient } from '../src/lib/cf-api.ts';
|
||||
import { planResources, applyResourcePlan, bindingKey } from '../src/lib/resource-resolver.ts';
|
||||
import type { BindingRequirement } from '../src/lib/resource-resolver.ts';
|
||||
|
||||
// ── B:安裝器那條(只碰 shared/)
|
||||
import { resolveInstanceResources } from '../../shared/resource-rule/installer-entry.mjs';
|
||||
|
||||
// ── 共用 fixture
|
||||
import {
|
||||
makeAccount,
|
||||
requirements,
|
||||
SCENARIOS,
|
||||
WORKER_NEEDS,
|
||||
type Scenario,
|
||||
} from '../../shared/resource-rule/tests/fixture-account.mjs';
|
||||
|
||||
const ACCOUNT = 'acct-fixture';
|
||||
const TOKEN = 'tok-fixture';
|
||||
|
||||
/** 把 fixture 的需求組成安裝器吃的 wrangler.toml 文字(它的入口是從 toml 讀需求的)。 */
|
||||
function tomlsFor(): string[] {
|
||||
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
|
||||
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
|
||||
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
|
||||
for (const d of need.d1) {
|
||||
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
/** A:跑 acr 那條。CfAccountClient 走 global fetch,所以這裡把它換成 fixture。 */
|
||||
async function runAcrPath(scenario: Scenario, mode: 'update' | 'init') {
|
||||
const account = makeAccount(scenario);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = account.fetch;
|
||||
try {
|
||||
const api = new CfAccountClient(ACCOUNT, TOKEN);
|
||||
const plan = await planResources(api, requirements() as BindingRequirement[], mode);
|
||||
if (plan.blockers.length > 0) {
|
||||
return { blocked: true, blockers: plan.blockers, bindings: {} as Record<string, string>, account };
|
||||
}
|
||||
const resolved = await applyResourcePlan(api, plan);
|
||||
const bindings: Record<string, string> = {};
|
||||
for (const [k, r] of resolved) bindings[k] = r.value;
|
||||
return { blocked: false, blockers: [] as string[], bindings, account };
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
}
|
||||
|
||||
/** B:跑安裝器那條。只用 shared/ 的入口,fetch 直接注入。 */
|
||||
async function runInstallerPath(scenario: Scenario, mode: 'update' | 'init') {
|
||||
const account = makeAccount(scenario);
|
||||
const r = await resolveInstanceResources({
|
||||
accountId: ACCOUNT,
|
||||
apiToken: TOKEN,
|
||||
wranglerTomls: tomlsFor(),
|
||||
mode,
|
||||
fetch: account.fetch,
|
||||
});
|
||||
return { blocked: r.blocked, blockers: r.blockers, bindings: r.bindings, account };
|
||||
}
|
||||
|
||||
/** 把兩邊的決定印出來——PR 要貼的就是這張對照表。 */
|
||||
function report(scenario: Scenario, a: Record<string, string>, b: Record<string, string>): void {
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||
console.log(`\n ── ${scenario}:${SCENARIOS[scenario].label}`);
|
||||
console.log(` ${'binding'.padEnd(34)} ${'acr 選的'.padEnd(24)} 安裝器選的 一致?`);
|
||||
for (const k of keys) {
|
||||
const same = a[k] === b[k] ? '✓' : '✗';
|
||||
console.log(` ${k.padEnd(34)} ${(a[k] ?? '—').padEnd(24)} ${(b[k] ?? '—').padEnd(18)} ${same}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
for (const scenario of ['fresh', 'installed', 'renamed'] as const) {
|
||||
const mode = scenario === 'fresh' ? 'init' : 'update';
|
||||
|
||||
test(`兩條路一致 — ${scenario}:${SCENARIOS[scenario].label}`, async () => {
|
||||
const a = await runAcrPath(scenario, mode);
|
||||
const b = await runInstallerPath(scenario, mode);
|
||||
|
||||
assert.equal(a.blocked, b.blocked, '一邊停手、一邊照做 = 最危險的分歧');
|
||||
assert.deepEqual(a.blockers, b.blockers, '停手的理由也要一樣');
|
||||
report(scenario, a.bindings, b.bindings);
|
||||
assert.deepEqual(
|
||||
a.bindings,
|
||||
b.bindings,
|
||||
`${scenario}:兩條路選出的 resource id 不同——這就是 Arcrun#97 的形狀`,
|
||||
);
|
||||
|
||||
// 建立行為也要一致(一邊沿用、一邊新建 = 使用者的東西在其中一條路上會消失)
|
||||
assert.deepEqual(a.account.created, b.account.created, '兩條路「建了什麼」必須一樣');
|
||||
});
|
||||
}
|
||||
|
||||
// ── 三種情境各自該有的行為(不只是「兩邊一樣」,還要「一樣地對」)─────────────
|
||||
|
||||
test('情境① 沒裝過 → 正常建新的(不能為了沿用而變成永遠不建)', async () => {
|
||||
const { blocked, bindings, account } = await runInstallerPath('fresh', 'init');
|
||||
assert.equal(blocked, false, '全新帳號要裝得起來');
|
||||
assert.equal(account.created.kv.length, 9, `應新建 9 顆 KV,實際 ${account.created.kv.length}`);
|
||||
assert.equal(account.created.d1.length, 1, `應新建 1 顆 D1,實際 ${account.created.d1.length}`);
|
||||
// cypher 的 CREDENTIALS_DB 與 kbdb 的 DB 宣告同一個 database_name → 只該建一顆,兩邊共用
|
||||
assert.equal(bindings['d1:CREDENTIALS_DB'], bindings['d1:DB'], '同一顆 D1 不該被建成兩顆');
|
||||
console.log(`\n ① 新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆` +
|
||||
`(D1 共用:CREDENTIALS_DB = DB = ${bindings['d1:DB']})`);
|
||||
});
|
||||
|
||||
test('情境② 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在', async () => {
|
||||
const { blocked, bindings, account } = await runInstallerPath('installed', 'update');
|
||||
assert.equal(blocked, false);
|
||||
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '更新不該建出任何新資源');
|
||||
|
||||
// 使用者的東西掛在資源 id 上:綁定還指向原本那顆 = 東西還在
|
||||
assert.equal(bindings['kv_namespace:WEBHOOKS'], account.kvIdFor('WEBHOOKS'));
|
||||
assert.equal(bindings['kv_namespace:SESSIONS_KV'], account.kvIdFor('SESSIONS_KV'));
|
||||
assert.equal(bindings['d1:DB'], account.d1Id);
|
||||
console.log(`\n ② 沿用:WEBHOOKS → ${bindings['kv_namespace:WEBHOOKS']}` +
|
||||
`(工作流 ${account.userData.workflows.length} 支還在)|` +
|
||||
`SESSIONS_KV → ${bindings['kv_namespace:SESSIONS_KV']}(登入 session 還在)|` +
|
||||
`DB → ${bindings['d1:DB']}(子庫 ${account.userData.libraries.length} 個還在)|新建 0 顆`);
|
||||
});
|
||||
|
||||
test('情境③ 資源在但名字與預期完全不同 → 仍然沿用(#97 的病根,專門驗)', async () => {
|
||||
const { blocked, bindings, account } = await runInstallerPath('renamed', 'update');
|
||||
assert.equal(blocked, false);
|
||||
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] },
|
||||
'名字對不上就新建 = 正是 #97:一次更新生出 9 顆空 KV,使用者的東西從畫面上消失');
|
||||
for (const b of ['WEBHOOKS', 'SESSIONS_KV', 'RECIPES', 'USERS_KV']) {
|
||||
assert.equal(bindings[bindingKey('kv_namespace', b)], account.kvIdFor(b),
|
||||
`${b} 沒有沿用到原本那顆`);
|
||||
}
|
||||
console.log(`\n ③ 名字全不同(例:WEBHOOKS 那顆實際叫 "${SCENARIOS.renamed.titleFor('WEBHOOKS')}")` +
|
||||
` → 仍沿用 ${bindings['kv_namespace:WEBHOOKS']},新建 0 顆`);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」
|
||||
*
|
||||
* 2026-08-12 實害:leo 更新完 leo21c,Portal 設定頁的版本欄變成
|
||||
* 「無法讀取目前版本(知識庫服務可能正在啟動)」。
|
||||
* 根因:`ARCRUN_BUNDLE_VERSION` 是部署時注入的 plain_text var,**只有安裝器會注入**;
|
||||
* CLI 這條路重部署時 wrangler 整份覆蓋 toml,沒寫的 var 直接消失 ⇒ 標籤被洗掉。
|
||||
* #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),**沒修「櫃子上的標籤」**。
|
||||
*
|
||||
* 這份測試守兩件相反的事(本次的核心判斷):
|
||||
* · 設定類 var(安裝器注入的 PORTAL_MAIL_RELAY_BASE 之類)=使用者實例的事實 → **沿用**
|
||||
* · 版本標籤 ARCRUN_BUNDLE_VERSION =這份成品的屬性 → **每趟重烙,絕不沿用舊值**
|
||||
* (沿用舊值 = 一個永遠停在安裝當天的假標籤,比沒有標籤更糟)
|
||||
*
|
||||
* 全部離線跑:真的 wrangler.toml + 真的 render/inject 程式碼,fetch 用假的,不碰任何實例。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
renderWranglerToml,
|
||||
preservedVars,
|
||||
applyVars,
|
||||
resolveBundleStamp,
|
||||
CLI_MANAGED_VARS,
|
||||
VERSION_STAMP_WORKER,
|
||||
type DeployContext,
|
||||
} from '../src/lib/deploy.ts';
|
||||
import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||||
|
||||
const CTX: DeployContext = {
|
||||
accountId: 'acc-user-123',
|
||||
apiToken: 'token',
|
||||
workerSubdomain: 'user-sub',
|
||||
selfHosted: true,
|
||||
kbdbEmbed: true,
|
||||
};
|
||||
|
||||
/** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */
|
||||
const LIVE_VARS: Record<string, string> = {
|
||||
ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤
|
||||
PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信
|
||||
CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的
|
||||
WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算
|
||||
CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算
|
||||
MULTI_TENANT: 'false', // CLI 自己算
|
||||
ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫
|
||||
};
|
||||
|
||||
/** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */
|
||||
function readVars(toml: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
let inVars = false;
|
||||
for (const raw of toml.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; }
|
||||
if (!inVars || line.startsWith('#')) continue;
|
||||
const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (m) out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ① 病灶本身:舊行為會把標籤洗掉
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => {
|
||||
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||||
assert.equal(
|
||||
readVars(rendered).ARCRUN_BUNDLE_VERSION,
|
||||
undefined,
|
||||
'若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫',
|
||||
);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ② 設定類 var:沿用實例上的事實
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => {
|
||||
const keep = preservedVars(LIVE_VARS, CYPHER_TOML);
|
||||
assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
// repo toml 寫死的是官方值,使用者實例上的值才是事實
|
||||
assert.equal(keep.CONSOLE_TENANT, 'someone-else');
|
||||
// 與 toml 同值 → 不需要重寫進去(雜訊)
|
||||
assert.equal(keep.ENVIRONMENT, undefined);
|
||||
});
|
||||
|
||||
test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => {
|
||||
const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML);
|
||||
for (const managed of CLI_MANAGED_VARS) {
|
||||
assert.equal(keep[managed], undefined, `${managed} 不該被沿用`);
|
||||
}
|
||||
// 而且注入完的 toml 裡,這些值仍是這趟算出來的那個
|
||||
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep);
|
||||
const vars = readVars(rendered);
|
||||
assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub');
|
||||
assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123');
|
||||
assert.equal(vars.MULTI_TENANT, 'false');
|
||||
assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev');
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ③ 版本標籤:重烙,不沿用
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
assert.equal(stamp.version, '1.4.41');
|
||||
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue
|
||||
assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver');
|
||||
});
|
||||
|
||||
test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => {
|
||||
const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/);
|
||||
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION);
|
||||
assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」');
|
||||
});
|
||||
|
||||
test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch);
|
||||
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/);
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => {
|
||||
const api: ResourceApi = {
|
||||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} };
|
||||
return {
|
||||
deployed: true,
|
||||
bindings: [
|
||||
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' },
|
||||
{ kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' },
|
||||
{ kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' },
|
||||
{ kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' },
|
||||
{ kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' },
|
||||
{ kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' },
|
||||
{ kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' },
|
||||
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' },
|
||||
],
|
||||
vars: LIVE_VARS,
|
||||
};
|
||||
},
|
||||
async listKvNamespaces() {
|
||||
return new Map([
|
||||
['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'],
|
||||
['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'],
|
||||
]);
|
||||
},
|
||||
async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); },
|
||||
async listVectorizeIndexes() { return []; },
|
||||
async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); },
|
||||
async createD1Database() { throw new Error('這趟不該新建 D1'); },
|
||||
async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); },
|
||||
};
|
||||
|
||||
const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||||
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||||
const parsed = parseWranglerRequirements(preview);
|
||||
const plan = await planResources(
|
||||
api,
|
||||
parsed.bindings.map((b) => ({ ...b, worker: parsed.script })),
|
||||
'update',
|
||||
);
|
||||
assert.deepEqual(plan.blockers, []);
|
||||
// 讀綁定時順手把 var 帶回來——不另外打一次 API
|
||||
assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch;
|
||||
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||
const extra = {
|
||||
...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML),
|
||||
ARCRUN_BUNDLE_VERSION: stamp.version,
|
||||
ARCRUN_BUNDLE_COMMIT: stamp.commit!,
|
||||
};
|
||||
|
||||
const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra));
|
||||
|
||||
// ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本
|
||||
assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41');
|
||||
assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
// ② 安裝器注入的設定沒有在更新中消失
|
||||
assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||
assert.equal(deployed.CONSOLE_TENANT, 'someone-else');
|
||||
// ③ CLI 自己算的仍然是這趟算出來的
|
||||
assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub');
|
||||
assert.equal(deployed.MULTI_TENANT, 'false');
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => {
|
||||
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/);
|
||||
assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/);
|
||||
assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/);
|
||||
const noVars = applyVars('name = "w"\n', { A: 'a' });
|
||||
assert.match(noVars, /\[vars\]\nA = "a"/);
|
||||
assert.match(noVars, /^name = "w"/);
|
||||
});
|
||||
|
||||
test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => {
|
||||
const out = applyVars('[vars]\n', { A: 'say "hi"\\path' });
|
||||
assert.match(out, /A = "say \\"hi\\"\\\\path"/);
|
||||
});
|
||||
|
||||
test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => {
|
||||
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/);
|
||||
assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/);
|
||||
// 怪名字不寫進去(不拿它組正規式)
|
||||
assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n');
|
||||
});
|
||||
|
||||
test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => {
|
||||
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||||
const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map()));
|
||||
const withVars = parseWranglerRequirements(
|
||||
renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }),
|
||||
);
|
||||
assert.equal(withVars.script, withoutVars.script);
|
||||
assert.deepEqual(withVars.bindings, withoutVars.bindings);
|
||||
});
|
||||
@@ -6,6 +6,11 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
// resource-rule.mjs 是共用規則的副本(純 JS + JSDoc,零依賴,見該檔開頭)。
|
||||
// allowJs 讓 tsc 把它一起編進 dist(否則 npm 套件裡會缺這支 → 執行期 MODULE_NOT_FOUND);
|
||||
// checkJs 讓它的 JSDoc 型別真的被檢查,而不是靜靜地當 any。
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
|
||||
@@ -277,9 +277,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
} else {
|
||||
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||
}
|
||||
rows.push(sys.graph && sys.graph.ok
|
||||
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||
// Arcrun#100:「服務活著嗎」與「庫裡有幾條」拆兩列。混一列時,圖服務打不通會把
|
||||
// 「其實有 1854 條」整個吞掉,畫面看起來就像知識庫是空的。數字讀不到寫「讀不到」,不寫 0。
|
||||
var gOk = !!(sys.graph && sys.graph.ok);
|
||||
var tri = sys.graph && sys.graph.triplets != null ? sys.graph.triplets : null;
|
||||
rows.push(sysRow('知識圖譜服務', gOk ? '● 正常' : '● 打不通', gOk ? 'ok' : 'bad'));
|
||||
rows.push(sysRow('三元組(關聯)', tri == null ? '讀不到' : tri.toLocaleString() + ' 條', tri == null ? 'off' : ''));
|
||||
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
||||
// 三元組/已嵌入 已各有一列)
|
||||
|
||||
@@ -934,14 +934,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
fetch(API_BASE + '/console/kb-scale-data')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
||||
// #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上)
|
||||
if (!d) { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; return; }
|
||||
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
|
||||
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
|
||||
var latest = d.wiki_card_latest_ago_minutes;
|
||||
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
|
||||
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
|
||||
})
|
||||
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
|
||||
.catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; });
|
||||
}
|
||||
$('se-sem').addEventListener('click', function () {
|
||||
S.semantic = !S.semantic;
|
||||
@@ -1504,7 +1505,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
]).then(function (rs) {
|
||||
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
|
||||
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
|
||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
||||
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
|
||||
var rows = '';
|
||||
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
|
||||
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
|
||||
|
||||
@@ -122,6 +122,12 @@
|
||||
.modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
|
||||
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
|
||||
.modebtn.hide { display: none; }
|
||||
/* 🔴 2026-08-10 實測撞到:`.hide` 一直**只有上面那三條有 scope 的規則**(#tabbar .tab / #sidenav .nav / .modebtn),
|
||||
所以任何其他元素掛上 class="hide" 都**完全沒有被藏起來**——看起來有藏、其實沒藏。
|
||||
D62 的「忘記密碼」欄位與「現有密碼」欄位就是這樣在瀏覽器上原形畢露的。
|
||||
補一條通用規則;帶 !important 是因為要蓋過 inline 的 display(#forgot-box 就有)。
|
||||
既有那三種用法的意圖與本規則一致(都是「藏起來」),加這條不改變它們的行為。 */
|
||||
.hide { display: none !important; }
|
||||
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
|
||||
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
|
||||
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
|
||||
@@ -219,12 +225,42 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
|
||||
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
|
||||
<div style="font-size:13px;color:rgba(var(--ink-rgb),.4);line-height:1.7">你自己就是管理員?<a href="/console/" style="color:var(--amber)">用管理主控台密碼救援自己</a></div>
|
||||
<!-- 🔴 D50/D62:這裡原本寫「忘記密碼請聯絡管理員重設」+一條「用管理主控台密碼救援自己」
|
||||
的 console 連結。**兩條都是已否決的做法**,不准寫回來:
|
||||
① leo:「我要是記得密碼,還來找忘記密碼幹嘛?」——console 登入頁一樣要密碼
|
||||
② console 與 portal 是安裝時**同一組帳密**寫進兩個地方(D50 補刀)
|
||||
⇒ console 不是第二道閘,是同一把鎖的第二個鑰匙孔,往它補洞不會多出任何一條路
|
||||
③ 唯一管理員忘記密碼時「聯絡管理員」=叫他聯絡他自己
|
||||
leo 2026-08-10:「**是對 portal 不是對 console**,這樣 youlin 雖然忘記,
|
||||
我還是可以去 portal 忘記密碼。」⇒ 出口就在這裡。 -->
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">
|
||||
忘記密碼了?<a href="#" id="login-forgot" style="color:var(--amber)">寄一條「修改密碼」連結給我</a>
|
||||
</div>
|
||||
<div id="forgot-box" class="hide" style="display:flex;flex-direction:column;gap:10px;margin-top:4px">
|
||||
<input type="email" id="forgot-email" class="txt" placeholder="你註冊時用的 Email" autocomplete="username">
|
||||
<button class="btn" id="forgot-submit">寄出連結</button>
|
||||
<div id="forgot-status" style="font-size:13.5px;min-height:1.2em;line-height:1.6"></div>
|
||||
</div>
|
||||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密碼(D62):忘記密碼點信裡的連結進來的落點。
|
||||
🔴 這個殼**刻意沒有自己的密碼欄位**——真正的表單是設定頁那一份 #pw-form,
|
||||
進到這個模式時被 JS 原封不動搬進 #reset-slot。D62:「同一個畫面、同一條寫入路徑,
|
||||
差別只有一格:現有密碼是要填、還是被連結豁免。」複製一份長得像的就會漂移。 -->
|
||||
<div class="authwrap view" id="v-reset">
|
||||
<div class="authbox">
|
||||
<div>
|
||||
<div style="font-size:22px;font-weight:600;margin-bottom:6px">設定新密碼</div>
|
||||
<div id="reset-who" style="font-size:14px;color:rgba(var(--ink-rgb),.55);line-height:1.7;margin-bottom:14px"></div>
|
||||
<div id="reset-slot"></div>
|
||||
<div id="reset-error" class="err" style="font-size:14px;line-height:1.7"></div>
|
||||
<div style="margin-top:16px;font-size:13.5px"><a href="#/" id="reset-back" style="color:var(--amber)">回登入頁</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首次設定(t49,leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
|
||||
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
|
||||
<div class="authwrap view" id="v-firstsetup">
|
||||
@@ -375,10 +411,14 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">更改密碼</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<!-- D62(leo 2026-08-10):「改密碼與忘記密碼**這兩個機制其實是一個機制,可以簡化**。」
|
||||
⇒ 這個表單是**整個站唯一一份**改密碼的表單。忘記密碼點連結進來時,
|
||||
它會被原封不動搬到 #v-reset 那個殼裡(不是複製一份長得像的)——
|
||||
差別只有「現有密碼」那一格顯不顯示。同一個畫面、同一條寫入路徑。 -->
|
||||
<div class="panel" id="st-pw-panel">
|
||||
<div style="font-size:17px;font-weight:600" id="pw-title">更改密碼</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)" id="pw-sub">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||
<div id="pw-form" style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
|
||||
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
|
||||
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
|
||||
@@ -746,6 +786,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
|
||||
function route() {
|
||||
// 🔴 D62:`#/reset?token=…` **不是站內的一個 view**,是「還沒登入的人拿著連結進來」。
|
||||
// 少了這一段,下面那行正規化會把不認得的 `reset` 換成 HOME ⇒ **連 token 一起丟掉**,
|
||||
// 使用者點信裡的連結會落在搜尋頁、而且再也回不去(連結一次有效,已經耗掉了)。
|
||||
// 實測(2026-08-10 stage,瀏覽器):網址被改寫成 `#/search`、修改密碼畫面根本沒出現。
|
||||
var rt = resetTokenFromUrl();
|
||||
if (rt) { showReset(rt); return; }
|
||||
var r = currentRoute();
|
||||
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
|
||||
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
|
||||
@@ -817,6 +863,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
} catch (e) { /* 登入殼還沒渲染就算了 */ }
|
||||
}
|
||||
function boot() {
|
||||
// D62:信裡的「修改密碼」連結最優先——這個人**就是進不去才收到這封信的**,
|
||||
// 不可以因為瀏覽器裡剛好還留著一個舊 token 就把他丟去別的畫面。
|
||||
var rt = (typeof resetTokenFromUrl === 'function') ? resetTokenFromUrl() : '';
|
||||
if (rt) { showReset(rt); return; }
|
||||
if (!S.token) { showAuth(); return; }
|
||||
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
@@ -1058,7 +1108,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
})();
|
||||
|
||||
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
|
||||
// ── t53 完成安裝清單(進站必見,做完才消失)───────────────────────────────
|
||||
// 件數演進:t53 三件 → t54 兩件(設定檔改由小幫手憑帳密自取)
|
||||
// → arcrun-rag#81 一件(AI 問答改走 Workers AI,不再要用戶自備金鑰)。
|
||||
function setupSteps() {
|
||||
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
|
||||
}
|
||||
@@ -1073,7 +1125,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var p = S.profile || {};
|
||||
if (p.role !== 'admin') return;
|
||||
var s = setupSteps();
|
||||
if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
|
||||
// arcrun-rag#81(leo 08-12:「已經改用 workers AI,刪掉」):只剩「下載小幫手」一件。
|
||||
// 舊的 s.key(貼 Google AI 金鑰)已整條移除,見下方 innerHTML 處的說明。
|
||||
// 相容:舊瀏覽器 localStorage 裡殘留的 s.key 不再被讀 —— 沒設過 key 的人也不會被卡住。
|
||||
if (s.daemon) return; // t54 起設定改由小幫手輸入帳密自取,不再下載檔案
|
||||
var cfg = window.ARCRUN_CONFIG || {};
|
||||
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
|
||||
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
|
||||
@@ -1084,7 +1139,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var el = document.createElement('div');
|
||||
el.id = 'setup-checklist';
|
||||
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
|
||||
el.innerHTML = '<b>還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
|
||||
// 🔴 arcrun-rag#81(leo 08-12):第二項「啟用 AI 問答(貼 Google AI 金鑰)」整條刪除。
|
||||
// 為什麼不是「一個沒用的欄位」而已:它長在**安裝完成清單裡而且是勾選項**
|
||||
// ⇒ 用戶會以為不做這步就沒裝完,而它要人離開流程、去第三方網站申請帳號、
|
||||
// 把金鑰貼進表單——整條安裝路徑上最重的一個動作,而且是白做的。
|
||||
// 真相:雲端問答走 t181(08-04)改好的 Workers AI(`env.AI` binding,免金鑰)——
|
||||
// /portal/data/chat → tenant 的 rag_chat workflow → `workers_ai_chat` recipe
|
||||
// (api-recipe-seeds.ts:150,endpoint `@cf/meta/llama-4-scout-17b-16e-instruct`)。
|
||||
// 這裡貼的金鑰是打 POST /portal/admin/ai 存一筆雲端 gemini_api_key credential,
|
||||
// **現行問答鏈路一個地方都沒有讀它**。
|
||||
// 後端 route 本身不動(同 08-09 拿掉檢修孔按鈕的處置:端點無害、已無任何 UI 呼叫,
|
||||
// 純粹清路標);已經存過金鑰的人那筆 credential 也原封不動,不做刪除。
|
||||
// ⚠️ 別把這個跟設定頁「AI 設定」面板講的地端萃取金鑰搞混——那把是填在
|
||||
// **同步小幫手**托盤裡的,從來不經過這個清單。
|
||||
el.innerHTML = '<b>還差 1 步,安裝就真的完成了</b>'
|
||||
+ row(s.daemon, 'daemon',
|
||||
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
|
||||
+ (daemonUrl
|
||||
@@ -1106,34 +1174,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
|
||||
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
|
||||
+ '<div class="muted" style="font-size:12.5px;margin-top:4px">裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。</div>')
|
||||
+ row(s.key, 'key',
|
||||
'<b>啟用 AI 問答</b>(<a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
|
||||
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
|
||||
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
|
||||
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
|
||||
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
|
||||
document.body.appendChild(el);
|
||||
var dl = document.getElementById('sc-daemon');
|
||||
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
|
||||
// t54:config.json 下載鈕已移除(設定由小幫手憑帳密自取)
|
||||
var kb = document.getElementById('sc-key-save');
|
||||
if (kb) kb.addEventListener('click', function () {
|
||||
var k = (document.getElementById('sc-key').value || '').trim();
|
||||
var m = document.getElementById('sc-key-msg');
|
||||
if (!k) { m.textContent = '請先貼上金鑰'; return; }
|
||||
kb.disabled = true; m.textContent = '啟用中…';
|
||||
fetch(API_BASE + '/portal/admin/ai', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify({ gemini_api_key: k })
|
||||
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
kb.disabled = false;
|
||||
if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; }
|
||||
markStep('key');
|
||||
})
|
||||
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
|
||||
});
|
||||
// arcrun-rag#81:金鑰輸入框的送出邏輯(POST /portal/admin/ai)一併移除——
|
||||
// 只藏畫面留著那條路,等於這個要求還在,只是變得更難發現。
|
||||
var later = document.getElementById('sc-later');
|
||||
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
|
||||
}
|
||||
@@ -1436,7 +1483,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
$('se-q').value = name;
|
||||
doGraphSearch(name);
|
||||
}
|
||||
// 讀不到就明說「讀不到」——標題列**絕不**留著 0 或舊數字(Arcrun#100:leo 看到
|
||||
// 「0 個實體・0 條關聯」以為要去上傳文件,其實庫裡有 1854 條,只是這支讀失敗了)。
|
||||
function mapUnavailable(html) {
|
||||
$('map-meta').textContent = '讀不到';
|
||||
$('map-box').innerHTML = '<div class="err" style="padding:30px 10px">' + html + '</div>';
|
||||
}
|
||||
function loadMap() {
|
||||
$('map-meta').textContent = '';
|
||||
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
|
||||
$('map-md-link').innerHTML = SOURCE_WEB_BASE
|
||||
? ':<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
|
||||
@@ -1445,14 +1499,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '</div>'; return; }
|
||||
var nodes = x.d.nodes || [];
|
||||
var edges = x.d.edges || [];
|
||||
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
|
||||
if (!x.ok) { mapUnavailable(esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')'))); return; }
|
||||
// Arcrun#100:「0」只准在後端確認過真的是 0 的時候出現。
|
||||
// 形狀不對 → 讀不到(不是空庫);nodes 為空但 empty_confirmed 不成立 → 讀不到。
|
||||
if (!Array.isArray(x.d.nodes) || !Array.isArray(x.d.edges)) {
|
||||
mapUnavailable('總圖回應格式不對——沒有拿到關聯資料。這不代表知識庫是空的。');
|
||||
return;
|
||||
}
|
||||
var nodes = x.d.nodes, edges = x.d.edges;
|
||||
var total = typeof x.d.triplets_total === 'number' ? x.d.triplets_total : null;
|
||||
if (!nodes.length && x.d.empty_confirmed !== true) {
|
||||
mapUnavailable(x.d.empty_reason === 'scope_mismatch'
|
||||
? '讀不到你這個帳號的關聯資料——知識庫裡有三元組'
|
||||
+ (total ? '(本帳號範圍算到 ' + total.toLocaleString() + ' 條)' : '')
|
||||
+ ',但這張圖一條都抽不出來。<br>'
|
||||
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>;比較像資料的歸屬範圍對不上,請通知管理員。'
|
||||
: '讀不到知識庫的關聯資料,無法確認庫裡有沒有關聯。<br>'
|
||||
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>——是這次讀取失敗,請稍後重整或通知管理員。');
|
||||
return;
|
||||
}
|
||||
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯'
|
||||
+ (total !== null && x.d.truncated ? '(全庫共 ' + total.toLocaleString() + ' 條,已達單次上限)' : x.d.truncated ? '・已達上限截斷' : '');
|
||||
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
|
||||
renderMap(nodes, edges);
|
||||
})
|
||||
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
|
||||
.catch(function (e) { mapUnavailable('請求失敗:' + esc(friendlyErr(e))); });
|
||||
}
|
||||
function renderMap(nodes, edges) {
|
||||
var N = nodes.length;
|
||||
@@ -1968,6 +2039,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
// 兩邊都是 semver(例 1.4.2),用數字逐段比,不用字串比('1.4.10' < '1.4.9' 會出錯)。
|
||||
var INSTALLER_ORIGIN = 'https://install.arcrun.dev';
|
||||
|
||||
// Arcrun#106:版號後面可以帶 build metadata(`1.4.41+d61`、`1.4.41+a1b2c3d`)——
|
||||
// 那是 semver 規格裡「比大小時要忽略」的那一段。舊寫法拿整串去比對正規式,
|
||||
// 一律落到「較舊版本」(youlin 實例就是這樣,明明有版號卻顯示不出來)。
|
||||
// 這裡只取前面的 `x.y.z` 當比較用的核心,顯示仍顯示完整原字串。
|
||||
function semverCore(v) {
|
||||
var m = String(v || '').match(/^(\d+\.\d+\.\d+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function cmpSemver(a, b) {
|
||||
var x = String(a || '').split('.').map(Number);
|
||||
var y = String(b || '').split('.').map(Number);
|
||||
@@ -1984,9 +2064,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
var btn = $('st-ver-update');
|
||||
if (!line) return;
|
||||
|
||||
// #106:順便把 bundle_commit 帶回來(有注入才有)——版號是頻道編號,commit 才是「真的部了哪份碼」。
|
||||
var mineCommit = '';
|
||||
var mineP = fetch(window.ARCRUN_API_BASE + '/health', { cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { return (j && j.bundle_version) || ''; })
|
||||
.then(function (j) {
|
||||
mineCommit = (j && j.bundle_commit) || '';
|
||||
return (j && j.bundle_version) || '';
|
||||
})
|
||||
.catch(function () { return ''; });
|
||||
var latestP = fetch(INSTALLER_ORIGIN + '/api/latest')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
@@ -1999,15 +2084,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
if (!mine) { line.textContent = '無法讀取目前版本(知識庫服務可能正在啟動)'; return; }
|
||||
// 舊實例的 bundle_version 是舊格式(2026-07-31+8e83589),比不了 semver。
|
||||
// 這種情況一律當成「落後」——因為新版才會寫 semver 進來。
|
||||
var mineIsSemver = /^\d+\.\d+\.\d+$/.test(mine);
|
||||
// #106:`1.4.41+<commit>` 這種帶 build metadata 的**是** semver,取核心比即可。
|
||||
var mineCore = semverCore(mine);
|
||||
var mineIsSemver = !!mineCore;
|
||||
// commit 是輔助資訊(有才顯示):版號說「哪一版」,commit 說「真的是哪份碼」。
|
||||
var commitNote = mineCommit ? ' <span class="muted">commit ' + esc(String(mineCommit).slice(0, 7)) + '</span>' : '';
|
||||
|
||||
if (!latest) {
|
||||
line.textContent = '目前版本 ' + mine + '(暫時查不到最新版,稍後再試)';
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong>(暫時查不到最新版,稍後再試)' + commitNote;
|
||||
return;
|
||||
}
|
||||
var behind = !mineIsSemver || cmpSemver(mine, latest) < 0;
|
||||
var behind = !mineIsSemver || cmpSemver(mineCore, latest) < 0;
|
||||
if (!behind) {
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版';
|
||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版' + commitNote;
|
||||
dot.style.display = 'none';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
@@ -2041,33 +2130,147 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
|
||||
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
|
||||
}
|
||||
$('st-pw-save').addEventListener('click', function () {
|
||||
// ══════════ D62:改密碼與忘記密碼=同一個機制 ══════════
|
||||
//
|
||||
// leo 2026-08-10:「這兩個機制其實是一個機制,可以簡化。」
|
||||
// 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋
|
||||
// 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
|
||||
// ⇒ 底下只有**一個表單元素**(#pw-form)與**一支送出函式**,差別只有「現有密碼」那一格
|
||||
// 顯不顯示、以及帶 current 還是帶 reset_token。兩份長得像的東西必然漂移
|
||||
// (arcrun-rag#40「同一個事實兩份」)。
|
||||
// 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。
|
||||
|
||||
var S_RESET_TOKEN = ''; // 非空=現在是「忘記密碼」模式
|
||||
|
||||
/** 把那一份唯一的表單搬到指定容器,並決定「現有密碼」那一格出不出現。 */
|
||||
function mountPasswordForm(host, needCurrent) {
|
||||
var form = $('pw-form');
|
||||
if (form && form.parentNode !== host) host.appendChild(form);
|
||||
$('st-pw-old').classList.toggle('hide', !needCurrent);
|
||||
$('st-pw-save').textContent = needCurrent ? '更新密碼' : '設定新密碼';
|
||||
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||||
$('st-pw-status').textContent = '';
|
||||
}
|
||||
|
||||
/** 送出:兩種模式共用。帶 reset_token 就走連結那一格,否則走現有密碼那一格。 */
|
||||
function submitPasswordChange() {
|
||||
var needCurrent = !S_RESET_TOKEN;
|
||||
var oldPw = $('st-pw-old').value;
|
||||
var newPw = $('st-pw-new').value;
|
||||
var newPw2 = $('st-pw-new2').value;
|
||||
var st = $('st-pw-status');
|
||||
st.className = 'err';
|
||||
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
|
||||
if (needCurrent && !oldPw) { st.textContent = '請填現在的密碼'; return; }
|
||||
if (!newPw) { st.textContent = '請填新密碼'; return; }
|
||||
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
|
||||
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
|
||||
var body = { 'new': newPw };
|
||||
if (S_RESET_TOKEN) body.reset_token = S_RESET_TOKEN; else body.current = oldPw;
|
||||
$('st-pw-save').disabled = true;
|
||||
st.textContent = '';
|
||||
fetch(API_BASE + '/portal/me/password', {
|
||||
st.textContent = '處理中…';
|
||||
fetch(API_BASE + '/portal/password/change', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify({ current: oldPw, 'new': newPw })
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, needCurrent ? authHeaders() : {}),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$('st-pw-save').disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
|
||||
// 忘記密碼模式沒有 session,401 不代表「你被登出」——別誤踢
|
||||
if (needCurrent && guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = (x.d && x.d.error) || '更新失敗'; return; }
|
||||
st.className = 'ok';
|
||||
st.textContent = '密碼已更新';
|
||||
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||||
toast('密碼已更新');
|
||||
if (S_RESET_TOKEN) {
|
||||
st.textContent = '密碼已設定好了,正在帶你回登入頁…';
|
||||
var who = (x.d && x.d.email) || '';
|
||||
S_RESET_TOKEN = '';
|
||||
setTimeout(function () {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
location.hash = '';
|
||||
$('v-reset').classList.remove('on');
|
||||
mountPasswordForm($('st-pw-panel'), true);
|
||||
showAuth();
|
||||
if (who) $('login-email').value = who;
|
||||
$('login-status').className = 'ok';
|
||||
$('login-status').textContent = '新密碼已生效,請用它登入。';
|
||||
}, 1200);
|
||||
} else {
|
||||
st.textContent = '密碼已更新(你不會被登出,可以繼續操作)';
|
||||
toast('密碼已更新');
|
||||
}
|
||||
})
|
||||
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
|
||||
}
|
||||
$('st-pw-save').addEventListener('click', submitPasswordChange);
|
||||
|
||||
// ── 登入頁的「忘記密碼」入口(**在 portal,不是 console**,leo 2026-08-10)──
|
||||
$('login-forgot').addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
$('forgot-box').classList.remove('hide');
|
||||
$('forgot-email').value = $('login-email').value.trim();
|
||||
$('forgot-email').focus();
|
||||
});
|
||||
$('forgot-submit').addEventListener('click', function () {
|
||||
var email = $('forgot-email').value.trim();
|
||||
var st = $('forgot-status');
|
||||
st.className = 'err';
|
||||
if (!email) { st.textContent = '請填 Email'; return; }
|
||||
$('forgot-submit').disabled = true;
|
||||
st.textContent = '寄送中…';
|
||||
fetch(API_BASE + '/portal/password/forgot', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$('forgot-submit').disabled = false;
|
||||
if (!x.ok) { st.textContent = (x.d && x.d.error) || '寄送失敗'; return; }
|
||||
st.className = 'ok';
|
||||
st.textContent = (x.d && x.d.message) || '如果這個 email 有帳號,連結已經寄出了。';
|
||||
})
|
||||
.catch(function (e) { $('forgot-submit').disabled = false; st.textContent = friendlyErr(e); });
|
||||
});
|
||||
|
||||
/** 點信裡的連結進來:`…/portal/#/reset?token=…`(cypher 的 /portal/password/reset-link 導過來的)。 */
|
||||
function resetTokenFromUrl() {
|
||||
var m = String(location.hash || '').match(/[?&]token=([^&]+)/);
|
||||
if (m) return decodeURIComponent(m[1]);
|
||||
m = String(location.search || '').match(/[?&]token=([^&]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
function showReset(token) {
|
||||
$('v-login').classList.remove('on');
|
||||
$('v-firstsetup').classList.remove('on');
|
||||
$('shell').classList.remove('on');
|
||||
$('tabbar').classList.remove('on');
|
||||
$('v-reset').classList.add('on');
|
||||
$('reset-error').textContent = '';
|
||||
// 先問一次「這張票還有效嗎」——連結是一次有效、30 分鐘過期的,
|
||||
// 過期就要當場說清楚,不要讓人填完密碼才被打回票(#49:別把系統狀態講成用戶的錯)。
|
||||
fetch(API_BASE + '/portal/password/reset?token=' + encodeURIComponent(token))
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok || !x.d || x.d.valid !== true) {
|
||||
$('reset-who').textContent = '';
|
||||
$('reset-error').textContent = (x.d && x.d.error) || '這條連結已經失效了。請回登入頁重新按一次「忘記密碼」。';
|
||||
return;
|
||||
}
|
||||
S_RESET_TOKEN = token;
|
||||
$('reset-who').innerHTML = '你正在為 <strong>' + esc(x.d.email) + '</strong> 設定新密碼。<br>這條連結只能用一次,<strong>不需要輸入現在的密碼</strong>。';
|
||||
mountPasswordForm($('reset-slot'), false); // ← 差別只有這一格
|
||||
})
|
||||
.catch(function (e) { $('reset-error').textContent = friendlyErr(e); });
|
||||
}
|
||||
$('reset-back').addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
S_RESET_TOKEN = '';
|
||||
history.replaceState(null, '', location.pathname);
|
||||
location.hash = '';
|
||||
$('v-reset').classList.remove('on');
|
||||
mountPasswordForm($('st-pw-panel'), true);
|
||||
showAuth();
|
||||
});
|
||||
|
||||
boot();
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run"
|
||||
"check:tenant": "node scripts/check-tenant-source.mjs",
|
||||
"test": "node scripts/check-tenant-source.mjs && vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "^1.2.4",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 「靜態租戶字串不得用於資料面過濾」機械閘的**執行殼**(Arcrun#108)。
|
||||
*
|
||||
* 規則本體(純函式、零 node 相依)在 `tenant-source-rules.mjs`——拆開的理由是
|
||||
* **這道閘自己要能被測試**:Workers runtime 的 vitest 沒有 node:fs,規則若和走檔案系統的
|
||||
* 程式碼綁在一起就 import 不動,測試也就寫不出來(當晚有一道閘連讀自己的原始碼都擋,
|
||||
* 結果沒人驗得了它會不會誤攔)。現在 tests/tenant-gate.test.ts 直接餵字串驗規則。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/check-tenant-source.mjs [projectRoot] # 掃 src/,有違規 → exit 1
|
||||
* node scripts/check-tenant-source.mjs --stdin <相對路徑> # 從 stdin 讀「即將寫入的內容」
|
||||
* npm run check:tenant
|
||||
*
|
||||
* `--stdin` 是給 `.claude/hooks/pre-write-guard.sh`(規則 8.1)用的:在檔案**還沒寫下去之前**
|
||||
* 就擋,這樣違規根本進不了工作區。Edit 只給片段也沒關係——規則是逐行的,正好只看新寫的那幾行。
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scanSource } from './tenant-source-rules.mjs';
|
||||
|
||||
/** 遞迴列出目錄下的 .ts 檔(相對 root 的路徑)。 */
|
||||
function listTsFiles(root, dir = root, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) listTsFiles(root, full, out);
|
||||
else if (name.endsWith('.ts')) out.push(relative(root, full));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 掃整個 cypher-executor/src。回傳違規清單。 */
|
||||
export function scanProject(projectRoot) {
|
||||
const srcRoot = join(projectRoot, 'src');
|
||||
const all = [];
|
||||
for (const rel of listTsFiles(projectRoot, srcRoot)) {
|
||||
const relPosix = rel.split(sep).join('/');
|
||||
all.push(
|
||||
...scanSource(relPosix, readFileSync(join(projectRoot, rel), 'utf8')).map((v) => ({
|
||||
...v,
|
||||
file: relPosix,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** stdin 模式:讀「即將寫入的內容」,印違規、有違規 → exit 1。 */
|
||||
async function runStdin(relPath) {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const violations = scanSource(relPath, Buffer.concat(chunks).toString('utf8'));
|
||||
if (violations.length === 0) return 0;
|
||||
for (const v of violations) {
|
||||
console.error(`[${v.rule}] ${relPath}(新寫入的第 ${v.line} 行):${v.text}`);
|
||||
console.error(` → ${v.message}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
if (process.argv[2] === '--stdin') {
|
||||
process.exit(await runStdin(process.argv[3] ?? 'src/unknown.ts'));
|
||||
}
|
||||
const projectRoot = process.argv[2] ?? process.cwd();
|
||||
const violations = scanProject(projectRoot);
|
||||
if (violations.length === 0) {
|
||||
console.log('✓ 租戶來源檢查通過:資料面 owner_id 全部來自 src/lib/tenant.ts');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('❌ 租戶來源檢查失敗(Arcrun#108 的閘)\n');
|
||||
for (const v of violations) {
|
||||
console.error(` [${v.rule}] ${v.file}:${v.line}`);
|
||||
console.error(` ${v.text}`);
|
||||
console.error(` → ${v.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 「靜態租戶字串不得用於資料面過濾」— 機械閘(Arcrun#108)。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 為什麼要有這道閘
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 同一句話已經寫錯兩次:
|
||||
* #105 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"`
|
||||
* #108 `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
|
||||
* 兩次都是「拿一個部署環境變數的字面預設值,當成使用者資料的歸屬」。規則早就在(rule 07
|
||||
* 薄殼、design §3.3 租戶不下發),但**沒有任何機制會擋**,所以它每隔幾週就長回來一次。
|
||||
* leo 2026-08-12:「做一個平台要減少 hotfix。」⇒ 修掉 bug 不算完成,要留下會擋的東西。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 判準:看「有沒有在做那件事」,不是看「有沒有出現那個詞」
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 誤攔比漏攔更容易殺死一道閘(被擋煩了就有人把它關掉),所以三條規則全部盯**行為**:
|
||||
*
|
||||
* T1 租戶環境變數只有一個產地
|
||||
* `env.CONSOLE_TENANT` / `env.ARCRUN_NAMESPACE` 只能在 src/lib/tenant.ts 被讀取。
|
||||
* 盯的是「你在把部署設定讀成身分」這個動作本身。註解裡寫這兩個字不算(只看 `env.X` 取值)。
|
||||
*
|
||||
* T2 資料面租戶識別不得憑空捏造
|
||||
* `as TenantId` 只能出現在 src/lib/tenant.ts,且不得套在字面字串上。
|
||||
* 盯的是「繞過唯一產地自己造一個租戶」。
|
||||
*
|
||||
* T3 帳號層字串不得流進知識資料面
|
||||
* 同一行同時「在組 owner_id」且「值來自 portalTenant()/accountTenant()」→ 擋。
|
||||
* 這正是 #108 那一行的形狀:`owner_id=${encodeURIComponent(portalTenant(c.env))}`。
|
||||
* `owner_id: ns`(帳號子 namespace,合法)不命中;`x.owner_id` 這種讀取也不命中。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 這道閘自己要能被測試
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 核心是純函式 `scanSource(relPath, text)`(不碰檔案系統),測試餵好例子/壞例子驗它會不會叫
|
||||
* (tests/tenant-gate.test.ts)——當晚有一道閘連讀自己的原始碼都擋,導致沒人驗得了它。
|
||||
* 本檔只掃 `src/`,測試與 fixture 都不在掃描範圍內,所以**不會擋到自己**。
|
||||
*
|
||||
* 本檔是**純規則**(零 node 相依),所以 Workers runtime 的 vitest 也 import 得動;
|
||||
* 走檔案系統的那半在 check-tenant-source.mjs。
|
||||
*/
|
||||
|
||||
/** 唯一允許產出租戶識別的檔案(相對 cypher-executor/)。 */
|
||||
export const TENANT_SOURCE_FILE = 'src/lib/tenant.ts';
|
||||
/** 只宣告型別、不取值的檔案(`CONSOLE_TENANT?: string` 這種)。 */
|
||||
const TYPE_DECL_FILES = new Set(['src/types.ts']);
|
||||
|
||||
/** 被視為「租戶來源」的環境變數——讀它們=在決定使用者資料的歸屬。 */
|
||||
const TENANT_ENV_VARS = ['CONSOLE_TENANT', 'ARCRUN_NAMESPACE'];
|
||||
|
||||
/** 帳號層租戶字串的取得方式(回的是 string 不是 TenantId,不得用於知識資料面)。 */
|
||||
const ACCOUNT_TENANT_CALLS = ['portalTenant(', 'accountTenant('];
|
||||
|
||||
const ENV_READ = new RegExp(String.raw`\benv\s*\.\s*(${TENANT_ENV_VARS.join('|')})\b`);
|
||||
const AS_TENANT_ID = /\bas\s+TenantId\b/;
|
||||
const LITERAL_AS_TENANT_ID = /(['"`][^'"`]*['"`])\s*as\s+TenantId\b/;
|
||||
|
||||
/**
|
||||
* 「這一行在組 owner_id 嗎?」——**構造**才算,**讀取**不算。
|
||||
* 算:`owner_id=` 出現在字串/樣板裡、`owner_id:` 當成物件屬性在賦值
|
||||
* 不算:`x.owner_id`(讀)、`owner_id?:`(型別宣告)、`owner_id` 單獨出現在註解句子裡
|
||||
*/
|
||||
function buildsOwnerFilter(line) {
|
||||
const code = stripComment(line);
|
||||
if (!code.includes('owner_id')) return false;
|
||||
if (/owner_id\s*=/.test(code) && !/[.\w]owner_id\s*=/.test(code)) return true; // `?owner_id=` / `owner_id=${...}`
|
||||
if (/(^|[^.\w])owner_id\s*:/.test(code) && !/owner_id\s*\?\s*:/.test(code)) return true; // `owner_id: X`
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 去掉行末 `//` 註解(不處理跨行 /* *\/——那種行本來就不含可執行的取值)。 */
|
||||
function stripComment(line) {
|
||||
const i = line.indexOf('//');
|
||||
return i === -1 ? line : line.slice(0, i);
|
||||
}
|
||||
|
||||
/** 整行是註解?(`//` 開頭或位於 JSDoc 區塊的 ` *` 行) */
|
||||
function isCommentLine(line) {
|
||||
const t = line.trim();
|
||||
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
|
||||
}
|
||||
|
||||
/**
|
||||
* 掃一份原始碼,回傳違規清單(純函式,測試直接餵字串)。
|
||||
* @param {string} relPath 相對 cypher-executor/ 的路徑,例如 'src/routes/portal-data.ts'
|
||||
* @param {string} text 檔案內容
|
||||
* @returns {{rule: string, line: number, text: string, message: string}[]}
|
||||
*/
|
||||
export function scanSource(relPath, text) {
|
||||
const rel = relPath.split('\\').join('/');
|
||||
const violations = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const n = idx + 1;
|
||||
const push = (rule, message) =>
|
||||
violations.push({ rule, line: n, text: line.trim(), message });
|
||||
|
||||
if (isCommentLine(line)) return;
|
||||
const code = stripComment(line);
|
||||
|
||||
// T1:租戶環境變數只有一個產地
|
||||
if (rel !== TENANT_SOURCE_FILE && !TYPE_DECL_FILES.has(rel) && ENV_READ.test(code)) {
|
||||
push(
|
||||
'T1',
|
||||
`租戶環境變數只能在 ${TENANT_SOURCE_FILE} 讀取。` +
|
||||
'在別處讀它=又一次「身分來自環境變數」(#105/#108 同形),' +
|
||||
'請改呼叫 knowledgeOwner(env)(知識資料面)或 accountTenant(env)(帳號層)。',
|
||||
);
|
||||
}
|
||||
|
||||
// T2:資料面租戶識別不得憑空捏造
|
||||
if (AS_TENANT_ID.test(code)) {
|
||||
if (rel !== TENANT_SOURCE_FILE) {
|
||||
push(
|
||||
'T2',
|
||||
`TenantId 只能由 ${TENANT_SOURCE_FILE} 產生。自己 cast 一個等於繞過唯一產地——` +
|
||||
'請用 knowledgeOwner(env) 或 tenantFromApiKey(header)。',
|
||||
);
|
||||
} else if (LITERAL_AS_TENANT_ID.test(code)) {
|
||||
push(
|
||||
'T2',
|
||||
'不得把**字面字串**當成租戶識別(那就是 `|| "leo"` 那個預設值的原形)。' +
|
||||
'解析不到請丟 TenantUnresolvedError,誠實說讀不到。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// T3:帳號層字串不得流進知識資料面
|
||||
if (buildsOwnerFilter(line) && ACCOUNT_TENANT_CALLS.some((fn) => code.includes(fn))) {
|
||||
push(
|
||||
'T3',
|
||||
'這一行拿**帳號層**租戶字串去組知識資料面的 owner_id 過濾——' +
|
||||
'正是 #108 那一行(1854 條三元組被過濾成 0)。' +
|
||||
'知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()。',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||||
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
|
||||
import { resolveRecipe } from '../routes/recipes';
|
||||
import type { RecipeDefinition } from '../routes/recipes';
|
||||
import { branchHintFor } from '../lib/branch-hints';
|
||||
@@ -44,8 +44,12 @@ export type NodeInfo = {
|
||||
status: NodeStatus;
|
||||
componentId?: string;
|
||||
type: NodeRole;
|
||||
/** found 時標來源庫:零件 registry(component)或 recipe 庫(recipe)。 */
|
||||
source?: 'component' | 'recipe';
|
||||
/**
|
||||
* found 時標來源庫:零件 registry(component)、recipe 庫(recipe),
|
||||
* 或 cypher-executor 自帶、無須查 registry 即保證解析得動的執行期原生零件(builtin,
|
||||
* Arcrun#88——component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS)。
|
||||
*/
|
||||
source?: 'component' | 'recipe' | 'builtin';
|
||||
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
|
||||
input_schema?: unknown;
|
||||
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
|
||||
@@ -212,6 +216,25 @@ export async function searchNodes(
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ──────────────────
|
||||
// component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS=trigger_workflow/
|
||||
// BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS 的聯集——
|
||||
// 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。
|
||||
// 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control/
|
||||
// http_request/switch…)會被下面「兩庫都查過沒有」誤判成 not_found——
|
||||
// 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。
|
||||
// target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。
|
||||
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
|
||||
nodeResults[nodeName] = {
|
||||
status: 'found',
|
||||
componentId,
|
||||
type: role,
|
||||
source: 'builtin',
|
||||
branch_hint: branchHintFor(componentId),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
|
||||
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
||||
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
|
||||
|
||||
@@ -77,7 +77,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
||||
filter: 'SVC_FILTER',
|
||||
merge: 'SVC_MERGE',
|
||||
try_catch: 'SVC_TRY_CATCH',
|
||||
wait: 'SVC_WAIT',
|
||||
// wait 已於 Arcrun#101(2026-08-12)移進 BUILTIN_COMPONENTS(step 1)——
|
||||
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
|
||||
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
|
||||
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
|
||||
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.1:13 個既有 binding 保留不新增),
|
||||
// 拆綁定要重新部署、與本票無關。
|
||||
set: 'SVC_SET',
|
||||
array_ops: 'SVC_ARRAY_OPS',
|
||||
string_ops: 'SVC_STRING_OPS',
|
||||
@@ -88,6 +93,33 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
||||
};
|
||||
|
||||
/**
|
||||
* 「查得到 vs 真的有」的單一真相源(Arcrun#88,2026-08-11)。
|
||||
*
|
||||
* 病因:`/cypher/search`(`search-nodes.ts`)只查 component registry(`SUBMISSIONS_KV`,
|
||||
* 經 `submitComponent`/`index-only` 才會有記錄);而本檔 0/1/5/7 四步驟能直接解析、
|
||||
* **完全不查 registry** 的一整類零件(trigger_workflow、BUILTIN_COMPONENTS、
|
||||
* LOGIC_BINDING_MAP、WASM_HTTP_RUNNER_IDS)從未被 submit 過(也不需要——它們是
|
||||
* cypher-executor 自帶的,不是投稿存量)。實測 leo21c 實例:`/components/catalog`
|
||||
* 404(registry 是舊版沒這端點/索引空),search 因此對 `if_control`/`http_request`
|
||||
* 誠實地回「兩庫都查過沒有」——但這兩顆其實跑得動(leo 08-11 探測工作流已證)。
|
||||
*
|
||||
* 修法:把「執行期真的解析得動」的這份清單匯出給 search-nodes.ts,在查 registry
|
||||
* **之前**先比對——讓「查得到」不受 registry 是否可達/是否已 backfill 影響。
|
||||
*
|
||||
* 刻意不做的事:不去掃 `registry/components/*` 目錄當清單來源——那是零件原始碼
|
||||
* 存放處,含已標記待刪的死碼(`km_writer`/`kbdb_upsert_block`,見
|
||||
* `system-dev/docs/3-specs/arcrun-usable/cleanup-dead-code.md`);07-30 曾把這類死碼
|
||||
* 誤灌進 registry(leo 點名的錯)。這裡改用**執行期真正拿去 resolve 的白名單本身**
|
||||
* (本檔 1/5/7 步驟既有的三份清單)——精確等於「解析得動」,不會多一顆、不會少一顆。
|
||||
*/
|
||||
export const RUNTIME_NATIVE_COMPONENT_IDS: ReadonlySet<string> = new Set([
|
||||
'trigger_workflow',
|
||||
...BUILTIN_COMPONENTS.keys(),
|
||||
...Object.keys(LOGIC_BINDING_MAP),
|
||||
...WASM_HTTP_RUNNER_IDS,
|
||||
]);
|
||||
|
||||
export function createComponentLoader(env: Bindings) {
|
||||
return async (componentId: string): Promise<ComponentRunner> => {
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* wait 零件的等待上限(毫秒)。與 registry/components/wait/component.contract.yaml
|
||||
* 逐字相同 —— 超過此值截斷、不報錯。**不可為了閃避資源上限調小**(Arcrun#101 紅線):
|
||||
* 「等外部系統跟上」是這顆零件存在的理由,把上限砍掉等於把能力換掉。
|
||||
*/
|
||||
export const WAIT_MAX_MS = 30000;
|
||||
|
||||
/**
|
||||
* 內建零件表(靜態函數)
|
||||
* WASM 零件 = 各自獨立 Worker,cypher-executor 走 HTTP URL 呼叫(不從 R2 讀)
|
||||
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
|
||||
// ── wait:等待 N 毫秒後繼續(Arcrun#101,2026-08-12)────────────────────────
|
||||
//
|
||||
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
|
||||
//
|
||||
// 舊實作是 registry/components/wait/main.go(TinyGo → WASM),用 time.Sleep。
|
||||
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
|
||||
// poll_oneoff 實作成 ENOSYS(`.component-builds/*/src/index.ts`:`poll_oneoff: () => 76`)
|
||||
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
|
||||
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
|
||||
//
|
||||
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
|
||||
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
|
||||
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
|
||||
// ⇒ 時鐘並沒有全程凍結。
|
||||
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
|
||||
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
|
||||
//
|
||||
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
|
||||
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
|
||||
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
|
||||
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
|
||||
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
|
||||
//
|
||||
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
|
||||
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
|
||||
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
|
||||
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
|
||||
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
|
||||
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
|
||||
//
|
||||
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
|
||||
// 吃 ms(必填 > 0)+可選 context;ms > WAIT_MAX_MS 截斷;
|
||||
// 回 { success: true, data: { ...context, waited_ms } };ms <= 0 回 success:false。
|
||||
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
|
||||
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
|
||||
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
|
||||
['wait', async (ctx) => {
|
||||
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
|
||||
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
|
||||
if (!Number.isFinite(requested) || requested <= 0) {
|
||||
return { success: false, error: 'ms 必須大於 0' };
|
||||
}
|
||||
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
|
||||
|
||||
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
|
||||
? c.context as Record<string, unknown>
|
||||
: {};
|
||||
return { success: true, data: { ...passthrough, waited_ms: ms } };
|
||||
}],
|
||||
]);
|
||||
|
||||
export const SCORE_THRESHOLD = 0.5;
|
||||
|
||||
@@ -98,6 +98,20 @@ export function randomHex(bytes: number): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV key(D62)。
|
||||
*
|
||||
* 為什麼不直接拿 token 當 key:連結裡的 token 是**能改密碼的憑據**,直接當 key 等於
|
||||
* 把它明碼存在 KV 裡;改存雜湊後,看得到 KV 的人也拿不到可用的連結。
|
||||
* (這裡只做 digest——不是 rule 2.2 禁的 `crypto.subtle.decrypt` / RSASSA 簽章。)
|
||||
*/
|
||||
export async function sha256Hex(input: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
|
||||
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 租戶字串的**唯一產地**(Arcrun#108)。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 這個檔案存在的理由(不是為了整潔,是為了不再犯同一個錯)
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* #105:`ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` ——身分來自環境變數。
|
||||
* #108:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` ——同一句話換一個檔案。
|
||||
*
|
||||
* 兩次的形狀一模一樣:**「這筆資料是誰的」與「這個請求是誰」來自兩個可以各自漂移的地方**。
|
||||
* leo 的知識在 `owner_id=bfezv28v`(08-11 回灌時定的名,也就是他 `~/.arcrun/config.yaml`
|
||||
* 的 `api_key`、小幫手上傳時帶的 `X-Arcrun-API-Key`),而 cypher 拿 repo 預設值 `"leo"`
|
||||
* 去過濾 ⇒ 1854 條三元組被過濾成 0,畫面卻只寫「沒有庫」。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 定案:租戶字串從哪裡來
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* **從「寫入這批知識的那一方」來,而不是從一份手抄的環境變數預設值來。**
|
||||
*
|
||||
* 寫入端只有一個真相源:使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace)。
|
||||
* CLI 用它 push workflow(`{ns}:wf:*`)、小幫手用它上傳知識(`owner_id=ns`)、
|
||||
* MCP 用它當 Bearer。**讀取端必須用同一個值**,否則讀寫兩端各說各話。
|
||||
* 所以 `acr init/update` 把它注入成 `ARCRUN_NAMESPACE`(cli/src/lib/deploy.ts,
|
||||
* 與 CF_ACCOUNT_ID / WORKER_SUBDOMAIN / MULTI_TENANT 同一批 CLI 管理值)——
|
||||
* 它不是「使用者要自己維護的設定」,是**從既有真相源導出的值**,因此不會漂。
|
||||
*
|
||||
* 那為什麼不像 #105 一樣「掛在登入者身上」?因為在這個架構裡租戶**不是**每人一個:
|
||||
* portal 帳號共用同一台實例的知識庫(design D-2,帳號自己住 `{tenant}::portal` 子
|
||||
* namespace),帳號之間的差別是 `libraries` 權限,不是 owner_id。把 owner_id 複製一份
|
||||
* 到每個帳號上,只會多一個可以各自過期的副本——那正是本票的病,不是解藥。
|
||||
* #105 真正的教訓不是「一律搬到帳號上」,而是:
|
||||
* **過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、而且要能被機械驗證。**
|
||||
* 這三件事就是本檔在做的事。
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 型別即閘(`TenantId`)
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* `TenantId` 是 branded string,**只能**由本檔產生(`knowledgeOwner` / `tenantFromApiKey`)。
|
||||
* 所有資料面 owner_id 過濾一律經 `ownerQuery()` / `ownerField()`,而那兩支只吃 `TenantId`
|
||||
* ⇒ 想把「隨手一個 env 字串」拿去過濾,`tsc` 當場就不給過。
|
||||
*
|
||||
* 配套的機械檢查在 `scripts/check-tenant-source.mjs`(測試 `tests/tenant-gate.test.ts`
|
||||
* 會同時驗「repo 現況乾淨」與「這道閘真的擋得住壞例子」)。
|
||||
*/
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
/**
|
||||
* 可以拿去做資料面過濾的租戶識別。
|
||||
*
|
||||
* branded type:外面拿不到建構子,只能從本檔的兩支 minter 取得——
|
||||
* 一支從實例 namespace 來(`knowledgeOwner`),一支從請求本身來(`tenantFromApiKey`)。
|
||||
* 兩支都不含字面預設值。
|
||||
*/
|
||||
export type TenantId = string & { readonly __tenantId: unique symbol };
|
||||
|
||||
/** 實例 namespace 解析不出來 → 誠實炸掉,不拿預設值當答案(#100「讀不到就說讀不到」同源)。 */
|
||||
export class TenantUnresolvedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TenantUnresolvedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 這台實例的**知識 owner_id**——三元組 / entries / records / 藏書地圖 / 工作流 KV
|
||||
* 全部掛在這個字串底下,由 CLI、小幫手、MCP 寫入時決定。
|
||||
*
|
||||
* 解析順序(**沒有字面預設值**):
|
||||
* 1. `ARCRUN_NAMESPACE`——`acr init/update` 從 `~/.arcrun/config.yaml` 的 `api_key` 注入。
|
||||
* 這是寫入端用的那個值本身,因此永遠對得上。
|
||||
* 2. `CONSOLE_TENANT`——官方 prod(`cypher.arcrun.dev`)與 #108 之前部署的實例走這條。
|
||||
* 官方 prod 的知識確實寫在 `leo` 底下,所以對它而言這是正解;對跑過 `acr update`
|
||||
* 的 self-hosted 實例,第 1 條會先命中。
|
||||
* 3. 兩個都沒有 → **丟 TenantUnresolvedError**。不回 `"leo"`:那個預設值正是把
|
||||
* 「這台機器沒設定」偽裝成「你沒有資料」的元凶。
|
||||
*/
|
||||
export function knowledgeOwner(env: Bindings): TenantId {
|
||||
const injected = (env.ARCRUN_NAMESPACE ?? '').trim();
|
||||
if (injected) return injected as TenantId;
|
||||
const legacy = (env.CONSOLE_TENANT ?? '').trim();
|
||||
if (legacy) return legacy as TenantId;
|
||||
throw new TenantUnresolvedError(
|
||||
'這個部署沒有知識命名空間(ARCRUN_NAMESPACE / CONSOLE_TENANT 都沒設)——' +
|
||||
'不知道要去哪一格找資料。請跑 `acr update` 讓它從你的 ~/.arcrun/config.yaml 注入。',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 請求自帶的租戶(`X-Arcrun-API-Key`=namespace 明碼,self-hosted 身分模型)。
|
||||
*
|
||||
* 這條路的租戶來自**請求本身**而不是環境變數,本來就沒有 #105/#108 的漂移問題;
|
||||
* 收進本檔只是為了讓「所有 owner_id 過濾值都是 TenantId」這條型別閘沒有破口。
|
||||
* 空字串不給過——沒有身分就不該有查詢範圍。
|
||||
*/
|
||||
export function tenantFromApiKey(apiKey: string): TenantId {
|
||||
const key = (apiKey ?? '').trim();
|
||||
if (!key) throw new TenantUnresolvedError('缺少 X-Arcrun-API-Key,無法決定查詢範圍');
|
||||
return key as TenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 帳號子 namespace 用的租戶字串(design D-2:帳號資料住 `{tenant}::portal`)。
|
||||
*
|
||||
* 🔴 **回傳的是 `string`,不是 `TenantId`——這是刻意的**:帳號那批資料是 cypher 自己
|
||||
* 寫進去的(用的就是這個值),所以它自洽;但它**不可以**拿去過濾知識資料面,
|
||||
* 否則就是把 #108 再犯一次。型別上不給過,不必靠人記得。
|
||||
*
|
||||
* 保留 `'leo'` 預設值是為了不動既有帳號的落點(改了會讓舊實例登不進去)。
|
||||
*/
|
||||
export function accountTenant(env: Bindings): string {
|
||||
return env.CONSOLE_TENANT || 'leo';
|
||||
}
|
||||
|
||||
/**
|
||||
* KBDB query string 的 owner_id 過濾片段——**資料面過濾的唯一入口之一**。
|
||||
* 用法:`kbdbFetch(env, `/map?${ownerQuery(tenant)}`)`
|
||||
*/
|
||||
export function ownerQuery(tenant: TenantId): string {
|
||||
return `owner_id=${encodeURIComponent(tenant)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 要放進 JSON body / URLSearchParams 的 owner_id 值——**資料面過濾的唯一入口之一**。
|
||||
* 用法:`JSON.stringify({ owner_id: ownerField(tenant) })`
|
||||
*/
|
||||
export function ownerField(tenant: TenantId): string {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **刻意不帶租戶範圍**的查詢片段(KBDB 慣例:`owner_id` 空值=不過濾)。
|
||||
*
|
||||
* 唯一合法用途:#100 的普查——「本租戶查到 0 筆」時再問一次「整台實例到底有沒有」,
|
||||
* 用來分辨「查不到」與「沒有」。**回傳的是統計數字,不是任何人的內容**;
|
||||
* 拿它去撈實際資料就是跨租戶外洩。名字取得這麼長就是要讓 review 一眼看見。
|
||||
*/
|
||||
export function censusQueryAllTenants(): string {
|
||||
return 'owner_id=';
|
||||
}
|
||||
|
||||
/** 逐筆核對歸屬(讀回來的 record/entry 是不是這個租戶的)。缺欄位一律視為不是。 */
|
||||
export function isOwnedBy(value: unknown, tenant: TenantId): boolean {
|
||||
return typeof value === 'string' && value === (tenant as string);
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
readAuthStore,
|
||||
type AuthConsoleRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
// Arcrun#108:租戶字串唯一產地。
|
||||
import { knowledgeOwner } from '../lib/tenant';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -79,8 +81,17 @@ async function hashPassword(password: string, salt: string): Promise<string> {
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* console 登入後下發給前端當 api_key 用的租戶字串(舊 console 的設計,與 portal 不同:
|
||||
* portal 絕不下發,console 會)。
|
||||
*
|
||||
* Arcrun#108:這是**知識資料面**的 owner_id(前端拿它直打 `/kbdb/*`),所以必須與寫入端
|
||||
* (CLI/小幫手/MCP 用的實例 namespace)同源。以前直接讀 `env.CONSOLE_TENANT || 'leo'`
|
||||
* ⇒ 與 portal 同一個病:資料在 `bfezv28v`、過濾拿 `leo`,console 首頁的藏書地圖同樣是空的。
|
||||
* 現在走唯一產地 `lib/tenant.ts`。
|
||||
*/
|
||||
function tenantOf(c: { env: Bindings }): string {
|
||||
return c.env.CONSOLE_TENANT || 'leo';
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
|
||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbBase, graphBase } from './kbdb-proxy';
|
||||
import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import {
|
||||
type KbdbEntry,
|
||||
@@ -72,6 +72,9 @@ import {
|
||||
taipeiDayKey,
|
||||
} from '../lib/console-dashboard-model';
|
||||
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
|
||||
// Arcrun#108:租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT
|
||||
// 過濾而看不到自己的資料——與 portal 同一個病,同一個修法。
|
||||
import { knowledgeOwner } from '../lib/tenant';
|
||||
|
||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -104,6 +107,31 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本租戶三元組的**真實總數**(null = 讀不到,畫面要顯示「讀不到」而非 0)。
|
||||
*
|
||||
* 🔴 Arcrun#100:不可以拿 graph-plugin `/triplets/stats` 的 `total` 當數量。
|
||||
* 那支的 `total` 是**分頁長度**不是 COUNT——它走 `/records/by-template/triplet`(KBDB 端
|
||||
* `searchByTemplate` 預設 limit=100、硬上限 500)且不帶 owner 過濾,所以 1854 條的庫
|
||||
* 只會回 100。修好 401 之後若還讀它,畫面會從「0」變成「100」——一樣是假的。
|
||||
* 真相源=KBDB `/records/triplet-stats`(真 SQL COUNT(*)、依 owner_id 過濾、無上限),
|
||||
* 回 `{ success, stats: [{ library, triplet_count }] }`,加總即全庫條數。
|
||||
*/
|
||||
async function fetchTripletTotal(env: Bindings, tenant: string): Promise<number | null> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>(
|
||||
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`,
|
||||
headers,
|
||||
);
|
||||
if (!data || !Array.isArray(data.stats)) return null;
|
||||
let total = 0;
|
||||
for (const row of data.stats) {
|
||||
if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到,不半信半疑加總
|
||||
total += row.triplet_count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||||
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
@@ -240,7 +268,7 @@ export async function cachedGiteaSprint(
|
||||
|
||||
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
||||
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const now = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
@@ -254,6 +282,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
tripletTotal,
|
||||
entriesTotal,
|
||||
wikiCardTotal,
|
||||
workflowTotal,
|
||||
@@ -265,7 +294,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
|
||||
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
|
||||
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
|
||||
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(
|
||||
`${graphUrl}/triplets/stats`,
|
||||
graphHeaders(c.env),
|
||||
),
|
||||
fetchTripletTotal(c.env, tenant),
|
||||
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||
fetchEntryTotal(c.env, { owner_id: tenant }),
|
||||
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
|
||||
@@ -400,13 +435,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
embed: embedStatus
|
||||
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
||||
: null,
|
||||
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
|
||||
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
|
||||
graph: { ok: graphStats !== null, triplets: tripletTotal },
|
||||
workflow_total: workflowTotal,
|
||||
},
|
||||
kb: {
|
||||
entries_total: entriesTotal,
|
||||
wiki_card_total: wikiCardTotal,
|
||||
triplets_total: graphStats?.total ?? null,
|
||||
triplets_total: tripletTotal,
|
||||
},
|
||||
generated_at: new Date(now).toISOString(),
|
||||
});
|
||||
@@ -418,17 +454,17 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
|
||||
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
|
||||
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
const now = Date.now();
|
||||
const [wikiCards, graphStats, embedStatus] = await Promise.all([
|
||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
||||
// limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
|
||||
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
|
||||
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
|
||||
headers,
|
||||
),
|
||||
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
|
||||
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
|
||||
fetchTripletTotal(c.env, tenant),
|
||||
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
|
||||
]);
|
||||
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||
@@ -436,7 +472,7 @@ consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
return c.json({
|
||||
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
|
||||
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
|
||||
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
|
||||
triplets_total: tripletTotal,
|
||||
embedded: embedStatus?.embedded ?? null,
|
||||
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
||||
generated_at: new Date(now).toISOString(),
|
||||
@@ -466,7 +502,7 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const [todoEntries, inboxEntries] = await Promise.all([
|
||||
fetchEntries(c.env, tenant, 'todo', 500),
|
||||
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||
@@ -497,7 +533,7 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => {
|
||||
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
|
||||
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
|
||||
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
|
||||
|
||||
@@ -15,12 +15,26 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
|
||||
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
|
||||
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
|
||||
// Arcrun#106(leo 08-12 實撞:更新完設定頁變成「無法讀取目前版本」):
|
||||
// `bundle_version` 只在部署時被注入,而**只有安裝器會注入**——CLI 更新那條路重部署
|
||||
// 等於把這個標籤洗掉(wrangler deploy 整份覆蓋,toml 沒寫的 var 直接消失)。
|
||||
// 修在 CLI 那側(cli/src/lib/deploy.ts:既有 var 沿用 + 版本標籤每趟重烙)。
|
||||
// 這裡只多吐一個 `bundle_commit`:版號是「發行頻道的編號」,commit 才是「真的部了哪份碼」——
|
||||
// 兩個一起看才有辦法查「標籤有沒有跟成品漂掉」。沒注入就省略該欄(同 bundle_version 的既有行為)。
|
||||
healthRouter.get('/health', (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||
return c.json({
|
||||
ok: true,
|
||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||
...(bundleCommit ? { bundle_commit: bundleCommit } : {}),
|
||||
auth_store: authStoreStatus(c.env),
|
||||
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||
// 版本無關(同一個 cypher 版本,有的實例有這個 var、有的沒有)。純比版本號的話,
|
||||
// 已經在最新版的實例(如 leo 自己那台)永遠不會因為「按更新」而重推,這個 var
|
||||
// 就永遠補不進去。只回布林(有沒有設,不回值本身)——不洩漏郵差網址。
|
||||
mail_relay_configured: Boolean(String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -119,6 +119,27 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// PATCH /kbdb/records/:recordId — 翻某筆 record 的 slot 值({ values:{slot:content} })。
|
||||
// 補上基本盤既有能力(kbdb/src/routes/records.ts 的 PATCH /records/:recordId,mira-dissolve T2.1)
|
||||
// 缺的對外通道——2026-08-11 leo 三元組 library 補標核實:base 早有這個端點,但這條 proxy
|
||||
// 之前只轉發 GET/POST,插件/工作流打不到,補標三元組只能繞去改表(違 D38)。單純轉發,無業務邏輯。
|
||||
// by-id 沿用既有慣例(require-key,不額外做 owner 比對——與本檔 GET .../:recordId、
|
||||
// PATCH /kbdb/entries/:id 同款)。
|
||||
kbdbProxyRouter.patch('/kbdb/records/:recordId', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.values !== 'object' || body.values === null) {
|
||||
return c.json({ error: 'values 必填({slot名: 內容})' }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ values: body.values }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
|
||||
|
||||
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
|
||||
@@ -202,13 +223,27 @@ export function graphBase(env: Bindings): string {
|
||||
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb-graph-plugin 的 internal headers。**打 plugin 一律用這支,不要各自手拼**(Arcrun#100)。
|
||||
*
|
||||
* plugin 端(kbdb-graph-plugin/src/index.ts)對 `/triplets` `/graph` `/search` `/entities`
|
||||
* 四個前綴掛了 Bearer 閘:設了 KBDB_INTERNAL_TOKEN 就必須帶,否則一律 401。
|
||||
* 原本三處手拼(本檔 neighbors、portal-data neighbors、console-dashboard 兩支 stats),
|
||||
* 前兩處帶了、後兩處漏了 → `/triplets/stats` 永遠 401 → 前端「三元組 0」。
|
||||
* 收斂成一支函式=新的呼叫點不可能再漏(漂移的根,不是那兩行本身)。
|
||||
*/
|
||||
export function graphHeaders(env: Bindings): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
|
||||
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
|
||||
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const base = graphBase(c.env);
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const headers = graphHeaders(c.env);
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
@@ -23,8 +23,11 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||
import { graphBase } from './kbdb-proxy';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||
// Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import
|
||||
// portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。
|
||||
import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant';
|
||||
import { graphBase, graphHeaders } from './kbdb-proxy';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
|
||||
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
||||
@@ -40,7 +43,9 @@ export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/** 讀 tenant 的 named workflow graph(`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
|
||||
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
|
||||
const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text');
|
||||
// #108:workflow 是 CLI `acr push` 用實例 namespace 寫進來的(`{ns}:wf:*`),
|
||||
// 所以讀的時候也要用同一個 namespace,不是帳號層那個字串。
|
||||
const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text');
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
|
||||
@@ -186,10 +191,51 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
|
||||
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||||
}
|
||||
|
||||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||||
/**
|
||||
* 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端
|
||||
* `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。
|
||||
*/
|
||||
async function tripletCount(env: Bindings, owner: TenantId | null): Promise<number | null> {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||
// owner=null = 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個
|
||||
// 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
|
||||
if (!body || !Array.isArray(body.stats)) return null;
|
||||
let total = 0;
|
||||
for (const row of body.stats) {
|
||||
if (typeof row?.triplet_count !== 'number') return null;
|
||||
total += row.triplet_count;
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 三元組普查(Arcrun#100)——回答總圖那句「知識庫還沒有任何關聯」到底能不能講。
|
||||
*
|
||||
* leo 的原則(已寫在 portal.ts §②.5 daemon diagnostics):**「不要讓『查不到』和『沒有』
|
||||
* 長得一樣」**。t161 前科:手補的 record owner_id 存成 None ⇒ 全量查得到、按 owner_id 過濾
|
||||
* 的畫面永遠空——比真的沒資料更難查。所以本租戶數為 0 時**再花一次查詢換一條路徑**
|
||||
* (同一支端點但不帶 owner),問「這個庫到底有沒有三元組」:
|
||||
* owned>0 → 有資料
|
||||
* owned=0 且 any=0 → 真的空(此時、也只有此時,畫面才准印 0)
|
||||
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
|
||||
* owned=null → 讀不到 → 畫面說讀不到
|
||||
*/
|
||||
async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> {
|
||||
const owned = await tripletCount(env, tenant);
|
||||
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
|
||||
return { owned, any: await tripletCount(env, null) };
|
||||
}
|
||||
|
||||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
@@ -224,7 +270,7 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
||||
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
|
||||
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
|
||||
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
||||
@@ -295,7 +341,7 @@ portalDataRouter.get('/portal/data/entries/:id', (c) =>
|
||||
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
|
||||
const entry = body.entry;
|
||||
if (!entry) return notFound(c);
|
||||
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
|
||||
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
|
||||
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||
return c.json({ success: true, entry });
|
||||
}),
|
||||
@@ -320,7 +366,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
const nodeName = normalizeCjkQuery(c.req.param('name'));
|
||||
|
||||
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
||||
if (wfGraph) {
|
||||
const depthRaw = c.req.query('depth') ?? '';
|
||||
@@ -343,8 +389,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
|
||||
// ② plugin fallback(Mira/leo21c 相容)
|
||||
const base = graphBase(c.env);
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const headers = graphHeaders(c.env);
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||||
if (!res.ok) {
|
||||
@@ -382,15 +427,24 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
||||
if (!(await hasGraphAccess(c.env, libraries))) {
|
||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||
}
|
||||
const tenant = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`),
|
||||
tripletCensus(c.env, tenant),
|
||||
]);
|
||||
const tripletsTotal = census.owned;
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { records?: { values?: Record<string, unknown> }[] }
|
||||
| null;
|
||||
const records = body && Array.isArray(body.records) ? body.records : [];
|
||||
// #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖,
|
||||
// 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
|
||||
}
|
||||
const records = body.records;
|
||||
const EDGE_CAP = 500;
|
||||
const seen = new Set<string>();
|
||||
const edges: { subject: string; predicate: string; object: string }[] = [];
|
||||
@@ -413,7 +467,28 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
||||
degree.set(o, (degree.get(o) ?? 0) + 1);
|
||||
}
|
||||
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
|
||||
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
|
||||
// #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)——
|
||||
// confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」
|
||||
// scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科)
|
||||
// unreadable :連條數都讀不到 → 只能說讀不到
|
||||
let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
|
||||
if (nodes.length === 0) {
|
||||
if (census.owned === null) emptyReason = 'unreadable';
|
||||
else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
|
||||
else if (census.any === null) emptyReason = 'unreadable';
|
||||
else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
|
||||
}
|
||||
return c.json({
|
||||
nodes,
|
||||
edges,
|
||||
node_count: nodes.length,
|
||||
edge_count: edges.length,
|
||||
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
|
||||
truncated: truncated || records.length >= 500,
|
||||
triplets_total: tripletsTotal,
|
||||
empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
|
||||
empty_reason: emptyReason,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -437,7 +512,7 @@ portalDataRouter.get('/portal/data/chat', (c) =>
|
||||
wfGraph,
|
||||
{ question },
|
||||
'rag_chat',
|
||||
portalTenant(c.env),
|
||||
knowledgeOwner(c.env),
|
||||
c.executionCtx,
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -543,7 +618,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
// 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
|
||||
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
|
||||
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const prefix = `${tenant}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const workflows = await Promise.all(
|
||||
@@ -569,7 +644,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`,
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null) as {
|
||||
success?: boolean;
|
||||
@@ -585,6 +660,267 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 授權的 AI(arcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
|
||||
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
|
||||
//
|
||||
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
|
||||
// 於是查詢時只好去找一把**服務內部金鑰**(KBDB_INTERNAL_TOKEN)直打 KBDB——
|
||||
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
|
||||
//
|
||||
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
|
||||
// ① 一律 requirePortalUser(session → 回讀 user record → 停用即時生效),
|
||||
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
|
||||
// 呼叫端自己帶租戶字串=繞過庫過濾),
|
||||
// ③ 越權與不存在同回 404(不洩存在性)。
|
||||
//
|
||||
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
|
||||
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
|
||||
|
||||
/**
|
||||
* record 的庫歸屬。與 entry 不同:**沒有 `library` slot 的 record 不套庫過濾**。
|
||||
*
|
||||
* 為什麼不比照 entry 用 'general' fallback:entry 是知識內容(庫是它的第一屬性,沒標就歸
|
||||
* general 是對的);record 是結構化資料列(contact / workflow_metadata / triplet…),
|
||||
* 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback,
|
||||
* 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺,不是隔離。
|
||||
* 租戶邊界仍然守著(owner_id 由 server 注入/逐筆比對),這裡只多守「有標庫的別越庫」。
|
||||
*/
|
||||
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
|
||||
const lib = values?.library;
|
||||
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
|
||||
}
|
||||
|
||||
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
|
||||
function canReadRecord(
|
||||
rec: { values?: Record<string, unknown>; owner_id?: string | null },
|
||||
tenant: TenantId,
|
||||
libraries: string[],
|
||||
): boolean {
|
||||
if (!isOwnedBy(rec.owner_id, tenant)) return false;
|
||||
const lib = recordLibrary(rec.values);
|
||||
return lib === null || canReadLibrary(libraries, lib);
|
||||
}
|
||||
|
||||
// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。
|
||||
// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
|
||||
//
|
||||
// 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜**
|
||||
// (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制):
|
||||
// no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題
|
||||
// filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離
|
||||
// confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」
|
||||
// scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上**
|
||||
// (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾)
|
||||
// scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。
|
||||
//
|
||||
// ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。
|
||||
// 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。
|
||||
portalDataRouter.get('/portal/data/map', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'no_library_grant',
|
||||
note: '此帳號尚未被授權任何知識庫,請聯絡管理員。',
|
||||
});
|
||||
}
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
|
||||
if (!body || !Array.isArray(body.libraries)) {
|
||||
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
|
||||
}
|
||||
const allowed = body.libraries.filter(
|
||||
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
|
||||
);
|
||||
if (allowed.length > 0) {
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
|
||||
}
|
||||
// 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。
|
||||
if (body.libraries.length > 0) {
|
||||
// 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'filtered_out',
|
||||
note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。',
|
||||
});
|
||||
}
|
||||
const census = await tripletCensus(c.env, tenant);
|
||||
if (census.owned === null || (census.owned === 0 && census.any === null)) {
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: false, empty_reason: 'unreadable',
|
||||
note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。',
|
||||
});
|
||||
}
|
||||
if (census.owned === 0 && (census.any ?? 0) > 0) {
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: false, empty_reason: 'scope_mismatch',
|
||||
instance_triplet_count: census.any,
|
||||
note:
|
||||
`讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` +
|
||||
'這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' +
|
||||
'請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。',
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'confirmed_empty',
|
||||
note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
|
||||
portalDataRouter.get('/portal/data/map/:library', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
const library = c.req.param('library');
|
||||
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`,
|
||||
);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/templates — template 清單。
|
||||
// template=虛擬表定義(schema),**全域共享不分租戶**(kbdb-proxy 同一裁定,leo 2026-06-14):
|
||||
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
|
||||
portalDataRouter.get('/portal/data/templates', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const res = await kbdbFetch(c.env, '/templates');
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/data/templates — 建 template(name + slots)。
|
||||
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
|
||||
// created_by 記租戶(溯源),template 本身全域可見可用。
|
||||
portalDataRouter.post('/portal/data/templates', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { name?: unknown; slots?: unknown; description?: unknown }
|
||||
| null;
|
||||
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
|
||||
return c.json({ error: 'name 與 slots[] 必填' }, 400);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, '/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: body.name,
|
||||
slots: body.slots,
|
||||
description: typeof body.description === 'string' ? body.description : undefined,
|
||||
created_by: knowledgeOwner(c.env),
|
||||
}),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
|
||||
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
|
||||
portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`,
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
|
||||
| null;
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
|
||||
}
|
||||
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
|
||||
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
|
||||
return c.json({ success: true, records, count: records.length });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/records/:recordId — 單筆 record。
|
||||
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
|
||||
portalDataRouter.get('/portal/data/records/:recordId', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return notFound(c);
|
||||
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
|
||||
| null;
|
||||
const record = body?.record;
|
||||
if (!record) return notFound(c);
|
||||
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
|
||||
return c.json({ success: true, record });
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/data/records — 依 template 填一筆 record。
|
||||
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
|
||||
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
|
||||
portalDataRouter.post('/portal/data/records', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { template?: unknown; values?: unknown }
|
||||
| null;
|
||||
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
|
||||
return c.json({ error: 'template 與 values 必填' }, 400);
|
||||
}
|
||||
const values = body.values as Record<string, unknown>;
|
||||
const targetLib = recordLibrary(values);
|
||||
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
|
||||
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
|
||||
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
|
||||
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, '/records', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||||
//
|
||||
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||||
@@ -607,7 +943,7 @@ 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 tenant = knowledgeOwner(c.env);
|
||||
const core = await buildDiagnostics(c.env, tenant);
|
||||
return c.json({
|
||||
generated_at: new Date().toISOString(),
|
||||
|
||||
@@ -23,8 +23,11 @@ import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
|
||||
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||
// Arcrun#108:租戶字串只有一個產地(lib/tenant.ts)。帳號面用 accountTenant(普通 string),
|
||||
// 知識資料面用 knowledgeOwner(TenantId)——型別分家,拿錯編不過。
|
||||
import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey, TenantUnresolvedError, type TenantId } from '../lib/tenant';
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
import { storeCredential, hasCredential } from './credentials';
|
||||
@@ -58,14 +61,27 @@ export const LIBRARY_TEMPLATE = 'portal_library';
|
||||
|
||||
// ── 基礎 helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */
|
||||
/**
|
||||
* 帳號層的租戶字串(**不是**知識資料的 owner_id,Arcrun#108 拆開)。
|
||||
*
|
||||
* 只用來組帳號子 namespace(`{tenant}::portal`,design D-2)與 cypher 自己寫的設定
|
||||
* (extractor_config / credentials 目錄)——那些都是 cypher 用同一個值寫進去的,所以自洽。
|
||||
*
|
||||
* 🔴 **不可以拿它過濾知識資料面**(三元組 / entries / records / 藏書地圖 / 工作流):
|
||||
* 那批是 CLI/小幫手用實例 namespace 寫的,兩者對不上就是 #108
|
||||
* (leo 的 1854 條被 `CONSOLE_TENANT="leo"` 過濾成 0)。資料面請用
|
||||
* `lib/tenant.ts` 的 `knowledgeOwner(env)`——它回 `TenantId`,本函式回 `string`,
|
||||
* 型別上就分得開,不必靠人記得。
|
||||
*
|
||||
* **只在 server 側使用,永不下發前端**。
|
||||
*/
|
||||
export function portalTenant(env: Bindings): string {
|
||||
return env.CONSOLE_TENANT || 'leo';
|
||||
return accountTenant(env);
|
||||
}
|
||||
|
||||
/** 帳號子 namespace(design D-2)。 */
|
||||
function portalNamespace(env: Bindings): string {
|
||||
return `${portalTenant(env)}::portal`;
|
||||
return `${accountTenant(env)}::portal`;
|
||||
}
|
||||
|
||||
function sessionTtl(env: Bindings): number {
|
||||
@@ -102,6 +118,11 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
// Arcrun#108:連「這台實例的知識放在哪一格」都解析不出來 → 誠實講「讀不到」,
|
||||
// 不拿 repo 預設值當答案然後回一頁空的(那正是本票的病:設定缺失被畫成「你沒有資料」)。
|
||||
if (e instanceof TenantUnresolvedError) {
|
||||
return c.json({ error: e.message, code: 'tenant_unresolved' }, 500);
|
||||
}
|
||||
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
|
||||
throw e;
|
||||
}
|
||||
@@ -677,6 +698,11 @@ portalRouter.post('/portal/login', (c) =>
|
||||
display_name: rec.values.display_name ?? '',
|
||||
role: rec.values.role ?? 'user',
|
||||
libraries: parseLibraries(rec.values.libraries),
|
||||
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
|
||||
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
|
||||
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
|
||||
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
|
||||
session_expires_in: sessionTtl(c.env),
|
||||
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
||||
});
|
||||
}),
|
||||
@@ -715,29 +741,310 @@ portalRouter.get('/portal/session', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/me/password — body {current, new}。驗舊密改新密。
|
||||
portalRouter.post('/portal/me/password', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const current = String(body?.current ?? '');
|
||||
const next = String(body?.new ?? '');
|
||||
if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400);
|
||||
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
||||
// ═════════════════ D62:改密碼與忘記密碼是**同一個機制** ═══════════════════════
|
||||
//
|
||||
// leo 2026-08-10 拍板(頂層 decisions-summary D62):
|
||||
// 「這兩個機制其實是一個機制,可以簡化。」
|
||||
// - 修改密碼:到「修改密碼」→ 輸入**現有的** → 輸入新的 → 覆蓋現有的
|
||||
// - 忘記密碼:寄給你**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
|
||||
// ⇒ **同一個畫面、同一條寫入路徑,差別只有一格**:「現有密碼」是要填、還是被連結豁免。
|
||||
//
|
||||
// 🔴 **不做一次性密碼**(leo:「不要發一次性密碼太麻煩」)——這修正了 D50 的「一次性驗證碼」,
|
||||
// 形態改成連結;D50 其餘部分(console 退場、不准沿用註冊辨識碼)不變。
|
||||
// 🔴 連結的安全性(承 D50 的理由,不可退讓):
|
||||
// ① **一次有效**——用掉就從 KV 刪除(本檔 consumeResetToken)
|
||||
// ② **會過期**——KV expirationTtl 30 分鐘,過了就是不存在
|
||||
// ③ **與註冊辨識碼不同源**——現場 crypto 亂數產生、只活在這台實例的 SESSIONS_KV,
|
||||
// 跟 landing `SIGNUPS` 那組安裝辨識碼沒有任何關係。
|
||||
// D50 否決固定辨識碼的理由正是「綁定不會變 ⇒ 等於不會過期的鑰匙」。
|
||||
// 🔴 已否決、不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。
|
||||
// 🔴 **入口在 portal,不是 console**(leo 2026-08-10:「是對 portal 不是對 console,
|
||||
// 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」)——console 與 portal 是
|
||||
// 安裝時同一組帳密寫進兩個地方(D50 補刀),往 console 補洞不會多出任何一條路。
|
||||
|
||||
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
|
||||
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
|
||||
/** 「修改密碼」連結 token 的 KV key 前綴(存的是 token 的 sha256,不是 token 本身)。 */
|
||||
const PWRESET_PREFIX = 'portal_pwreset:';
|
||||
/** 連結有效期:30 分鐘(安全要求②「會過期」)。 */
|
||||
const PWRESET_TTL_SECONDS = 30 * 60;
|
||||
/** 同一個 email 的請求節流 key 前綴+冷卻秒數(避免被拿來灌信)。 */
|
||||
const PWRESET_THROTTLE_PREFIX = 'portal_pwreset_req:';
|
||||
const PWRESET_THROTTLE_SECONDS = 120;
|
||||
/** 代寄回呼票(讓郵差可以回頭問「這封真的是你要我寄的嗎」)的 key 前綴與存活秒數。 */
|
||||
const RELAY_TICKET_PREFIX = 'portal_relay_ticket:';
|
||||
const RELAY_TICKET_TTL_SECONDS = 120;
|
||||
|
||||
const newHash = await hashPassword(next);
|
||||
await patchRecordValues(c.env, auth.user.recordId, {
|
||||
password_hash: newHash,
|
||||
updated_at: new Date().toISOString(),
|
||||
interface ResetTokenPayload {
|
||||
record_id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* portal 前端(使用者會看到的那個網址)的 origin。
|
||||
* 與 index.ts 的 CORS 白名單**同一套推導**:UI_ORIGINS 優先,否則用 workers.dev 兄弟位址。
|
||||
* (2026-08-08 事故的教訓:能推導出來的東西就不要再多一個「必須被注入、漏了看不出來」的變數。)
|
||||
*/
|
||||
function portalUiOrigin(env: Bindings): string | null {
|
||||
const declared = String(env.UI_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (declared.length > 0) return declared[0];
|
||||
const sub = String(env.WORKER_SUBDOMAIN ?? '').trim();
|
||||
return sub ? `https://arcrun-rag-ui.${sub}.workers.dev` : null;
|
||||
}
|
||||
|
||||
/** 發一張「修改密碼」連結票,回傳要放進連結的 token(明碼只在這一刻存在)。 */
|
||||
async function issueResetToken(env: Bindings, recordId: string, email: string): Promise<string> {
|
||||
const token = randomHex(32);
|
||||
const payload: ResetTokenPayload = { record_id: recordId, email, created_at: new Date().toISOString() };
|
||||
await env.SESSIONS_KV.put(`${PWRESET_PREFIX}${await sha256Hex(token)}`, JSON.stringify(payload), {
|
||||
expirationTtl: PWRESET_TTL_SECONDS,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 看一眼票是否有效(**不消耗**)——給「點進連結先渲染畫面」用。 */
|
||||
async function peekResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
|
||||
if (!token || !/^[0-9a-f]{16,128}$/i.test(token)) return null;
|
||||
const raw = await env.SESSIONS_KV.get(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as ResetTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用掉一張票(安全要求①「一次有效」):**先刪再回傳**。
|
||||
* 順序是刻意的——先刪掉才回,兩個人同時點同一條連結時最多只有一個拿得到。
|
||||
*/
|
||||
async function consumeResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
|
||||
const payload = await peekResetToken(env, token);
|
||||
if (!payload) return null;
|
||||
await env.SESSIONS_KV.delete(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* **唯一的密碼寫入路徑**(D62「同一條寫入路徑」的落地點)。
|
||||
* 修改密碼與忘記密碼都只能從這裡覆蓋密碼——不再有第二支自己 hash 自己 patch 的路。
|
||||
*/
|
||||
async function writeNewPassword(env: Bindings, recordId: string, newPassword: string): Promise<void> {
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await patchRecordValues(env, recordId, {
|
||||
password_hash: newHash,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 請中央 landing 代寄「修改密碼」連結。
|
||||
*
|
||||
* 🔴 **為什麼要代寄**(leo 2026-08-10 給的職責切法):
|
||||
* 「其實這應該是 youlin 的實例告訴 arcrun.dev 說『我實例的重設密碼網址是 abc.recover,
|
||||
* 你幫我寄信給用戶讓他來修改密碼』,由 arcrun.dev 幫它寄出這封信。」
|
||||
* ⇒ **實例**產生連結、管一次有效/過期/作廢;**arcrun.dev 只是郵差**,不碰任何認證邏輯。
|
||||
* 必須這樣切的硬理由:用戶自己的實例**根本沒有寄信能力**——安裝器部署 cypher 的 binding
|
||||
* 只有 ai/d1/kv_namespace/plain_text/secret_text/service/vectorize,**沒有 send_email**。
|
||||
* ⚠️ 「由我們中央代寄」是依 leo「寄給你」推導的**假設**,他尚未正式表態(D62 明列為未裁前置)。
|
||||
*
|
||||
* 🔴 **絕不把整條 URL 交給郵差**(總管 2026-08-10 紅線):寄件網域 `arcrun.dev` 掛在 uncle6、
|
||||
* 帶 DKIM。郵差若肯收「任意 URL + 任意 email」就寄,任何人裝一台實例就能用 `arcrun.dev`
|
||||
* 的名義、**通過 DKIM 驗證**把任意連結寄給任意人 ⇒ 一台開放的釣魚中繼,
|
||||
* 燒的是整個網域的信譽、波及所有用戶、**不可逆**。
|
||||
*
|
||||
* 做法:我們只交出**這台實例自己的 origin + 一張回呼票**。連結由郵差自己組,而且郵差會
|
||||
* **回頭打這個 origin** 問「這張票是你發的嗎」(見 /portal/password/relay-verify)。
|
||||
* 冒用別人的網域會被那台實例自己否認 ⇒ **主機屬於呼叫方這件事由郵差親自確認,
|
||||
* 不是相信呼叫方的宣稱**。
|
||||
*
|
||||
* `apiOrigin` 取自**這次請求真正抵達的位址**(`new URL(c.req.url).origin`),不新增任何
|
||||
* 需要被注入的變數——2026-08-08 兩次事故的教訓:能推導的就不要再多一個會被漏掉的設定。
|
||||
*
|
||||
* **不假綠**(mindset §7):寄不出去就回 'failed' / 'not_configured'。
|
||||
*/
|
||||
async function relayResetLink(
|
||||
env: Bindings,
|
||||
apiOrigin: string,
|
||||
email: string,
|
||||
ticket: string,
|
||||
): Promise<'sent' | 'not_configured' | 'failed'> {
|
||||
const base = String(env.PORTAL_MAIL_RELAY_BASE ?? '').trim().replace(/\/$/, '');
|
||||
if (!base) return 'not_configured';
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.PORTAL_MAIL_RELAY_KEY) headers['X-Arcrun-Relay-Key'] = env.PORTAL_MAIL_RELAY_KEY;
|
||||
try {
|
||||
const res = await fetch(`${base}/api/send-password-reset`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ email, api_origin: apiOrigin, ticket }),
|
||||
});
|
||||
return c.json({ success: true });
|
||||
return res.ok ? 'sent' : 'failed';
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /portal/password/relay-verify — **郵差回頭確認用**(body `{ticket}`)。
|
||||
*
|
||||
* 這支存在的唯一理由是上面那條紅線:郵差不可以相信「呼叫方說這台是我的」。
|
||||
* 它會回頭打**連結裡的那個主機**問這張票在不在——
|
||||
* - 真的是這台發的 → 這裡答得出來 → 寄
|
||||
* - 有人冒用別人的網域 → 被冒用的那台**根本沒有這張票** → 答不出來 → 郵差拒寄
|
||||
* 回應**不含 email 明碼、不含 reset token**,只回收件人 email 的 sha256 讓郵差核對它手上那個,
|
||||
* 以及要放進信裡的完整連結(由本台自己組,主機必然是自己)。
|
||||
* 票 TTL 兩分鐘、看過即焚——它不是憑據,只是一次握手。
|
||||
*/
|
||||
portalRouter.post('/portal/password/relay-verify', (c) =>
|
||||
run(c, async () => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const ticket = String(body?.ticket ?? '').trim();
|
||||
if (!ticket || !/^[0-9a-f]{8,64}$/i.test(ticket)) return c.json({ ok: false }, 400);
|
||||
const key = `${RELAY_TICKET_PREFIX}${ticket}`;
|
||||
const raw = await c.env.SESSIONS_KV.get(key);
|
||||
if (!raw) return c.json({ ok: false }, 404);
|
||||
await c.env.SESSIONS_KV.delete(key); // 一次握手,看過即焚
|
||||
let parsed: { email: string; link: string };
|
||||
try {
|
||||
parsed = JSON.parse(raw) as { email: string; link: string };
|
||||
} catch {
|
||||
return c.json({ ok: false }, 404);
|
||||
}
|
||||
return c.json({ ok: true, email_sha256: await sha256Hex(parsed.email), link: parsed.link });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/password/reset-link?token=… — 信裡連結的落點。
|
||||
// **連結的主機刻意是 cypher 自己**(=郵差回呼確認的那個主機,兩者必須是同一個,
|
||||
// 否則「郵差確認過的主機」與「信裡的主機」就不是同一件事,紅線等於沒守)。
|
||||
// 這裡只做一件事:把人帶去 portal 前端的修改密碼畫面。
|
||||
portalRouter.get('/portal/password/reset-link', (c) => {
|
||||
const token = c.req.query('token') ?? '';
|
||||
const ui = portalUiOrigin(c.env);
|
||||
if (!ui) return c.text('這台實例沒有設定 portal 前端網址,無法導向修改密碼畫面。', 500);
|
||||
return c.redirect(`${ui}/portal/#/reset?token=${encodeURIComponent(token)}`, 302);
|
||||
});
|
||||
|
||||
// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。
|
||||
//
|
||||
// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。
|
||||
// 唯一會回錯的是「寄信功能根本沒接上」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
|
||||
// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。
|
||||
//
|
||||
// 🔴 arcrun-rag#38/#69/#25(2026-08-11 leo 親口點出兩個問題,改字前先讀):
|
||||
// ① 「實例」是行話——目標用戶是「只會叫 AI 幫忙的人」,不懂什麼叫一台實例。
|
||||
// ② 「請管理員直接幫你改密碼」對單人使用者是死路——他自己就是管理員,等於叫他聯絡自己
|
||||
// (`#25` 同一種病:「我忘記 portal 密碼,畫面叫我去找管理員,但管理員就是我」)。
|
||||
// ⇒ 訊息改成白話(不提「實例」),而且給一條他自己走得完的路:**重新跑一次安裝/更新**
|
||||
// 現在**真的有用**(不是安慰話)——arcrun-rag#38/#69/#25 同批修正讓安裝器學會「版本號一樣
|
||||
// 不代表這個功能已經接上,沒接上就當作舊的重推」,所以照原本收到的安裝網址走一次,
|
||||
// 選同一個 Cloudflare 帳號,這個功能就會自動接上,不需要自己改任何設定。
|
||||
portalRouter.post('/portal/password/forgot', (c) =>
|
||||
run(c, async () => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||||
if (!email || !isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
|
||||
|
||||
if (!String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'寄信功能還沒接上,所以「忘記密碼」的信寄不出去。' +
|
||||
'請照當初收到的安裝網址,重新執行一次安裝(選同一個 Cloudflare 帳號)——' +
|
||||
'完成後這個功能就會自動接上,不需要自己設定任何東西,也不用找任何人幫忙。',
|
||||
code: 'mail_relay_not_configured',
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const generic = {
|
||||
success: true,
|
||||
message: '如果這個 email 有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
|
||||
};
|
||||
|
||||
// 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
|
||||
const throttleKey = `${PWRESET_THROTTLE_PREFIX}${email}`;
|
||||
if (await c.env.SESSIONS_KV.get(throttleKey)) return c.json(generic);
|
||||
await c.env.SESSIONS_KV.put(throttleKey, '1', { expirationTtl: PWRESET_THROTTLE_SECONDS });
|
||||
|
||||
const recordId = await findUserRecordId(c.env, email).catch(() => null);
|
||||
if (!recordId) return c.json(generic); // 沒有這個帳號 → 一樣的回應,什麼都不寄
|
||||
|
||||
const token = await issueResetToken(c.env, recordId, email);
|
||||
// 連結的主機=這次請求真正抵達的位址(郵差待會兒就是回頭打這裡確認的)
|
||||
const apiOrigin = new URL(c.req.url).origin;
|
||||
const link = `${apiOrigin}/portal/password/reset-link?token=${encodeURIComponent(token)}`;
|
||||
const ticket = randomHex(16);
|
||||
await c.env.SESSIONS_KV.put(`${RELAY_TICKET_PREFIX}${ticket}`, JSON.stringify({ email, link }), {
|
||||
expirationTtl: RELAY_TICKET_TTL_SECONDS,
|
||||
});
|
||||
await relayResetLink(c.env, apiOrigin, email, ticket); // 寄不出去也回同一句(存在性不可由回應推得)
|
||||
return c.json(generic);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/password/reset?token=… — 點進連結時先問「這張票還有效嗎」(**不消耗**)。
|
||||
// 回 email 讓畫面顯示「你正在為 xxx@yyy 設定新密碼」——票本身就證明持有人控制那個信箱。
|
||||
portalRouter.get('/portal/password/reset', (c) =>
|
||||
run(c, async () => {
|
||||
const payload = await peekResetToken(c.env, c.req.query('token') ?? '');
|
||||
if (!payload) {
|
||||
return c.json(
|
||||
{ valid: false, error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
return c.json({ valid: true, email: payload.email });
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /portal/password/change — **D62 的那一支**:修改密碼與忘記密碼共用。
|
||||
*
|
||||
* body:`{ new, current? , reset_token? }`
|
||||
* - 帶 `reset_token`(從信裡的連結來)→ **忽略 current**,票就是憑據
|
||||
* - 沒帶 → 必須是登入狀態 + 提供正確的 `current`
|
||||
* 兩條路在這一行之後**完全相同**(writeNewPassword)——這就是「差別只有一格」的實體。
|
||||
*/
|
||||
async function handlePasswordChange(c: Context<{ Bindings: Bindings }>): Promise<Response> {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const next = String(body?.new ?? '');
|
||||
const resetToken = String(body?.reset_token ?? '').trim();
|
||||
if (!next) return c.json({ error: 'new(新密碼)必填' }, 400);
|
||||
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
||||
|
||||
// ── 忘記密碼那一格:憑連結,不問現有密碼 ──
|
||||
if (resetToken) {
|
||||
const payload = await consumeResetToken(c.env, resetToken);
|
||||
if (!payload) {
|
||||
return c.json(
|
||||
{ error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。', code: 'reset_token_invalid' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
await writeNewPassword(c.env, payload.record_id, next);
|
||||
return c.json({ success: true, email: payload.email, via: 'reset_link' });
|
||||
}
|
||||
|
||||
// ── 修改密碼那一格:要登入、要現有密碼 ──
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const current = String(body?.current ?? '');
|
||||
if (!current) return c.json({ error: 'current(現有密碼)必填' }, 400);
|
||||
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
|
||||
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
|
||||
|
||||
await writeNewPassword(c.env, auth.user.recordId, next);
|
||||
return c.json({ success: true, via: 'current_password' });
|
||||
}
|
||||
|
||||
portalRouter.post('/portal/password/change', (c) => run(c, () => handlePasswordChange(c)));
|
||||
|
||||
// POST /portal/me/password — 舊名,**轉呼同一支**(現有前端與 CLI 還在用這個路徑)。
|
||||
// 保留別名而不是留第二份實作:兩份必然漂移(D39/arcrun-rag#40「同一個事實兩份」)。
|
||||
portalRouter.post('/portal/me/password', (c) => run(c, () => handlePasswordChange(c)));
|
||||
|
||||
// ═══════════════════════════════ admin 端點 ══════════════════════════════════
|
||||
|
||||
// POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是
|
||||
@@ -1123,7 +1430,6 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const tenant = portalTenant(c.env);
|
||||
// t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
|
||||
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
|
||||
//
|
||||
@@ -1136,9 +1442,12 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
||||
//
|
||||
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊,
|
||||
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
|
||||
// #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
|
||||
// 以前給的是帳號層字串(CONSOLE_TENANT),與 CLI/MCP 用的實例 namespace 是兩個來源
|
||||
// ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner,一台實例一個值。
|
||||
const daemonCfg: Record<string, string> = {
|
||||
cypher_url: new URL(c.req.url).origin,
|
||||
namespace: tenant,
|
||||
namespace: knowledgeOwner(c.env),
|
||||
library: 'kb',
|
||||
email,
|
||||
instance_name: String(rec.values.display_name ?? ''),
|
||||
@@ -1170,7 +1479,7 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
|
||||
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
|
||||
const key = String(body?.key ?? '').trim();
|
||||
if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const kvKey = `${tenant}:wf:rag_chat`;
|
||||
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
|
||||
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
|
||||
@@ -1232,8 +1541,8 @@ portalRouter.get('/portal/admin/libraries', (c) =>
|
||||
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
|
||||
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
|
||||
try {
|
||||
const tenant = portalTenant(c.env);
|
||||
const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const ownerParam = ownerQuery(tenant);
|
||||
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
||||
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
||||
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
||||
@@ -1426,8 +1735,8 @@ 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)}`);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(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 });
|
||||
@@ -1446,10 +1755,10 @@ portalRouter.put('/portal/admin/execution-log-retention', (c) =>
|
||||
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 ownerId = knowledgeOwner(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 }),
|
||||
body: JSON.stringify({ owner_id: ownerField(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 };
|
||||
@@ -1470,10 +1779,10 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
|
||||
const confirm = String(body?.confirm ?? '').trim();
|
||||
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
|
||||
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ owner_id: ownerId, library: name }),
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }),
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
|
||||
const data = (await res.json()) as { deprecated_count?: number };
|
||||
@@ -1564,16 +1873,19 @@ export interface DiagnosticsCore {
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** tenant=owner_id(session 版傳 portalTenant(env);daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */
|
||||
export async function buildDiagnostics(env: Bindings, tenant: string): Promise<DiagnosticsCore> {
|
||||
/**
|
||||
* tenant=owner_id(session 版傳 `knowledgeOwner(env)`;daemon 版傳 `tenantFromApiKey(header)`)。
|
||||
* #108:型別收成 `TenantId`——診斷檔要是報了另一個命名空間的統計,等於用假數字排查真問題。
|
||||
*/
|
||||
export async function buildDiagnostics(env: Bindings, tenant: TenantId): 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()}`),
|
||||
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`),
|
||||
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`),
|
||||
]);
|
||||
const statusBody = (await statusRes.json().catch(() => null)) as
|
||||
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
|
||||
@@ -1609,7 +1921,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
||||
// - 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();
|
||||
const ownerParam = ownerQuery(tenant);
|
||||
try {
|
||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||
@@ -1643,7 +1955,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
||||
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 probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant), limit: '1' }).toString()}`);
|
||||
const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
|
||||
const total = probeBody?.total ?? 0;
|
||||
library_scope_check = {
|
||||
@@ -1690,7 +2002,8 @@ 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);
|
||||
// 這條路的租戶來自**請求本身**(小幫手帶的 namespace),不是環境變數 → 沒有 #108 的漂移問題。
|
||||
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
|
||||
return c.json({
|
||||
generated_at: new Date().toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
|
||||
@@ -75,6 +75,13 @@ export type Bindings = {
|
||||
* 未注入(本地 dev/舊實例)= undefined,/health 省略該欄。
|
||||
*/
|
||||
ARCRUN_BUNDLE_VERSION?: string;
|
||||
/**
|
||||
* Arcrun#106:這份成品實際來自哪個 commit(40 碼 sha)。
|
||||
* `ARCRUN_BUNDLE_VERSION` 是**發行頻道的編號**(semver,Portal/daemon 拿它比新舊),
|
||||
* 這個是**真的部了哪份碼**——兩個一起吐,標籤跟成品漂掉時查得出來。
|
||||
* 由 `acr init/update`(cli/src/lib/deploy.ts)注入;安裝器那條路沒有此 var → /health 省略該欄。
|
||||
*/
|
||||
ARCRUN_BUNDLE_COMMIT?: string;
|
||||
// Platform telemetry api_key(可選,wrangler secret)
|
||||
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
||||
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
||||
@@ -84,6 +91,20 @@ export type Bindings = {
|
||||
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
||||
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
||||
CONSOLE_TENANT?: string;
|
||||
/**
|
||||
* 這台實例的**知識命名空間**(Arcrun#108)=使用者 `~/.arcrun/config.yaml` 的 `api_key`。
|
||||
*
|
||||
* 由 `acr init/update`(cli/src/lib/deploy.ts CLI_MANAGED_VARS)自動注入,**使用者不必手動維護**:
|
||||
* 它就是 CLI push workflow(`{ns}:wf:*`)、小幫手上傳知識(`owner_id=ns`)、MCP Bearer
|
||||
* 用的同一個值 ⇒ 讀取端用它過濾,永遠對得上寫入端。
|
||||
*
|
||||
* 為什麼不沿用 `CONSOLE_TENANT`:那是 repo toml 帶的**官方 prod 值**(`leo`),
|
||||
* self-hosted 實例的資料根本不在它底下(#108 實撞:1854 條被過濾成 0),
|
||||
* 而且 `CONSOLE_TENANT` 同時還是帳號子 namespace(`{tenant}::portal`)的組成,
|
||||
* 改它會讓舊實例登不進去。兩件事拆成兩個 var,各自對應各自的真相源。
|
||||
* 解析邏輯只在 `src/lib/tenant.ts`(唯一產地,機械閘看守)。
|
||||
*/
|
||||
ARCRUN_NAMESPACE?: string;
|
||||
// Console 顯示品牌/實例名(Arcrun#21 rebrand,非機密)。只影響 UI 字樣(title/header/logo),
|
||||
// 不影響任何行為。未設 → "Arcrun"(console 是引擎共用件,不寫死產品名)。
|
||||
// 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。
|
||||
@@ -103,9 +124,8 @@ export type Bindings = {
|
||||
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
|
||||
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
|
||||
ARCRUN_BUNDLE_VERSION?: string;
|
||||
// (ARCRUN_BUNDLE_VERSION 原本在這裡重複宣告了一次——TS2300 重複識別字,
|
||||
// #106 順手併回上面那一處,說明同源,行為零變化。)
|
||||
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
||||
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
||||
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||
@@ -115,6 +135,19 @@ export type Bindings = {
|
||||
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
|
||||
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
|
||||
PORTAL_SESSION_TTL?: string;
|
||||
// Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它;
|
||||
// D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。
|
||||
UI_ORIGINS?: string;
|
||||
// ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)───────────────────────────
|
||||
// 中央代寄服務的 base URL(landing worker)。**用戶自己的實例沒有寄信能力**——安裝器
|
||||
// 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize,
|
||||
// **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。
|
||||
// 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。
|
||||
// ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。
|
||||
PORTAL_MAIL_RELAY_BASE?: string;
|
||||
// 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端
|
||||
// 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。
|
||||
PORTAL_MAIL_RELAY_KEY?: string;
|
||||
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
|
||||
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
|
||||
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Arcrun#100 — 「畫面上的 0,只准在真的是 0 的時候出現」
|
||||
*
|
||||
* 病灶(leo 實遇):總圖頁寫「0 個實體・0 條關聯/知識庫還沒有任何關聯——上傳文件後 AI 會
|
||||
* 自動織網」,而他庫裡有 1854 條三元組。那句話會叫他去做一件不需要做的事。
|
||||
*
|
||||
* 本檔釘住三件事:
|
||||
* ① kbdb-graph-plugin 的 `/triplets` 前綴掛 Bearer 閘,cypher 打它**一定要帶 token**
|
||||
* (console-dashboard 兩支 stats 原本漏帶 → 永遠 401)。
|
||||
* ② 三元組數量的真相源=KBDB `/records/triplet-stats`(真 SQL COUNT、依 owner 過濾),
|
||||
* **不是** plugin `/triplets/stats` 的 `total`——那是分頁長度(KBDB 端上限 100/500),
|
||||
* 1854 條的庫只會回 100。只修 401 不換來源=把「0」換成「100」,一樣是假的。
|
||||
* ③ 讀不到一律 null / 502 / empty_confirmed=false,**絕不退化成 0**。
|
||||
*
|
||||
* KBDB/graph-plugin 都打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test、
|
||||
* KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { graphHeaders, graphBase } from '../src/routes/kbdb-proxy';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const GRAPH = 'https://graph.test';
|
||||
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
/** KBDB `/records/triplet-stats` — 真 COUNT 的形狀:{ success, stats: [{library, triplet_count}] } */
|
||||
function mockTripletStats(rows: { library: string; triplet_count: number }[] | null, status = 200) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(status, rows === null ? { success: false, error: 'boom' } : { success: true, stats: rows });
|
||||
}
|
||||
|
||||
// ═══════════════ 1. graphHeaders:打 plugin 的 header 只有一份 ═══════════════
|
||||
|
||||
describe('graphHeaders(#100 漂移的根:三處手拼 → 一支函式)', () => {
|
||||
it('有 KBDB_INTERNAL_TOKEN → 帶 Bearer(plugin 的 /triplets /graph /search /entities 全靠它)', () => {
|
||||
expect(graphHeaders({ KBDB_INTERNAL_TOKEN: 'tok-abc' } as unknown as Bindings)).toEqual({
|
||||
Authorization: 'Bearer tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('沒設 token → 空 headers(plugin 未設 secret 時本來就開放,不硬塞空 Bearer)', () => {
|
||||
expect(graphHeaders({} as unknown as Bindings)).toEqual({});
|
||||
});
|
||||
|
||||
it('graphBase 仍照舊(KBDB_GRAPH_URL 優先、去尾斜線)', () => {
|
||||
expect(graphBase({ KBDB_GRAPH_URL: 'https://graph.test/' } as unknown as Bindings)).toBe('https://graph.test');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. /console/kb-scale-data:數字對得上庫裡真正的數量 ═══════════════
|
||||
|
||||
describe('GET /console/kb-scale-data — 三元組數=KBDB 真 COUNT', () => {
|
||||
it('庫裡 1854 條(跨三個庫)→ triplets_total 回 1854,不是 plugin 的分頁長度 100', async () => {
|
||||
mockTripletStats([
|
||||
{ library: 'general', triplet_count: 1200 },
|
||||
{ library: 'finance', triplet_count: 600 },
|
||||
{ library: 'ops', triplet_count: 54 },
|
||||
]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:triplet-stats 讀不到(500)→ triplets_total = null,**不是 0**', async () => {
|
||||
mockTripletStats(null, 500);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBeNull();
|
||||
expect(d.triplets_total).not.toBe(0); // 這一行就是 #100 的整個重點
|
||||
});
|
||||
|
||||
it('反向:回應形狀不對(stats 不是陣列)→ null,不半信半疑當 0', async () => {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: 'oops' });
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBeNull();
|
||||
});
|
||||
|
||||
it('真的是 0(庫存在但沒有任何三元組)→ 誠實回 0(0 只在這種時候出現)', async () => {
|
||||
mockTripletStats([]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(0);
|
||||
});
|
||||
|
||||
it('kb-scale-data 不再打 graph-plugin(沒有 plugin interceptor 也能拿到數字)', async () => {
|
||||
mockTripletStats([{ library: 'general', triplet_count: 7 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(7); // 打 GRAPH 的話 disableNetConnect 會讓它變 null
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3. /console/dashboard-data:燈號問 plugin、數字問 KBDB ═══════════════
|
||||
|
||||
describe('GET /console/dashboard-data — 圖服務健康 vs 三元組數量是兩件事', () => {
|
||||
it('打 plugin /triplets/stats **有帶 Bearer** → graph.ok=true;數量仍取 KBDB 真 COUNT', async () => {
|
||||
// headers matcher:漏帶 Authorization 就配不到這個 interceptor → 請求失敗 → graph.ok=false
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({
|
||||
path: (p: string) => p.startsWith('/triplets/stats'),
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` },
|
||||
})
|
||||
.reply(200, { total: 100 }); // plugin 的分頁長度,故意與真值不同
|
||||
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as {
|
||||
system: { graph: { ok: boolean; triplets: number | null } };
|
||||
kb: { triplets_total: number | null };
|
||||
};
|
||||
expect(d.system.graph.ok).toBe(true); // 帶了 token 才會是 true(#100 迴歸閘)
|
||||
expect(d.system.graph.triplets).toBe(1854); // 不是 plugin 的 100
|
||||
expect(d.kb.triplets_total).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:plugin 打不通 → graph.ok=false,但三元組數照樣是真的(不被服務狀態吞掉)', async () => {
|
||||
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
|
||||
expect(d.system.graph.ok).toBe(false);
|
||||
expect(d.system.graph.triplets).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:兩邊都讀不到 → ok=false + triplets=null(不是 0)', async () => {
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
|
||||
expect(d.system.graph.ok).toBe(false);
|
||||
expect(d.system.graph.triplets).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -26,4 +26,33 @@ describe('GET /health — bundle_version 欄位', () => {
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.bundle_version).toBe('2026-07-28/6d06162');
|
||||
});
|
||||
|
||||
// Arcrun#106:CLI 更新那條路會多烙一個 commit(版號=發行頻道編號,commit=真的部了哪份碼)。
|
||||
it('有 ARCRUN_BUNDLE_COMMIT 時一起回(acr update 注入情境)', async () => {
|
||||
const fakeEnv = {
|
||||
ARCRUN_BUNDLE_VERSION: '1.4.41',
|
||||
ARCRUN_BUNDLE_COMMIT: 'f87d0e92f49690253e7c89c5badc82a08eb5d21b',
|
||||
} as unknown as Bindings;
|
||||
const res = await healthRouter.fetch(
|
||||
new Request('http://localhost/health'),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
const data = await res.json() as { bundle_version: string; bundle_commit: string };
|
||||
expect(data.bundle_version).toBe('1.4.41');
|
||||
expect(data.bundle_commit).toBe('f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||
});
|
||||
|
||||
// 安裝器那條路沒有這個 var(回歸:不能因為多了新欄位就讓舊路徑多吐一個空字串出來)。
|
||||
it('沒 ARCRUN_BUNDLE_COMMIT 就省略該欄(安裝器路徑不受影響)', async () => {
|
||||
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '1.4.41' } as unknown as Bindings;
|
||||
const res = await healthRouter.fetch(
|
||||
new Request('http://localhost/health'),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
const data = await res.json() as { bundle_version: string; bundle_commit?: string };
|
||||
expect(data.bundle_version).toBe('1.4.41');
|
||||
expect(data.bundle_commit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* PATCH /kbdb/records/:recordId proxy 測試(2026-08-11,三元組 library 補標需求核實)
|
||||
*
|
||||
* 背景:基本盤 kbdb/src/routes/records.ts 早有 PATCH /records/:recordId(mira-dissolve T2.1,
|
||||
* updateRecord 已支援「補一個 record 原本沒有的 slot 值」的 idempotent grow)。但這條 cypher
|
||||
* proxy(kbdb-proxy.ts)之前只轉發 GET/POST /kbdb/records,沒開 PATCH——外部(工作流/CLI/
|
||||
* 任何走 X-Arcrun-API-Key 的呼叫者)打不到,等於基本盤能力在,通道沒開。
|
||||
*
|
||||
* 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-map-proxy.test.ts 慣例):
|
||||
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
|
||||
* 2. body 沒有 values → 400,不轉發
|
||||
* 3. 轉發:PATCH /kbdb/records/:id → base PATCH /records/:id,body 只帶 { values }
|
||||
* 4. base 404(record 不存在)→ 原樣透傳,不假裝成功
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——測試絕不外連。
|
||||
*/
|
||||
import { SELF, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 租戶閘', () => {
|
||||
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ values: { library: 'kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 參數驗證', () => {
|
||||
it('body 沒有 values → 400,不轉發', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 轉發', () => {
|
||||
it('轉發 base PATCH /records/:id,body 只帶 values(不夾帶其他欄位)', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records/rec_1',
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
record: { record_id: 'rec_1', template_id: 'tpl-triplet', values: { library: 'gitea:Leo/kb' } },
|
||||
});
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.record.values.library).toBe('gitea:Leo/kb');
|
||||
});
|
||||
|
||||
it('base 404(record 不存在)→ 原樣透傳,不假裝成功', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/records/nope', method: 'PATCH' })
|
||||
.reply(404, { success: false, error: 'not found' });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/nope', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ values: { library: 'kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Arcrun#108 — 藏書地圖看得到自己的知識(租戶字串來源收斂)。
|
||||
*
|
||||
* 釘住的事實:
|
||||
* 1. 資料面 owner_id 來自 `knowledgeOwner(env)`:`ARCRUN_NAMESPACE` 優先、`CONSOLE_TENANT` 回退、
|
||||
* 兩者皆無 → 丟 TenantUnresolvedError(**沒有 `|| 'leo'` 這種靜默預設值**)。
|
||||
* 2. `/portal/data/map` 真的拿那個值去打 KBDB(leo 的情境:ARCRUN_NAMESPACE=bfezv28v
|
||||
* → 打 `owner_id=bfezv28v` 拿回 9 個庫,而不是打 `owner_id=leo` 拿回 0 個)。
|
||||
* 3. **權限沒有被拿掉**:同一份 KBDB 回應,庫權限 ["kb"] 的帳號只看得到 kb。
|
||||
* 4. 空地圖分得出四種成因(#100 那條「讀不到就說讀不到」延伸到藏書地圖):
|
||||
* no_library_grant / filtered_out / scope_mismatch / confirmed_empty。
|
||||
* 5. 回應**不含租戶字串**(design §3.3 紅線:前端拿到就能繞過庫過濾直打 /kbdb/*)。
|
||||
*/
|
||||
import { env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant';
|
||||
import { portalDataRouter } from '../src/routes/portal-data';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
|
||||
const LEO_NS = 'bfezv28v';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
/**
|
||||
* 直接餵 router 一份 env(不是 SELF.fetch)——`cloudflare:test` 的 `env` 物件改了不會傳進
|
||||
* SELF 那個 worker(實測:改 ARCRUN_BUNDLE_VERSION 後 /health 仍回舊值),
|
||||
* 而本票要驗的正是「換一個命名空間,查詢就跟著換」。Hono router 吃 env 參數,
|
||||
* 走的是同一支 handler、同一條 KBDB fetch,只有 env 這一項是測試給的。
|
||||
*/
|
||||
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext;
|
||||
|
||||
async function seedSession(token: string, recordId: string) {
|
||||
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||||
}
|
||||
|
||||
function mockGetRecord(recordId: string, libraries: string) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: `/records/${recordId}`, method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
record: {
|
||||
record_id: recordId,
|
||||
template_id: 'tpl_pu',
|
||||
values: {
|
||||
email: 'leo@example.com',
|
||||
display_name: 'leo',
|
||||
status: 'active',
|
||||
role: 'admin',
|
||||
password_hash: 'pbkdf2-sha256$600000$AA$BB',
|
||||
libraries,
|
||||
created_at: '2026-08-12T00:00:00.000Z',
|
||||
updated_at: '2026-08-12T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 攔 `/map`,同時把「實際被查詢的 owner_id」記下來給斷言用。 */
|
||||
function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) => {
|
||||
if (!p.startsWith('/map')) return false;
|
||||
seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? '');
|
||||
return true;
|
||||
},
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, libraries, count: libraries.length });
|
||||
}
|
||||
|
||||
function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) =>
|
||||
p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''),
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] });
|
||||
}
|
||||
|
||||
async function getMap(token: string, overrides: Partial<Bindings> = {}) {
|
||||
const res = await portalDataRouter.fetch(
|
||||
new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }),
|
||||
{ ...env, ...overrides } as Bindings,
|
||||
ctx,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** undici 的 path matcher 可能被呼叫多次 → 比對前先去重(我們在意的是「查了哪些 owner_id」)。 */
|
||||
const distinct = (xs: string[]): string[] => [...new Set(xs)];
|
||||
|
||||
// ── ① 唯一產地的解析順序 ───────────────────────────────────────────────────────
|
||||
|
||||
describe('knowledgeOwner:租戶字串只有一個產地,且沒有靜默預設值', () => {
|
||||
it('ARCRUN_NAMESPACE 優先(=acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值)', () => {
|
||||
expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS);
|
||||
});
|
||||
|
||||
it('沒注入 → 回退 CONSOLE_TENANT(官方 prod 與尚未 acr update 的實例,行為一字不變)', () => {
|
||||
expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
|
||||
});
|
||||
|
||||
it('空字串不算數(部署把 var 設成空字串 ≠ 有設定)', () => {
|
||||
expect(knowledgeOwner({ ARCRUN_NAMESPACE: ' ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
|
||||
});
|
||||
|
||||
it('兩個都沒有 → 丟 TenantUnresolvedError,**不回 "leo"**(靜默預設值正是本票的病)', () => {
|
||||
expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError);
|
||||
});
|
||||
|
||||
it('帳號層 accountTenant 不受影響(改它會讓舊實例登不進去,所以刻意不動)', () => {
|
||||
expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
|
||||
expect(accountTenant({} as Bindings)).toBe('leo');
|
||||
});
|
||||
|
||||
it('過濾片段只有兩種形狀:帶租戶的 ownerQuery,與明著喊全庫的普查', () => {
|
||||
expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b');
|
||||
expect(censusQueryAllTenants()).toBe('owner_id=');
|
||||
});
|
||||
});
|
||||
|
||||
// ── ② 地圖真的用那個 owner_id 去查 ─────────────────────────────────────────────
|
||||
|
||||
describe('GET /portal/data/map — leo 的情境(1854 條 → 看得到,不是 0 個庫)', () => {
|
||||
it('注入 ARCRUN_NAMESPACE 後,KBDB 收到的 owner_id 是它,而且庫都回得來', async () => {
|
||||
await seedSession('t-map-1', 'rec_leo');
|
||||
mockGetRecord('rec_leo', '["*"]');
|
||||
const seen: string[] = [];
|
||||
mockMap(
|
||||
[
|
||||
{ library: 'kb', triplet_count: 1851 },
|
||||
{ library: 'general', triplet_count: 3 },
|
||||
],
|
||||
seen,
|
||||
);
|
||||
|
||||
const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS });
|
||||
expect(status).toBe(200);
|
||||
expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票:以前送出去的是 'leo'
|
||||
expect(body.count).toBe(2);
|
||||
expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count))
|
||||
.toEqual([1851, 3]);
|
||||
expect(body.empty_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('回應不含租戶字串(前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線)', async () => {
|
||||
await seedSession('t-map-2', 'rec_leo2');
|
||||
mockGetRecord('rec_leo2', '["*"]');
|
||||
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
|
||||
|
||||
const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS });
|
||||
expect(JSON.stringify(body)).not.toContain(LEO_NS);
|
||||
expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE');
|
||||
});
|
||||
|
||||
it('沒注入時沿用 CONSOLE_TENANT(未跑 acr update 的實例行為不變,這次改動對它是惰性的)', async () => {
|
||||
await seedSession('t-map-3', 'rec_leo3');
|
||||
mockGetRecord('rec_leo3', '["*"]');
|
||||
const seen: string[] = [];
|
||||
mockMap([{ library: 'kb', triplet_count: 1 }], seen);
|
||||
|
||||
await getMap('t-map-3');
|
||||
expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT
|
||||
});
|
||||
});
|
||||
|
||||
// ── ③ 權限沒有被拿掉(紅線:修這題不准把 owner_id 過濾或庫過濾拆掉)──────────────
|
||||
|
||||
describe('權限:只被授權部分庫的帳號,只看得到那幾個庫', () => {
|
||||
it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => {
|
||||
await seedSession('t-perm-1', 'rec_partial');
|
||||
mockGetRecord('rec_partial', '["kb"]');
|
||||
mockMap(
|
||||
[
|
||||
{ library: 'kb', triplet_count: 1851 },
|
||||
{ library: 'finance', triplet_count: 42 },
|
||||
{ library: 'general', triplet_count: 3 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS });
|
||||
expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']);
|
||||
expect(body.count).toBe(1);
|
||||
});
|
||||
|
||||
it('一個庫都沒被授權 → 不打 KBDB,誠實說是權限問題', async () => {
|
||||
await seedSession('t-perm-2', 'rec_nolib');
|
||||
mockGetRecord('rec_nolib', '[]');
|
||||
const { body } = await getMap('t-perm-2'); // 沒有 mockMap:打了就會 assertNoPendingInterceptors 失敗
|
||||
expect(body.count).toBe(0);
|
||||
expect(body.empty_reason).toBe('no_library_grant');
|
||||
expect(body.empty_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('實例有庫但都不在權限內 → filtered_out(是隔離正常,不是資料不見)', async () => {
|
||||
await seedSession('t-perm-3', 'rec_other');
|
||||
mockGetRecord('rec_other', '["finance"]');
|
||||
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
|
||||
|
||||
const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS });
|
||||
expect(body.empty_reason).toBe('filtered_out');
|
||||
expect(body.empty_confirmed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ④ 空地圖的四種成因分得出來(不再把設定錯誤畫成「你沒有資料」)────────────────
|
||||
|
||||
describe('空地圖:分得出「讀不到」與「沒有」', () => {
|
||||
it('命名空間對不上(本租戶 0、整台實例有)→ scope_mismatch,並指出該跑 acr update', async () => {
|
||||
await seedSession('t-empty-1', 'rec_e1');
|
||||
mockGetRecord('rec_e1', '["*"]');
|
||||
mockMap([], []);
|
||||
mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0
|
||||
mockTripletStats((o) => o === '', 1854); // 全庫普查:有 1854 條
|
||||
|
||||
const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' });
|
||||
expect(body.empty_reason).toBe('scope_mismatch');
|
||||
expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」
|
||||
expect(body.instance_triplet_count).toBe(1854);
|
||||
expect(String(body.note)).toContain('acr update');
|
||||
expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串
|
||||
});
|
||||
|
||||
it('整台實例真的空 → confirmed_empty(此時、也只有此時,才准說「還沒有內容」)', async () => {
|
||||
await seedSession('t-empty-2', 'rec_e2');
|
||||
mockGetRecord('rec_e2', '["*"]');
|
||||
mockMap([], []);
|
||||
mockTripletStats((o) => o === 'leo', 0);
|
||||
mockTripletStats((o) => o === '', 0);
|
||||
|
||||
const { body } = await getMap('t-empty-2');
|
||||
expect(body.empty_reason).toBe('confirmed_empty');
|
||||
expect(body.empty_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('連統計都讀不到 → unreadable(不假裝是空庫)', async () => {
|
||||
await seedSession('t-empty-3', 'rec_e3');
|
||||
mockGetRecord('rec_e3', '["*"]');
|
||||
mockMap([], []);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(500, { error: 'boom' });
|
||||
|
||||
const { body } = await getMap('t-empty-3');
|
||||
expect(body.empty_reason).toBe('unreadable');
|
||||
expect(body.empty_confirmed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -582,3 +582,103 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
|
||||
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ D62 + arcrun-rag#66(2026-08-10)═══════════════
|
||||
//
|
||||
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
|
||||
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
|
||||
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
|
||||
|
||||
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
|
||||
const D62_EMAIL = 'd62-reset@example.com';
|
||||
|
||||
it('/portal/password/change 帶 reset_token:**不需要登入、不需要現有密碼**,且票用完即失效', async () => {
|
||||
// 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張),
|
||||
// 存的是 token 的 sha256——KV 裡看不到可用的連結。
|
||||
const { sha256Hex } = await import('../src/lib/portal-auth');
|
||||
const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||
const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
|
||||
await env.SESSIONS_KV.put(
|
||||
`portal_pwreset:${await sha256Hex(token)}`,
|
||||
JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// 票有效時,先「看一眼」不會消耗它
|
||||
const peek = await json('GET', `/portal/password/reset?token=${token}`);
|
||||
expect(peek.status).toBe(200);
|
||||
expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);
|
||||
|
||||
// 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回)
|
||||
const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
|
||||
expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的
|
||||
// 關鍵斷言:同一條連結**不能再用第二次**
|
||||
const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
|
||||
expect(again.status).toBe(400);
|
||||
expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
|
||||
// 而且票在 KV 裡真的沒了
|
||||
expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => {
|
||||
for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
|
||||
const res = await json('GET', `/portal/password/reset?token=${t}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json() as { valid: boolean }).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => {
|
||||
const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => {
|
||||
const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => {
|
||||
const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
|
||||
const TOKEN_A = 'sess-token-66-propagating';
|
||||
const TOKEN_B = 'sess-token-66-really-gone';
|
||||
const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;
|
||||
|
||||
it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => {
|
||||
await seedPortalSession(TOKEN_A, MISSING);
|
||||
// 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗
|
||||
await env.SESSIONS_KV.put(
|
||||
'auth_store_recent',
|
||||
JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
|
||||
);
|
||||
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
|
||||
// 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來
|
||||
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => {
|
||||
await seedPortalSession(TOKEN_B, MISSING);
|
||||
await env.SESSIONS_KV.delete('auth_store_recent');
|
||||
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
|
||||
expect(res.status).toBe(401);
|
||||
// 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收
|
||||
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => {
|
||||
await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
|
||||
//
|
||||
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
|
||||
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
|
||||
|
||||
describe('藏書地圖 /portal/data/map(MCP 走的那條)', () => {
|
||||
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
|
||||
await seedSession('tok-m1', 'rec_1');
|
||||
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
|
||||
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
|
||||
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
|
||||
expect(data.count).toBe(1);
|
||||
});
|
||||
|
||||
it('["*"] 全庫 → 全部庫都回', async () => {
|
||||
await seedSession('tok-m2', 'rec_2');
|
||||
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
|
||||
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
|
||||
const data = (await res.json()) as { libraries: { library: string }[] };
|
||||
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
|
||||
});
|
||||
|
||||
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
|
||||
await seedSession('tok-m3', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { count: number; note?: string };
|
||||
expect(data.count).toBe(0);
|
||||
expect(data.note).toContain('尚未被授權');
|
||||
});
|
||||
|
||||
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
|
||||
await seedSession('tok-m4', 'rec_4');
|
||||
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||
});
|
||||
|
||||
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
|
||||
await seedSession('tok-m5', 'rec_5');
|
||||
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
|
||||
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
|
||||
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('未登入 → 401', async () => {
|
||||
expect((await get('/portal/data/map')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('結構化資料 /portal/data/records、/portal/data/templates(MCP 走的那條)', () => {
|
||||
it('by-template:server 注入 owner_id;caller 自帶的被靜默覆蓋(繞不過)', async () => {
|
||||
await seedSession('tok-r1', 'rec_1');
|
||||
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
|
||||
let captured = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) => {
|
||||
if (!p.startsWith('/records/by-template/contact')) return false;
|
||||
captured = p;
|
||||
return true;
|
||||
},
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, records: [], count: 0 });
|
||||
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
|
||||
Authorization: 'Bearer tok-r1',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
|
||||
});
|
||||
|
||||
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
|
||||
await seedSession('tok-r2', 'rec_2');
|
||||
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
records: [
|
||||
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
|
||||
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
|
||||
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
|
||||
],
|
||||
count: 3,
|
||||
});
|
||||
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
|
||||
const data = (await res.json()) as { records: { record_id: string }[] };
|
||||
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
|
||||
});
|
||||
|
||||
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
|
||||
await seedSession('tok-r3', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_other', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
|
||||
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||
});
|
||||
|
||||
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
|
||||
await seedSession('tok-r4', 'rec_4');
|
||||
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_hr', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
|
||||
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
|
||||
|
||||
await seedSession('tok-r5', 'rec_5');
|
||||
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_fin', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
|
||||
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
|
||||
await seedSession('tok-r6', 'rec_6');
|
||||
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
|
||||
let body: Record<string, unknown> = {};
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
body: (b: string) => {
|
||||
body = JSON.parse(b) as Record<string, unknown>;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.reply(200, { success: true, record: { record_id: 'r_new' } });
|
||||
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.owner_id).toBe(TENANT);
|
||||
});
|
||||
|
||||
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
|
||||
await seedSession('tok-r7', 'rec_7');
|
||||
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
|
||||
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('templates 全域共享(schema 非內容):登入即可列', async () => {
|
||||
await seedSession('tok-t1', 'rec_t1');
|
||||
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/templates', method: 'GET' })
|
||||
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
|
||||
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { count: number }).count).toBe(1);
|
||||
});
|
||||
|
||||
it('未登入 → 401(records / templates 都是)', async () => {
|
||||
expect((await get('/portal/data/templates')).status).toBe(401);
|
||||
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
|
||||
expect((await get('/portal/data/records/r1')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
|
||||
|
||||
describe('GET /portal/data/graph/neighbors/:name(D-4 粗閘)', () => {
|
||||
@@ -942,3 +1150,91 @@ describe('GET /portal/daemon/diagnostics(t213 daemon 版)', () => {
|
||||
expect(JSON.stringify(body.notes)).not.toContain('截圖');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══ Arcrun#100: 總圖的「0」只准在真的是 0 的時候出現 ═══
|
||||
|
||||
describe('GET /portal/data/graph/overview(#100 空圖三態)', () => {
|
||||
/** KBDB `/records/triplet-stats`:帶 owner 與不帶 owner 是兩條不同路徑,分別攔。 */
|
||||
function mockCount(scoped: number | null, global?: number | null) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith(`/records/triplet-stats?owner_id=${TENANT}`), method: 'GET' })
|
||||
.reply(scoped === null ? 500 : 200, scoped === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: scoped }] });
|
||||
if (global !== undefined) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p === '/records/triplet-stats?owner_id=', method: 'GET' })
|
||||
.reply(global === null ? 500 : 200, global === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: global }] });
|
||||
}
|
||||
}
|
||||
function mockTriplets(body: object, status = 200) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(status, body);
|
||||
}
|
||||
async function overview(token: string) {
|
||||
await seedSession(token, `rec_${token}`);
|
||||
mockGetRecord(`rec_${token}`, userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
return get('/portal/data/graph/overview', { Authorization: `Bearer ${token}` });
|
||||
}
|
||||
|
||||
it('有資料 → 照常回圖,並附上全庫真實條數', async () => {
|
||||
mockTriplets({ success: true, records: [{ values: { subject: 'A', predicate: '連到', object: 'B' } }] });
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov1');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { node_count: number; triplets_total: number; empty_confirmed: boolean };
|
||||
expect(d.node_count).toBe(2);
|
||||
expect(d.triplets_total).toBe(1854);
|
||||
expect(d.empty_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('真的空(本租戶 0、全庫也 0)→ empty_confirmed=true,畫面才准印 0', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(0, 0);
|
||||
const res = await overview('tok-ov2');
|
||||
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.node_count).toBe(0);
|
||||
expect(d.empty_confirmed).toBe(true);
|
||||
expect(d.empty_reason).toBe('confirmed_empty');
|
||||
});
|
||||
|
||||
it('🔴 反向:本租戶查到 0、全庫卻有 1854(t161 owner_id 對不上)→ 不准說空,回 scope_mismatch', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(0, 1854);
|
||||
const res = await overview('tok-ov3');
|
||||
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
expect(d.empty_reason).toBe('scope_mismatch');
|
||||
});
|
||||
|
||||
it('🔴 反向:條數讀不到 → unreadable(不是 confirmed_empty,畫面顯示「讀不到」)', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(null);
|
||||
const res = await overview('tok-ov4');
|
||||
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string; triplets_total: number | null };
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
expect(d.empty_reason).toBe('unreadable');
|
||||
expect(d.triplets_total).toBeNull();
|
||||
});
|
||||
|
||||
it('🔴 反向:有條數卻一條邊都抽不出來 → scope_mismatch,不是空庫', async () => {
|
||||
mockTriplets({ success: true, records: [{ values: { subject: '', object: '' } }] });
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov5');
|
||||
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.node_count).toBe(0);
|
||||
expect(d.empty_reason).toBe('scope_mismatch');
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it('🔴 反向:KBDB 回應形狀不對(沒有 records 陣列)→ 502,不再回一張空圖', async () => {
|
||||
mockTriplets({ success: true, items: [] }); // 欄位名不對=讀不出來
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov6');
|
||||
expect(res.status).toBe(502);
|
||||
const d = (await res.json()) as { error: string };
|
||||
expect(d.error).toContain('三元組讀取失敗');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Arcrun#88:「零件目錄會說『這顆零件不存在』,但同一台實例上那顆零件跑得動」
|
||||
*
|
||||
* 病史(leo21c 實例實測,2026-08-11):
|
||||
* `/cypher/search` 對 `if_control`/`http_request` 回 `not_found`,
|
||||
* 但兩者其實由 component-loader.ts 直接解析(LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS),
|
||||
* 從不查 registry;registry catalog 端點在該實例回 404(`GET /components/catalog` →
|
||||
* `{"success":false,"error":"零件 catalog 不存在"}`),search 因此誤判成「兩庫都查過沒有」。
|
||||
*
|
||||
* 本測試複現病史的環境條件(wrangler.test.toml 未設 WORKER_SUBDOMAIN → registryBase
|
||||
* undefined → catalog.status='unreachable',等價於「registry 完全連不到」),
|
||||
* 驗證修法:RUNTIME_NATIVE_COMPONENT_IDS 的成員必須在 registry 查詢**之前**就短路成 found,
|
||||
* 不受 registry 健康狀態影響——因為它們的存在性從不依賴 registry。
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { parseTriplets } from '../src/actions/triplet-parser';
|
||||
import { searchNodes } from '../src/actions/search-nodes';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const IF_CONTROL_TRIPLETS = [
|
||||
'input >> ON_SUCCESS >> if_control',
|
||||
];
|
||||
|
||||
const HTTP_REQUEST_TRIPLETS = [
|
||||
'input >> ON_SUCCESS >> http_request',
|
||||
];
|
||||
|
||||
const MULTI_BUILTIN_TRIPLETS = [
|
||||
'input >> ON_SUCCESS >> switch',
|
||||
'input >> ON_SUCCESS >> filter',
|
||||
'input >> ON_SUCCESS >> code',
|
||||
];
|
||||
|
||||
const FAKE_COMPONENT_TRIPLETS = [
|
||||
'input >> ON_SUCCESS >> totally_made_up_component_xyz',
|
||||
];
|
||||
|
||||
describe('Arcrun#88:執行期原生零件不受 registry 健康狀態影響', () => {
|
||||
it('if_control(LOGIC_BINDING_MAP 成員)在 registry 不可達時仍回 found', async () => {
|
||||
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||
expect(parsed).not.toBeNull();
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
|
||||
// 無 WORKER_SUBDOMAIN/REGISTRY_BASE_URL → registryBase undefined → catalog unreachable
|
||||
});
|
||||
expect(nodeResults.if_control.status).toBe('found');
|
||||
expect(nodeResults.if_control.source).toBe('builtin');
|
||||
// if_control 會分岔,branch_hint 應隨 found 一併附上(不必逐顆再查一次)
|
||||
expect(nodeResults.if_control.branch_hint?.edge_types).toEqual(['ON_TRUE', 'ON_FALSE']);
|
||||
expect(missingNodes).not.toContain('if_control');
|
||||
});
|
||||
|
||||
it('http_request(WASM_HTTP_RUNNER_IDS 成員)在 registry 不可達時仍回 found', async () => {
|
||||
const parsed = parseTriplets(HTTP_REQUEST_TRIPLETS);
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {});
|
||||
expect(nodeResults.http_request.status).toBe('found');
|
||||
expect(nodeResults.http_request.source).toBe('builtin');
|
||||
expect(missingNodes).not.toContain('http_request');
|
||||
});
|
||||
|
||||
it('switch/filter/code(同一批白名單的其他成員)也回 found,不逐一漏網', async () => {
|
||||
const parsed = parseTriplets(MULTI_BUILTIN_TRIPLETS);
|
||||
const { nodeResults } = await searchNodes(parsed!, undefined, {});
|
||||
expect(nodeResults.switch.status).toBe('found');
|
||||
expect(nodeResults.filter.status).toBe('found');
|
||||
expect(nodeResults.code.status).toBe('found');
|
||||
});
|
||||
|
||||
it('registry 完全連不到時,真正不存在的名字誠實回 unknown(不敢空口說沒有——既有行為,修法沒有動它)', async () => {
|
||||
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
|
||||
const { nodeResults } = await searchNodes(parsed!, undefined, {});
|
||||
expect(nodeResults.totally_made_up_component_xyz.status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('registry 查得到但目錄是空的(複現 leo21c 實例 catalog 404 的真實症狀):真正不存在的名字回 not_found', async () => {
|
||||
// 複現生產實測:GET /components/catalog → HTTP 200 空陣列(本測試模擬「registry 活著但沒東西」,
|
||||
// 與 leo21c 實例的「404 零件 catalog 不存在」殊途同歸——都會落到「查得到、目錄無此零件」)。
|
||||
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
));
|
||||
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
|
||||
WORKER_SUBDOMAIN: 'test-sub',
|
||||
});
|
||||
expect(nodeResults.totally_made_up_component_xyz.status).toBe('not_found');
|
||||
expect(missingNodes).toContain('totally_made_up_component_xyz');
|
||||
});
|
||||
|
||||
it('registry 目錄是空的(catalog 通但無資料)時,執行期原生零件依然 found——這才是 Arcrun#88 的核心場景', async () => {
|
||||
// 這就是 leo21c 實例的真實狀態:registry 活著、目錄卻沒有任何一顆執行期原生零件的記錄
|
||||
// (SUBMISSIONS_KV 從未收到 if_control/http_request 的 submit)。若沒有本次修法,
|
||||
// 這裡會落到「兩庫都查過沒有」→ not_found,正是 Arcrun#88 回報的病徵。
|
||||
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
));
|
||||
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||
const { nodeResults } = await searchNodes(parsed!, undefined, { WORKER_SUBDOMAIN: 'test-sub' });
|
||||
expect(nodeResults.if_control.status).toBe('found');
|
||||
expect(nodeResults.if_control.source).toBe('builtin');
|
||||
});
|
||||
|
||||
it('target=recipe 明確只查 recipe 庫時,執行期原生零件不搶答 found(尊重使用者明確限庫)', async () => {
|
||||
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||
const { nodeResults } = await searchNodes(parsed!, undefined, {}, 'discover', 'recipe');
|
||||
// if_control 從來不是 recipe,target=recipe 下不該被 builtin 短路成 found
|
||||
expect(nodeResults.if_control.status).not.toBe('found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 「靜態租戶字串不得用於資料面過濾」這道閘**自己**的測試(Arcrun#108)。
|
||||
*
|
||||
* 收工標準明列三條,這裡逐條釘:
|
||||
* ① 會擋,不是只提醒 → 壞例子必須產出違規(CLI 端據此 exit 1、hook 據此 exit 2)
|
||||
* ② 判準看「有沒有在做那件事」 → 一整組「長得像但沒在做」的合法寫法必須零誤攔
|
||||
* ③ 閘自己要能被測試 → 規則是純函式,這裡直接餵字串;不需要跑檔案系統、也擋不到自己
|
||||
*
|
||||
* 外加一條回歸:現行 src/ 必須是乾淨的(`?raw` 讀真原始碼,不是讀我編的假字串)。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// @ts-expect-error -- 純規則模組(.mjs,零 node 相依),型別非本檔關注重點
|
||||
import { scanSource, TENANT_SOURCE_FILE } from '../scripts/tenant-source-rules.mjs';
|
||||
// @ts-expect-error -- vite ?raw:build-time 讀檔,runtime 是純字串(Workers 沒有 node:fs)
|
||||
import tenantLibSource from '../src/lib/tenant.ts?raw';
|
||||
// @ts-expect-error -- 同上
|
||||
import portalDataSource from '../src/routes/portal-data.ts?raw';
|
||||
// @ts-expect-error -- 同上
|
||||
import portalSource from '../src/routes/portal.ts?raw';
|
||||
// @ts-expect-error -- 同上
|
||||
import consoleAuthSource from '../src/routes/console-auth.ts?raw';
|
||||
// @ts-expect-error -- 同上
|
||||
import consoleDashboardSource from '../src/routes/console-dashboard.ts?raw';
|
||||
|
||||
type Violation = { rule: string; line: number; text: string; message: string };
|
||||
const scan = (path: string, text: string): Violation[] => scanSource(path, text) as Violation[];
|
||||
const rulesOf = (v: Violation[]): string[] => [...new Set(v.map((x) => x.rule))].sort();
|
||||
|
||||
const FILE = 'src/routes/portal-data.ts';
|
||||
|
||||
describe('閘會擋:三種「靜態租戶字串進資料面」的真實形狀', () => {
|
||||
it('T1 — 在 tenant.ts 以外讀租戶環境變數(#105/#108 的原句)', () => {
|
||||
const bad = `export function portalTenant(env: Bindings): string {\n return env.CONSOLE_TENANT || 'leo';\n}`;
|
||||
const v = scan('src/routes/portal.ts', bad);
|
||||
expect(rulesOf(v)).toContain('T1');
|
||||
expect(v[0].message).toContain('knowledgeOwner');
|
||||
});
|
||||
|
||||
it('T1 — `c.env.ARCRUN_NAMESPACE` 也一樣(換一個變數名不是換一個做法)', () => {
|
||||
const v = scan('src/routes/console-dashboard.ts', `const t = c.env.ARCRUN_NAMESPACE || 'leo';`);
|
||||
expect(rulesOf(v)).toEqual(['T1']);
|
||||
});
|
||||
|
||||
it('T2 — 繞過唯一產地自己 cast 一個 TenantId', () => {
|
||||
const v = scan(FILE, `const tenant = (c.env.SOMETHING ?? '') as TenantId;`);
|
||||
expect(rulesOf(v)).toContain('T2');
|
||||
});
|
||||
|
||||
it('T2 — 連在 tenant.ts 裡都不准把「字面字串」當成租戶識別(那就是 `|| "leo"` 的原形)', () => {
|
||||
const v = scan(TENANT_SOURCE_FILE, ` return 'leo' as TenantId;`);
|
||||
expect(rulesOf(v)).toEqual(['T2']);
|
||||
expect(v[0].message).toContain('TenantUnresolvedError');
|
||||
});
|
||||
|
||||
it('T3 — 拿帳號層字串去組知識資料面的 owner_id(#108 那一行,逐字)', () => {
|
||||
const bad = " const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);";
|
||||
const v = scan(FILE, bad);
|
||||
expect(rulesOf(v)).toContain('T3');
|
||||
expect(v[0].message).toContain('1854');
|
||||
});
|
||||
|
||||
it('T3 — 換成物件屬性寫法一樣擋(`owner_id: portalTenant(c.env)`)', () => {
|
||||
const v = scan(FILE, ` body: JSON.stringify({ values, owner_id: portalTenant(c.env) }),`);
|
||||
expect(rulesOf(v)).toContain('T3');
|
||||
});
|
||||
|
||||
it('T3 — accountTenant() 這個新名字也擋(規則盯的是「這是帳號層的值」,不是某個函式名字的拼法)', () => {
|
||||
const v = scan(FILE, " kbdbFetch(env, `/entries?owner_id=${accountTenant(env)}`);");
|
||||
expect(rulesOf(v)).toContain('T3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('閘不誤攔:長得像、但沒有在做那件事的合法寫法', () => {
|
||||
const legit: [string, string, string][] = [
|
||||
['讀取別人回傳的 owner_id(不是在組過濾)', FILE, ` if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);`],
|
||||
['型別宣告裡的 owner_id 欄位', FILE, ` | { record?: { values?: Record<string, unknown>; owner_id?: string | null } }`],
|
||||
['走唯一入口組過濾', FILE, " const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);"],
|
||||
['走唯一入口填 body', FILE, ` body: JSON.stringify({ template, values, owner_id: ownerField(tenant) }),`],
|
||||
['帳號子 namespace 的過濾(`{tenant}::portal`,那是 cypher 自己寫的資料)', 'src/routes/portal.ts', ` const res = await kbdbFetch(env, \`/records/by-template/x?owner_id=\${encodeURIComponent(ns)}\`);`],
|
||||
['請求自帶的租戶(webhooks-named 慣例:呼叫端就是租戶)', 'src/routes/webhooks-named.ts', ` owner_id: apiKey,`],
|
||||
['註解裡整句在講 CONSOLE_TENANT 與 owner_id(文件不是行為)', FILE, `// 之前的病:owner_id 拿 env.CONSOLE_TENANT,portalTenant(c.env) 那條路整個空掉`],
|
||||
['JSDoc 區塊裡出現同樣的字', FILE, ` * 舊寫法 owner_id=\${portalTenant(env)} 已廢除,改走 knowledgeOwner。`],
|
||||
['行末註解裡出現(程式碼本身乾淨)', FILE, ` const tenant = knowledgeOwner(c.env); // 不是 portalTenant(c.env),也不是 owner_id=leo`],
|
||||
['tenant.ts 自己讀環境變數(它就是唯一產地)', TENANT_SOURCE_FILE, ` const injected = (env.ARCRUN_NAMESPACE ?? '').trim();`],
|
||||
['types.ts 只宣告型別不取值', 'src/types.ts', ` CONSOLE_TENANT?: string;`],
|
||||
];
|
||||
|
||||
for (const [name, path, line] of legit) {
|
||||
it(`零誤攔:${name}`, () => {
|
||||
expect(scan(path, line)).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('回歸:現行原始碼是乾淨的(讀真檔,不是讀我編的字串)', () => {
|
||||
const files: [string, string][] = [
|
||||
[TENANT_SOURCE_FILE, tenantLibSource as string],
|
||||
['src/routes/portal-data.ts', portalDataSource as string],
|
||||
['src/routes/portal.ts', portalSource as string],
|
||||
['src/routes/console-auth.ts', consoleAuthSource as string],
|
||||
['src/routes/console-dashboard.ts', consoleDashboardSource as string],
|
||||
];
|
||||
for (const [path, text] of files) {
|
||||
it(`${path} 零違規`, () => {
|
||||
expect(scan(path, text)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it('唯一產地本身沒有字面預設值(knowledgeOwner 解析不到要用丟的,不是回 "leo")', () => {
|
||||
const body = (tenantLibSource as string).slice(
|
||||
(tenantLibSource as string).indexOf('export function knowledgeOwner'),
|
||||
(tenantLibSource as string).indexOf('export function tenantFromApiKey'),
|
||||
);
|
||||
expect(body).toContain('TenantUnresolvedError');
|
||||
// 解析路徑只准回 env 讀到的值;任何 `|| '...'` / `?? '...'` 形式的字面 fallback 都是本票的病本身
|
||||
expect(body).not.toMatch(/(\|\||\?\?)\s*['"][^'"]+['"]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* wait:等待不該吃運算額度(Arcrun#101)
|
||||
*
|
||||
* 病灶(leo 2026-08-12 於 youlin stage 實測,只有 input >> wait 兩個節點):
|
||||
* ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
* 四個值同一種死法、與 ms 完全無關。若「等 N 秒=燒 N 秒 CPU」,ms=3000 只會花 3 秒
|
||||
* 就結束、根本不該死 —— 所以真正的病不是「等待很貴」,是「等待永遠不會結束」。
|
||||
*
|
||||
* 機制:wait 是 TinyGo WASM,time.Sleep 走 WASI poll_oneoff;component worker 的
|
||||
* WASI shim 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get
|
||||
* 自旋;而 Workers 的時鐘在無 I/O 的同步執行期間凍結 ⇒ 迴圈的結束條件永遠不成立。
|
||||
*
|
||||
* 本檔驗四件事:
|
||||
* A. 反向驗證(機制):在真的 workerd 裡,輪詢時鐘的同步自旋迴圈確實永不前進。
|
||||
* B. 修法本體:wait 走引擎的 timer ⇒ 真的讓出執行緒(不佔請求執行緒)。
|
||||
* C. 契約沒變:既有 workflow 的 wait 節點定義不用改就能照樣跑。
|
||||
* D. 路由:wait 由 step 1 內建命中,不再打 arcrun-wait worker(不發任何 fetch)。
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { env } from 'cloudflare:test';
|
||||
import { BUILTIN_COMPONENTS, WAIT_MAX_MS } from '../src/lib/constants';
|
||||
import { createComponentLoader } from '../src/lib/component-loader';
|
||||
import type { Bindings, ComponentRunner } from '../src/types';
|
||||
|
||||
const wait = BUILTIN_COMPONENTS.get('wait') as ComponentRunner;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── A. 反向驗證:舊路徑為什麼不可能便宜地等 ──────────────────────────────────
|
||||
//
|
||||
// 直接跑那顆 component.wasm 沒辦法寫成安全的測試 —— 它會把 isolate 卡到 CPU 上限,
|
||||
// 測試無從中止(那正是 bug 本身)。所以這裡驗的是「**沙箱裡根本沒有睡覺這個手段**」。
|
||||
//
|
||||
// 🔴 這裡本來有一條斷言「Workers 的時鐘在同步執行期間凍結,所以自旋迴圈的結束條件
|
||||
// 永遠不成立」。**實跑打臉了**:在 vitest-pool-workers 的 workerd 裡,2553 圈之後
|
||||
// Date.now() 就前進了。⇒ 那條斷言被刪掉,不是改鬆——它從一開始就不是證據。
|
||||
//
|
||||
// 保留下來的是**查證得動的那一半**:WASI shim 把 poll_oneoff 實作成 ENOSYS(76),
|
||||
// TinyGo 的 time.Sleep 只有這一條路可走 ⇒ 拿不到「睡到某個時刻」的手段,
|
||||
// 只能退化成自旋。至於「自旋為什麼會拖到 35 秒才死」的完整機制**仍是推測**,
|
||||
// 證據是 leo 在 youlin stage 的四次實測(見檔頭),不是本檔任何一條斷言。
|
||||
//
|
||||
// ⇒ 而修法不依賴那個推測:純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)
|
||||
// 本來就沒有「不花 CPU 地等」這種東西,會等的只有宿主。無論卡死的細節是什麼,
|
||||
// 等待都該搬回引擎。
|
||||
// 「poll_oneoff 是 ENOSYS」這件事查原始碼即可(`wasi-shim.ts:319` 的
|
||||
// `poll_oneoff: () => WASI_ENOSYS`,以及 13 個 `.component-builds/*/src/index.ts`
|
||||
// 的 `poll_oneoff: () => 76`)。**沒有為它硬寫一條測試**——寫得出來的只會是
|
||||
// 「把字串抓出來比對」,那驗的是抓字串,不是行為。事實放註解,斷言留給真的驗行為的 B/C/D。
|
||||
describe('A. 反向驗證:WASI 沙箱裡沒有「睡覺」這個手段', () => {
|
||||
it('對照組:await 一個 timer 之後時鐘才會前進(=為什麼修法必須在引擎側 await)', async () => {
|
||||
const t0 = Date.now();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
expect(Date.now()).toBeGreaterThan(t0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── B. 修法本體:等待是 timer,不是佔用執行緒 ────────────────────────────────
|
||||
describe('B. 引擎側的 wait 真的讓出執行緒(等 30 秒與等 3 秒同價)', () => {
|
||||
it('5 個 300ms 的 wait 併發跑完 ≈ 300ms 而非 1500ms(會 blocking 的實作做不到這件事)', async () => {
|
||||
const started = Date.now();
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () => wait({ ms: 300 })),
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
for (const r of results) {
|
||||
expect(r).toEqual({ success: true, data: { waited_ms: 300 } });
|
||||
}
|
||||
// 序列化(blocking)會是 ~1500ms;讓出執行緒則 5 個計時器同時走完 ≈ 300ms。
|
||||
// 抓 900ms 當門檻:離 300 夠鬆、離 1500 夠遠。
|
||||
expect(elapsed).toBeLessThan(900);
|
||||
expect(elapsed).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
|
||||
it('等待期間 event loop 沒被佔住:同時排的 timer 照樣先到', async () => {
|
||||
const order: string[] = [];
|
||||
const waited = Promise.resolve(wait({ ms: 400 })).then(() => { order.push('wait-400'); });
|
||||
const ticked = new Promise<void>((r) => setTimeout(r, 50)).then(() => { order.push('tick-50'); });
|
||||
|
||||
await Promise.all([waited, ticked]);
|
||||
expect(order).toEqual(['tick-50', 'wait-400']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── C. 契約沒變:既有 wait 節點定義不用改 ────────────────────────────────────
|
||||
//
|
||||
// 逐條對 registry/components/wait/component.contract.yaml 的 gherkin_tests。
|
||||
describe('C. I/O 契約與 WASM 版一致(既有 workflow 不必改定義)', () => {
|
||||
it('contract gherkin:等待 100ms → waited_ms:100', async () => {
|
||||
expect(await wait({ ms: 100 })).toEqual({ success: true, data: { waited_ms: 100 } });
|
||||
});
|
||||
|
||||
it('contract gherkin:ms 為 0 時失敗(不是靜靜跳過)', async () => {
|
||||
expect(await wait({ ms: 0 })).toEqual({ success: false, error: 'ms 必須大於 0' });
|
||||
});
|
||||
|
||||
it('ms 缺漏 / 負數 / 非數字,一律誠實回 success:false,不假裝等過', async () => {
|
||||
for (const bad of [undefined, null, -1, 'abc', {}, []]) {
|
||||
expect(await wait({ ms: bad })).toEqual({ success: false, error: 'ms 必須大於 0' });
|
||||
}
|
||||
});
|
||||
|
||||
it('contract gherkin:ms=99999 截斷為上限 30000(不是報錯、也不是真的等 99 秒)', async () => {
|
||||
// 不真的等 30 秒:換掉 setTimeout,攔下引擎「要求等多久」再立刻放行。
|
||||
const asked: number[] = [];
|
||||
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
|
||||
asked.push(Number(delay));
|
||||
fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout);
|
||||
|
||||
expect(await wait({ ms: 99999 })).toEqual({ success: true, data: { waited_ms: WAIT_MAX_MS } });
|
||||
expect(asked).toEqual([WAIT_MAX_MS]);
|
||||
expect(WAIT_MAX_MS).toBe(30000); // 紅線:上限不准為了閃避資源限制被調小
|
||||
});
|
||||
|
||||
it('ms=30000 一路走到底也只是「排一個 30 秒的 timer」,沒有任何同步佔用', async () => {
|
||||
const asked: number[] = [];
|
||||
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
|
||||
asked.push(Number(delay));
|
||||
fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout);
|
||||
|
||||
expect(await wait({ ms: 30000 })).toEqual({ success: true, data: { waited_ms: 30000 } });
|
||||
expect(asked).toEqual([30000]);
|
||||
});
|
||||
|
||||
it('context 照契約透傳,並補上 waited_ms', async () => {
|
||||
const r = await wait({ ms: 5, context: { order_id: 'A-1', payload: { n: 2 } } });
|
||||
expect(r).toEqual({
|
||||
success: true,
|
||||
data: { order_id: 'A-1', payload: { n: 2 }, waited_ms: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('node.data 經 interpolateData 後 ms 會是字串 —— 收得下(WASM 版在這裡直接 unmarshal 失敗)', async () => {
|
||||
expect(await wait({ ms: '250' })).toEqual({ success: true, data: { waited_ms: 250 } });
|
||||
});
|
||||
});
|
||||
|
||||
// ── D. 路由:不再打 arcrun-wait worker ───────────────────────────────────────
|
||||
describe('D. component-loader 把 wait 解到內建 runner(step 1),不發任何 fetch', () => {
|
||||
it('loader("wait") 跑起來不會對外送出任何請求', async () => {
|
||||
const fakeEnv = { ...env, WORKER_SUBDOMAIN: 'test-sub' } as unknown as Bindings;
|
||||
const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const runner = await createComponentLoader(fakeEnv)('wait');
|
||||
const r = await runner({ ms: 10 });
|
||||
|
||||
expect(r).toEqual({ success: true, data: { waited_ms: 10 } });
|
||||
// 修法前這裡會打 arcrun-wait.test-sub.workers.dev(SVC_WAIT 未綁時的 fallback),
|
||||
// 那顆 worker 就是會燒到 1102 的那顆。
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wait 仍在「執行期真的解析得動」的清單裡(/cypher/search 查得到)', async () => {
|
||||
const { RUNTIME_NATIVE_COMPONENT_IDS } = await import('../src/lib/component-loader');
|
||||
expect(RUNTIME_NATIVE_COMPONENT_IDS.has('wait')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ KBDB_BASE_URL = "https://kbdb.test"
|
||||
CONSOLE_TENANT = "leo"
|
||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||
KBDB_GRAPH_URL = "https://graph.test"
|
||||
# Arcrun#100:kbdb-graph-plugin 對 /triplets /graph /search /entities 掛 Bearer 閘。測試環境要有
|
||||
# 這把(明顯的假字串、非真實金鑰)才驗得出「cypher 打 plugin 有沒有帶 token」——原本兩支
|
||||
# /triplets/stats 漏帶 → 永遠 401 → 前端「三元組 0」。真實部署仍走 wrangler secret put。
|
||||
KBDB_INTERNAL_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,同上方 CF_SECRETS_API_TOKEN 慣例
|
||||
# D61(ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
|
||||
# Scripts secrets 管理 API(https://api.cloudflare.com/...),authStoreWritable() 只看這兩項
|
||||
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
|
||||
|
||||
@@ -138,6 +138,19 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
|
||||
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
|
||||
CONSOLE_TENANT = "leo"
|
||||
|
||||
# 這台實例的**知識命名空間**(Arcrun#108)=知識資料(三元組/卡片/藏書地圖/工作流 KV)
|
||||
# 實際掛在哪個 owner_id 底下。**這裡刻意不寫死**:官方 prod 的知識確實在 `CONSOLE_TENANT`
|
||||
# (leo)底下,未設就沿用它,行為一字不變。
|
||||
#
|
||||
# self-hosted 實例由 `acr update` 自動注入(值=你 `~/.arcrun/config.yaml` 的 `api_key`,
|
||||
# 也就是 CLI push 工作流、小幫手上傳知識、MCP 查詢用的同一個 namespace),
|
||||
# 而且**只在確認那個 namespace 底下真的查得到知識時才寫**(見 cli/src/lib/deploy.ts
|
||||
# namespaceHasKnowledge)——避免把一台原本正常的實例指向空的那一格。
|
||||
#
|
||||
# 為什麼要跟 CONSOLE_TENANT 分開:CONSOLE_TENANT 同時是帳號子 namespace(`{tenant}::portal`)
|
||||
# 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。
|
||||
# ARCRUN_NAMESPACE = "your-namespace"
|
||||
|
||||
# Portal session TTL 秒數(portal-auth P2,#24/#25,routes/portal.ts)。預設 7 天(604800)——
|
||||
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
|
||||
PORTAL_SESSION_TTL = "604800"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- credential template seed(D38 圍牆修復,總管交辦,2026-08-07)
|
||||
-- SDD:無專屬 SDD(D38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
|
||||
--
|
||||
-- D38 鐵律(leo 2026-06-14 立、2026-08-07 擴大):KBDB 三張表打天下,永遠不加新表;
|
||||
-- 新資料類型一律用 template + entries,同 0003_library_map.sql / 0004_execution_log_template.sql
|
||||
-- 的手法——對 templates 表 INSERT OR IGNORE 一列定義,不建新表、不動既有表的結構。
|
||||
--
|
||||
-- 這是「credential 目錄」的第二個家:原本 0002_credentials.sql 在 KBDB 裡多開了一張
|
||||
-- 獨立表(違規,見 kbdb-usage skill「反例」),本檔 + 0006_drop_credentials_table.sql
|
||||
-- 把它改回三張表的形狀——一筆 credential=entries 表一列(entry_type='credential',
|
||||
-- page_name=name 當冪等鍵,owner_id=api_key 做租戶隔離,其餘欄位打包進 metadata_json),
|
||||
-- 儲存精神比照既有 recipe_stat / execution_log(template 只負責文件化,實際資料不走
|
||||
-- entry_values 全展開的多列 record)。
|
||||
--
|
||||
-- 密文本體不在這裡:值仍住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理
|
||||
-- API 唯寫,D19「擁有目錄,不擁有內容物」不變)。這張 template 定義的 slots 全部是目錄
|
||||
-- 欄位,零密文——與舊 0002_credentials.sql 的欄位定義一字不變,只是換了個家。
|
||||
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
|
||||
VALUES (
|
||||
'tpl-credential',
|
||||
'credential',
|
||||
'credential 目錄(D38 圍牆修復:改走 entries 表 entry_type=credential,取代舊 credentials 表;零密文,密文本體住 Workers per-script Secrets)',
|
||||
'["name","service","sensitivity","secret_ref","last_used_at"]',
|
||||
'system'
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 退役 credentials 表(D38 圍牆修復,總管交辦,2026-08-07)
|
||||
-- SDD:無專屬 SDD(D38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
|
||||
--
|
||||
-- 這是本次唯一真的需要動表結構的一支 migration,理由(不是繞過鐵律,是鐵律要求的收尾):
|
||||
-- D38 要求 KBDB 回到「只有三張核心表」的狀態。0002_credentials.sql 當初在 KBDB 裡多開了
|
||||
-- 一張獨立表,是已知違規(kbdb-usage skill 明文列為反例)。要把違規清乾淨,唯一辦法就是
|
||||
-- 真的把那張表拆掉——拆表本身不能只用 API 做(API 不提供「拆表」這種牆內維運操作,
|
||||
-- 也不該提供),所以下面兩句 SQL 標 kbdb-sql-ok:這不是繞過圍牆去存取資料,是圍牆施工
|
||||
-- 本身(kbdb/migrations/ 就是牆內,本檔存在的唯一目的就是讓舊表退場)。
|
||||
--
|
||||
-- 冪等設計(deploy.ts 每次部署都會重跑這支檔案,沒有 migration 追蹤表):
|
||||
-- 1. 先補一份空表存在保底——self-hosted 各實例套用進度不一,有些從沒跑過 0002(表從不
|
||||
-- 存在)、有些已經跑過本檔一次(表已被拆)。沒有這一步,下面的搬資料/退場語句會因表
|
||||
-- 不存在直接整支失敗(D1 對不存在的表沒有條件式跳過語法)。
|
||||
-- 2. 把舊表裡「entries 還沒有對應列」的 row 搬進 entries(entry_type='credential',
|
||||
-- page_name=name 冪等鍵,owner_id=api_key,其餘欄位打包進 metadata_json,欄位對應
|
||||
-- 0005_credential_template.sql 定義的 slots)。NOT EXISTS 判斷防止重跑造成重複列。
|
||||
-- 3. 搬完資料後表就沒有存在的理由,最後一步讓它退場。下次部署若又被步驟 1 重新墊一份
|
||||
-- 空殼,也只是空表、立刻搬 0 筆、立刻退場,不影響任何人(真資料只會被搬一次,因為
|
||||
-- 步驟 2 的判斷是看 entries 裡有沒有,不是看這是不是第一次跑)。
|
||||
CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 表退場施工步驟①保底存在,非資料存取違規,理由見檔頭
|
||||
api_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
service TEXT,
|
||||
sensitivity TEXT NOT NULL DEFAULT 'standard',
|
||||
secret_ref TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
PRIMARY KEY (api_key, name)
|
||||
);
|
||||
|
||||
INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at)
|
||||
SELECT
|
||||
'e_cred_' || lower(hex(randomblob(8))),
|
||||
'credential',
|
||||
c.api_key,
|
||||
c.name,
|
||||
json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at),
|
||||
c.created_at,
|
||||
unixepoch()
|
||||
FROM credentials c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM entries e
|
||||
WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS credentials; -- kbdb-sql-ok: 表退場施工步驟③讓舊表退場,非資料存取違規,理由見檔頭
|
||||
+283
-13
@@ -216,12 +216,63 @@ const MAX_LIKE_TERMS = 6;
|
||||
|
||||
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
|
||||
|
||||
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
|
||||
// ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(Arcrun#94)────────────────
|
||||
//
|
||||
// 病徵:搜尋框打 `100%` 或 `owner_id`,回來一堆跟那些字無關的東西。
|
||||
// SQLite LIKE 只有兩個萬用字元——`%`(任意長度)與 `_`(任意一個字元),而且
|
||||
// **沒有預設跳脫字元**(不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」)。
|
||||
// 我們把使用者輸入直接內插成 `'%' + q + '%'` ⇒ 他打的符號被當成 pattern 語法:
|
||||
// `100%` → `%100%%` → 「100」開頭後面接什麼都算 ⇒ 撈回一堆不相干的
|
||||
// `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ `ownerXid`、`owner-id` 也中
|
||||
// `%`/`_` 單打 → `%%%`/`%_%` → **整個庫都回來**(`_` 只要有一個字元就中)
|
||||
//
|
||||
// 這是舊病,不是 08-10 斷詞(47c6aae→本檔上一段)引進的:pattern 一直都是這樣拼的。
|
||||
// 之前關鍵字搜尋幾乎恆為 0 命中(整串比對),這個洞被那個洞蓋住,看不出來;
|
||||
// 斷詞讓搜尋真的會回東西之後它才浮出來。**斷詞那段一個字都沒動。**
|
||||
//
|
||||
// 修法:三個字元都跳脫,並在每個 LIKE 後面掛 `ESCAPE '\'`。
|
||||
//
|
||||
// 為什麼**跳脫字元本身(`\`)也要跳脫**(邊界問題的答案,不是順手多做):
|
||||
// 一旦宣告了 ESCAPE,`\` 在 pattern 裡就變成有意義的字元,於是「使用者打的 `\`」
|
||||
// 同樣會被誤讀——而且是更糟的一種,因為它會**把後面那個字吃掉**:
|
||||
// 使用者打 `100\%` → 不跳脫 `\` ⇒ pattern `%100\%%` ⇒ `\%`=字面 %
|
||||
// ⇒ 實際找的是 `100%`,**跟他打的字不一樣**
|
||||
// 使用者打 `C:\` → pattern `%C:\%` ⇒ 尾巴 `\%`=字面 %
|
||||
// ⇒ 找的是 `C:%`,而真正的 `C:\` 反而找不到
|
||||
// ⇒ 三個字元是一組的:宣告 ESCAPE 卻不跳脫 `\` 等於用新的漏洞換掉舊的。
|
||||
// (SQLite 對「`\` 後面接其他字元」是寬容的——照字面匹配下一個字、不報錯——
|
||||
// 所以不跳脫不會炸,只會靜靜地找錯東西,正是最難發現的那種。)
|
||||
//
|
||||
// 為什麼**只有這三個**:SQLite 的 LIKE 萬用字元就只有 `%` 和 `_`(`[...]`、`?`、`*`
|
||||
// 是別的方言/GLOB 的東西,LIKE 不吃),加上自己宣告的跳脫字元 `\`,就這三個。
|
||||
// 不多跳脫其他字元——跳脫沒有語法意義的字元只會白白吃掉 pattern 的 byte 預算。
|
||||
//
|
||||
// 🔴 與 50 bytes 上限的交互作用(不能只加跳脫就收工):跳脫會**變長**(`%`→`\%`),
|
||||
// 所以所有 byte 預算改用「跳脫後」的長度算(likeBytes),否則使用者打一串 `%`
|
||||
// 會讓 pattern 膨脹回 50 bytes 以上 ⇒ 退回 2026-08-03 那個 500。
|
||||
// 不含這三個字元的查詢,likeBytes ≡ utf8Len ⇒ **既有查詢的行為逐字不變**。
|
||||
const LIKE_ESCAPE = '\\';
|
||||
/** 每個 `content LIKE ?` 都要帶著它的 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面。 */
|
||||
const CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
|
||||
|
||||
/** 把使用者輸入當「字面字串」送進 LIKE(純函式,單測用 export)。 */
|
||||
export function escapeLikeLiteral(s: string): string {
|
||||
// 一次掃描、每個字元各自替換 ⇒ 不會發生「先換 % 再換 \ 把剛加的跳脫又跳脫一次」。
|
||||
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
|
||||
}
|
||||
|
||||
/** 這段文字**跳脫後**佔的 byte 數(=它在 LIKE pattern 裡真正佔的長度)。 */
|
||||
const likeBytes = (s: string): number => utf8Len(escapeLikeLiteral(s));
|
||||
|
||||
/** 子字串比對用的 pattern:只有頭尾那兩個 `%` 是萬用字元,中間全是字面。 */
|
||||
const likePattern = (s: string): string => `%${escapeLikeLiteral(s)}%`;
|
||||
|
||||
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。上限算的是**跳脫後**的長度。 */
|
||||
function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = '';
|
||||
for (const ch of s) {
|
||||
if (utf8Len(cur + ch) > maxBytes) {
|
||||
if (likeBytes(cur + ch) > maxBytes) {
|
||||
if (cur) out.push(cur);
|
||||
cur = ch;
|
||||
} else {
|
||||
@@ -237,8 +288,8 @@ function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
|
||||
*/
|
||||
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
|
||||
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
|
||||
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
|
||||
}
|
||||
const terms: string[] = [];
|
||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||
@@ -251,12 +302,215 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
|
||||
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
|
||||
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
|
||||
return {
|
||||
conds: terms.map(() => 'content LIKE ?'),
|
||||
params: terms.map((t) => `%${t}%`),
|
||||
conds: terms.map(() => CONTENT_LIKE),
|
||||
params: terms.map(likePattern),
|
||||
split: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ─────────────────────────
|
||||
//
|
||||
// 病徵(2026-08-10 總管在 leo21c 上實測,有對照組):
|
||||
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||
// ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。
|
||||
// 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一
|
||||
// `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。
|
||||
//
|
||||
// 為什麼這是**結構性**故障、不是準度問題:
|
||||
// **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰
|
||||
// ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」——
|
||||
// 接上了也還是瞎的,因為接上之後查什麼都沒有。
|
||||
// (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。)
|
||||
//
|
||||
// 這件 47c6aae(2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事;
|
||||
// 本次只動**查詢端**,buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。
|
||||
//
|
||||
// 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。
|
||||
// · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**,
|
||||
// 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。
|
||||
// · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛
|
||||
// ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2)
|
||||
// ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。
|
||||
// · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一,
|
||||
// `local arcrun` 那 5 筆不會被稀釋掉。
|
||||
// · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套):
|
||||
// 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。
|
||||
//
|
||||
// 回歸保證(不是靠測試碰運氣,是靠構造):
|
||||
// · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern),
|
||||
// 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。
|
||||
// · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞),
|
||||
// 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。
|
||||
//
|
||||
// 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段
|
||||
// +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、
|
||||
// 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。
|
||||
// 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到
|
||||
// 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。
|
||||
const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數)
|
||||
const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號
|
||||
// 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照):
|
||||
// 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾);
|
||||
// 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響
|
||||
// ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」,
|
||||
// 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。
|
||||
const KEYWORD_RELATIVE_CUT = 0.6;
|
||||
|
||||
// CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。**
|
||||
//
|
||||
// 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果
|
||||
// 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。
|
||||
// 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都
|
||||
// 同時是虛詞與實詞的組成部分)。
|
||||
// ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。
|
||||
// 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。
|
||||
//
|
||||
// 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額),
|
||||
// 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡
|
||||
// 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。
|
||||
const CJK_STOP_CHARS = new Set(
|
||||
'的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''),
|
||||
);
|
||||
|
||||
// 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。
|
||||
const ASCII_STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were',
|
||||
'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with',
|
||||
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would',
|
||||
'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they',
|
||||
]);
|
||||
|
||||
const isCjkChar = (ch: string): boolean => /[-ヿ㐀-䶿一-鿿豈-]/.test(ch);
|
||||
const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch);
|
||||
|
||||
/** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */
|
||||
export function splitRuns(q: string): { text: string; cjk: boolean }[] {
|
||||
const runs: { text: string; cjk: boolean }[] = [];
|
||||
let cur = ''; let curCjk = false;
|
||||
const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; };
|
||||
for (const ch of q) {
|
||||
const cjk = isCjkChar(ch);
|
||||
if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔
|
||||
if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意)
|
||||
cur += ch; curCjk = cjk;
|
||||
}
|
||||
flush();
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */
|
||||
function contentBigrams(run: string): string[] {
|
||||
const chars = [...run];
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i + 1 < chars.length; i++) {
|
||||
if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue;
|
||||
out.push(chars[i] + chars[i + 1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SearchTerm { term: string; weight: number }
|
||||
|
||||
/**
|
||||
* 把查詢句拆成帶份量的查詢詞(純函式,單測用 export)。
|
||||
* 份量=字數(上限 MAX_TERM_WEIGHT);愈長愈專指 ⇒ 排序時壓過泛詞。
|
||||
* 依份量由大到小截斷到 MAX_SEARCH_TERMS,確保被砍掉的是最泛的那幾個。
|
||||
*/
|
||||
export function tokenizeQuery(q: string): SearchTerm[] {
|
||||
const found = new Map<string, number>();
|
||||
const add = (t: string, w: number) => {
|
||||
for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束
|
||||
if (!piece) continue;
|
||||
found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT)));
|
||||
}
|
||||
};
|
||||
|
||||
const runs = splitRuns(q);
|
||||
// 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同:
|
||||
// · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。
|
||||
// · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。
|
||||
// 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有
|
||||
// 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的
|
||||
// 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。
|
||||
const isQuestion = runs.length > 1;
|
||||
|
||||
for (const run of runs) {
|
||||
if (!run.cjk) {
|
||||
const w = run.text.toLowerCase();
|
||||
if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length);
|
||||
continue;
|
||||
}
|
||||
const chars = [...run.text];
|
||||
// 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。
|
||||
if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length);
|
||||
// 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。
|
||||
// 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。
|
||||
if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2);
|
||||
}
|
||||
|
||||
return [...found.entries()]
|
||||
.map(([term, weight]) => ({ term, weight }))
|
||||
.sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
|
||||
.slice(0, MAX_SEARCH_TERMS);
|
||||
}
|
||||
|
||||
export interface SearchScorePlan {
|
||||
/** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */
|
||||
scoreExpr: string;
|
||||
scoreParams: string[];
|
||||
terms: SearchTerm[];
|
||||
/** true = 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */
|
||||
legacyShape: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生「覆蓋率分數」的 SQL 表達式(純函式,單測用 export)。
|
||||
*
|
||||
* 整句相鄰另給一份重賞(=所有詞份量總和),確保**舊行為排最前**:
|
||||
* 含整句的內容分數必然高於只含零散詞的,`local arcrun` 那 5 筆永遠在最上面。
|
||||
*/
|
||||
export function buildSearchScore(q: string): SearchScorePlan {
|
||||
const trimmed = q.trim();
|
||||
const terms = tokenizeQuery(trimmed);
|
||||
|
||||
// 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。
|
||||
if (terms.length === 0) {
|
||||
const m = buildContentLike(trimmed);
|
||||
return {
|
||||
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '),
|
||||
scoreParams: m.params,
|
||||
terms: [],
|
||||
legacyShape: true,
|
||||
};
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const params: string[] = [];
|
||||
for (const { term, weight } of terms) {
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
|
||||
params.push(likePattern(term));
|
||||
}
|
||||
|
||||
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
|
||||
params.push(likePattern(trimmed));
|
||||
}
|
||||
|
||||
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||
}
|
||||
|
||||
/** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */
|
||||
export function applyRelativeCut<T extends { match_score: number }>(rows: T[]): T[] {
|
||||
if (rows.length <= 1) return rows;
|
||||
const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT;
|
||||
return rows.filter((r) => r.match_score >= cut);
|
||||
}
|
||||
|
||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||
@@ -309,6 +563,11 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||
// 2026-08-12(Arcrun#94):q 裡的 `%` `_` `\` 一律當字面字元(escapeLikeLiteral + ESCAPE 宣告)
|
||||
// ——使用者打什麼字就照那些字找。舊病,見上面 LIKE_ESCAPE 那段。
|
||||
// 2026-08-10:q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||
export async function searchEntries(
|
||||
db: D1Database,
|
||||
q: string,
|
||||
@@ -318,18 +577,29 @@ export async function searchEntries(
|
||||
library?: string[],
|
||||
source?: string,
|
||||
includeDeprecated = false,
|
||||
): Promise<Entry[]> {
|
||||
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
||||
const conds = [...m.conds];
|
||||
const params: unknown[] = [...m.params];
|
||||
): Promise<(Entry & { match_score: number })[]> {
|
||||
const plan = buildSearchScore(q); // 斷詞+算分;單詞查詢=與舊版逐字相同的單一 LIKE
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [...plan.scoreParams];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
||||
// 分數在子查詢算、外層才篩 match_score > 0:SQLite 不保證能在 WHERE 引用 SELECT 別名,
|
||||
// 用子查詢就不必把整組 LIKE 參數再綁一次(參數重複=將來改一邊漏一邊的漂移來源)。
|
||||
// 其他 filter 留在**內層**,讓 owner/library/deprecated 先篩掉,算分只發生在該算的列上。
|
||||
const inner = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const res = await db
|
||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
||||
.prepare(
|
||||
`SELECT * FROM (
|
||||
SELECT *, (${plan.scoreExpr}) AS match_score FROM entries ${inner}
|
||||
) WHERE match_score > 0
|
||||
ORDER BY match_score DESC, updated_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(...params, Math.min(limit, 200))
|
||||
.all<Entry>();
|
||||
return res.results ?? [];
|
||||
.all<Entry & { match_score: number }>();
|
||||
// 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。
|
||||
return applyRelativeCut(res.results ?? []);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// 標庫 backfill(Arcrun#85 二次裁決,2026-08-11;相關票 Arcrun#87「藏書地圖是空的」)。
|
||||
//
|
||||
// 背景:leo 把向量化優先序講成一句話後又補一刀:「它是兩件事?其實是一件——沒有庫就
|
||||
// 剩一個,但你把庫標好以後,原來的判定要修改對吧」。⇒ 判定標準(embed.ts 的
|
||||
// SelectionCriteria)必須從第一天就同時容納時間與庫,本檔提供「庫」這一半真正的資料。
|
||||
//
|
||||
// base 的 write path 早就支援(`createEntry` 的 `metadata_json.$.library`,t52:「庫由
|
||||
// ingest 蓋章決定」)——既有的搜尋/embed/deprecate-by-library 全都讀這個欄位,
|
||||
// **缺的不是機制,是既有資料沒被蓋章**(library-map.ts 檔頭 2026-07-19 對 prod 核實:
|
||||
// 既有 entries 的 metadata.library 是空的)。「源頭寫入就貼標」是呼叫端(ingest)的事,
|
||||
// base 這裡管不到、也不該猜(base 對內容語意無知的既有原則);triplet 的實際寫入更是
|
||||
// 在另一個 repo(見 kbdb/src/index.ts 檔頭「triplet (separate repo)」)。
|
||||
//
|
||||
// 這個模組只做「補存量」那一半,且刻意設計成呼叫端驅動:
|
||||
// - base 不猜「這筆該屬於哪個庫」——那是語意判斷。呼叫端給一個 target library 值
|
||||
// +一組篩選條件,base 只負責把符合條件、目前未標記的 entries 安全、節流地蓋上這個值。
|
||||
// - 篩選條件有兩種精度(2026-08-11 leo 定案的做法後補上):
|
||||
// ① 精準比對 `page_names`(IN 清單)——leo 定的正解:「有 2 份原稿,在 gitea 和我的
|
||||
// Mac……去 gitea 把每個庫有哪些的卡名列出,跑來遍歷應該就搞定了」。呼叫端(daemon/
|
||||
// #87)從 Gitea repo 列出卡名,逐批把「這些卡名屬於庫 X」精準地寫進來,不必猜。
|
||||
// ② `source_prefix`/`page_name_prefix` 前綴 fallback(同 library-map.ts
|
||||
// recomputeLibraryMap 的 source_prefix 精神,只是這裡是寫入不是聚合)——沒有精準
|
||||
// 清單時的過渡手段,精度不如①,兩者可並用(AND)縮小範圍。
|
||||
// - 冪等:已標記的 entries 不會再入選(WHERE 帶「library 為空」)。
|
||||
//
|
||||
// D69 節流:與 reconcileEmbedGeneration(embed.ts)共用 maintenance-quota.ts 的同一顆
|
||||
// 每日 D1 寫入計數器——兩者都是「多筆 D1 row write、不打 AI」的背景維護操作,不共用
|
||||
// 計數器的話,補存量時會把世代核對的閘繞過去(leo 2026-08-11 二次裁決原話:「每日上限
|
||||
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
|
||||
import type { Bindings } from '../types';
|
||||
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
|
||||
|
||||
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
|
||||
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
|
||||
const MAX_PAGE_NAMES = 300;
|
||||
|
||||
export interface LibraryBackfillCriteria {
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
page_names?: string[]; // 精準比對 page_name(IN 清單)——leo 定案的正解:從 Gitea repo
|
||||
// 列出卡名,逐批精準點名「這些卡名屬於庫 X」(見檔頭說明①)。
|
||||
source_prefix?: string; // metadata_json.$.source LIKE prefix%(過渡 fallback,見檔頭②)
|
||||
page_name_prefix?: string; // page_name LIKE prefix%(過渡 fallback,見檔頭②)
|
||||
since?: number; // created_at >= since(unix seconds)
|
||||
until?: number; // created_at < until(unix seconds)
|
||||
}
|
||||
|
||||
export interface LibraryBackfillResult {
|
||||
library: string;
|
||||
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
|
||||
tagged: number; // 本次真的寫入 metadata_json.$.library 的筆數。
|
||||
remaining: number; // 本次之後仍待補標(符合條件、仍未標記)的筆數,不受額度影響。
|
||||
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與 reconcile 共用)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷。
|
||||
}
|
||||
|
||||
// 單次呼叫候選上限(避開 subrequest/CPU/timeout;一批只有 1 次 SELECT + 1 次 UPDATE,
|
||||
// 比 reconcile 多一次 Vectorize 呼叫的成本低,故上限可以放寬一些)。
|
||||
const HARD_LIMIT_CAP = 500;
|
||||
|
||||
function criteriaPredicate(c: LibraryBackfillCriteria): { conds: string[]; params: unknown[] } {
|
||||
// 冪等的核心:只選「目前沒有 library 值」的候選,已標記過的(含標成 'general' 的)不會再入選。
|
||||
const conds: string[] = [
|
||||
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')",
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
if (c.owner_id) { conds.push('owner_id = ?'); params.push(c.owner_id); }
|
||||
if (c.entry_type) { conds.push('entry_type = ?'); params.push(c.entry_type); }
|
||||
if (c.page_names && c.page_names.length > 0) {
|
||||
const names = c.page_names.slice(0, MAX_PAGE_NAMES);
|
||||
conds.push(`page_name IN (${names.map(() => '?').join(',')})`);
|
||||
params.push(...names);
|
||||
}
|
||||
if (c.source_prefix) { conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'"); params.push(c.source_prefix); }
|
||||
if (c.page_name_prefix) { conds.push("page_name LIKE ? || '%'"); params.push(c.page_name_prefix); }
|
||||
if (typeof c.since === 'number') { conds.push('created_at >= ?'); params.push(c.since); }
|
||||
if (typeof c.until === 'number') { conds.push('created_at < ?'); params.push(c.until); }
|
||||
return { conds, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 對「符合條件、目前未標記 library」的既有 entries 批次蓋上 target library 值。
|
||||
* 冪等 + 分批(單次 limit 上限)+ budget(與 reconcile 共用每日 D1 寫入額度,見檔頭)。
|
||||
* 呼叫端(ingest / Arcrun#87)決定「這批是誰、該貼哪個庫」,本函式只負責安全、節流地
|
||||
* 把值寫進去——base 不猜語意,也因此不假裝「這樣就把 #87 做完了」(mindset §7)。
|
||||
*
|
||||
* `owner_id` 刻意設成**必填**(不同於 LibraryBackfillCriteria 其餘欄位皆選填):
|
||||
* 2026-08-11 leo 在票上點出「補標補在錯的 owner 底下等於白做」(實查發現卡片實際掛在
|
||||
* `owner_id=bfezv28v`,換成 `owner_id='leo'` 查卻是空的——兩個候選 owner 已經在互相打架)。
|
||||
* 跟既有的 `deprecateEntriesByLibrary`(同樣是「依 library 批次改一大片既有資料」的操作)
|
||||
* 同一個防線:不給不知道自己在改誰的資料的呼叫端一個「忘記帶 owner_id 就變成跨租戶全庫掃」
|
||||
* 的後門,逼呼叫端明確想清楚「這批是哪個 owner」再動手。
|
||||
*/
|
||||
export async function backfillEntryLibraryTags(
|
||||
db: D1Database,
|
||||
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
|
||||
opts: { library: string; owner_id: string; limit?: number } & Omit<LibraryBackfillCriteria, 'owner_id'>,
|
||||
): Promise<LibraryBackfillResult> {
|
||||
const library = (opts.library ?? '').trim();
|
||||
if (!library) throw new Error('library required');
|
||||
const ownerId = (opts.owner_id ?? '').trim();
|
||||
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
|
||||
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
|
||||
|
||||
const sel = criteriaPredicate({ ...opts, owner_id: ownerId });
|
||||
const where = sel.conds.join(' AND ');
|
||||
const params = sel.params;
|
||||
|
||||
const res = await db
|
||||
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write,與 reconcile 共用同一顆計數器。
|
||||
const budget = await maintenanceBudgetToday(env, db);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
|
||||
let tagged = 0;
|
||||
if (ids.length > 0) {
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`,
|
||||
)
|
||||
.bind(library, ...ids)
|
||||
.run();
|
||||
tagged = ids.length;
|
||||
}
|
||||
|
||||
try {
|
||||
await addMaintenanceUsage(db, tagged);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
|
||||
}
|
||||
|
||||
const remRow = await db
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
return {
|
||||
library,
|
||||
scanned,
|
||||
tagged,
|
||||
remaining: remRow?.c ?? 0,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + tagged,
|
||||
quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待補標統計(回報用):符合條件、目前未標記 library 的筆數。 */
|
||||
export async function libraryBackfillStatus(
|
||||
db: D1Database,
|
||||
opts: LibraryBackfillCriteria = {},
|
||||
): Promise<{ pending: number }> {
|
||||
const sel = criteriaPredicate(opts);
|
||||
const where = sel.conds.join(' AND ');
|
||||
const row = await db
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...sel.params)
|
||||
.first<{ c: number }>();
|
||||
return { pending: row?.c ?? 0 };
|
||||
}
|
||||
@@ -330,6 +330,15 @@ type LibraryNameSet = Set<string>;
|
||||
|
||||
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
|
||||
// triplet 歸 'general')——與 GET /records/triplet-stats(t142)同一套分組語意,兩處數字對得上。
|
||||
//
|
||||
// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status,
|
||||
// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不
|
||||
// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上,
|
||||
// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map
|
||||
// record(superseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede
|
||||
// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作,
|
||||
// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出
|
||||
// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。
|
||||
async function liveTripletCountsByLibrary(
|
||||
db: D1Database,
|
||||
tripletTemplateId: string,
|
||||
@@ -337,15 +346,18 @@ async function liveTripletCountsByLibrary(
|
||||
): Promise<LibraryCountMap> {
|
||||
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||
const res = await db
|
||||
.prepare(
|
||||
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||||
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||
FROM (
|
||||
SELECT DISTINCT ev.record_id
|
||||
SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
||||
GROUP BY ev.record_id
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
|
||||
)
|
||||
.bind(...params)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// 背景維護寫入的共用 D1 每日額度(Arcrun#85 D69,2026-08-11)。
|
||||
//
|
||||
// 為什麼需要這個模組(不是每個 caller 各自算):
|
||||
// D68 已經替「補算向量」的 Workers AI 呼叫量設了每日軟上限(embed.ts
|
||||
// DEFAULT_BACKFILL_DAILY_LIMIT),但 leo 逐行複核後指出還有一個沒堵的洞——
|
||||
// 世代核對(reconcileEmbedGeneration)**不打 AI,卻一樣逐筆寫 D1**(補標 content_hash
|
||||
// 或重置 is_embedded),47 萬筆候選 ≈ 4.7 倍 D1 免費層 100,000 rows written/日,而它
|
||||
// 當時零保護。2026-08-11 leo 補了第二刀:**標庫(library backfill)也是同一種操作**
|
||||
// ——多筆 D1 row write、不打 AI——若各自設一顆獨立計數器,做標庫時會把 reconcile
|
||||
// 的閘繞過去(兩者加起來還是可能燒穿同一顆 D1)。
|
||||
// ⇒ 兩者必須共用同一顆「今天 D1 背景維護寫入還剩多少」計數器,這裡就是那顆計數器。
|
||||
//
|
||||
// 儲存精神完全比照 execution-log.ts checkUsage/embed.ts getBackfillUsageToday:單一
|
||||
// entries 列/日(entry_type='kbdb_maintenance_usage'),upsert,不新增表(D38)。
|
||||
//
|
||||
// 額度怎麼選(不是拍腦袋,比照 execution-log.ts DEFAULT_DAILY_LIMIT 的既有算法):
|
||||
// D1 免費層 100,000 rows written/日。execution_log 自設 20%(20,000)留給知識卡;
|
||||
// 本模組管的是「背景維護」(reconcile + 標庫 backfill,兩者都是低優先、非使用者
|
||||
// 當下等待的操作),同樣自設 20%(20,000/日)——不是硬性 Cloudflare 限制,是不讓
|
||||
// 背景維護把當天寫入額度和知識卡片的正常寫入/execution_log 搶光的自我節制,
|
||||
// 可用 env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT 覆寫。
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 20000;
|
||||
|
||||
export function maintenanceDailyLimit(env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>): number {
|
||||
const raw = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
}
|
||||
|
||||
function utcDay(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 額度計數器 entries id(單一列/日;不分租戶——D1 rows-written 額度是實例級,非租戶級)。 */
|
||||
function maintenanceUsageId(): string {
|
||||
return `kbdb-maintenance-usage:${utcDay()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今天背景維護寫入已消耗的筆數。讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open,
|
||||
* 精神同 embed.ts getBackfillUsageToday)。
|
||||
*/
|
||||
export async function getMaintenanceUsageToday(db: D1Database): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(maintenanceUsageId())
|
||||
.first<{ metadata_json: string | null }>();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { writes?: number }) : {};
|
||||
return Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 今天背景維護額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
|
||||
export async function addMaintenanceUsage(db: D1Database, by: number): Promise<void> {
|
||||
if (by <= 0) return;
|
||||
const id = maintenanceUsageId();
|
||||
const existing = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ metadata_json: string | null }>();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
|
||||
prev = Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
prev = 0;
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||||
.bind(JSON.stringify({ day: utcDay(), writes: prev + by }), id)
|
||||
.run();
|
||||
} else {
|
||||
await db
|
||||
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'kbdb_maintenance_usage', ?)`)
|
||||
.bind(id, JSON.stringify({ day: utcDay(), writes: by }))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export interface MaintenanceBudget {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
/** 今天還剩多少背景維護 D1 寫入額度(reconcile/標庫 backfill 呼叫前先問這個)。 */
|
||||
export async function maintenanceBudgetToday(
|
||||
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
|
||||
db: D1Database,
|
||||
): Promise<MaintenanceBudget> {
|
||||
const limit = maintenanceDailyLimit(env);
|
||||
let used = 0;
|
||||
try {
|
||||
used = await getMaintenanceUsageToday(db);
|
||||
} catch {
|
||||
used = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連背景維護都做不了
|
||||
}
|
||||
return { limit, used, remaining: Math.max(0, limit - used) };
|
||||
}
|
||||
@@ -65,6 +65,13 @@ export interface RecordResult {
|
||||
record_id: string;
|
||||
template_id: string;
|
||||
values: Record<string, string>;
|
||||
/**
|
||||
* record 的歸屬(=其底層 slot entries 的 owner_id,createRecord 寫入時同一值)。
|
||||
* 2026-08-12 補:`GET /records/:id` 原本不回這欄,所以**呼叫端無從判斷這筆是不是自己的**
|
||||
* ——按 id 直讀等於沒有租戶邊界。要讓 cypher 的 portal 資料面(授權的人/AI 走的那條)
|
||||
* 能對單筆做「不是我的就回 404」,歸屬必須跟著資料一起回來。無歸屬的舊資料 → null。
|
||||
*/
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
|
||||
@@ -85,7 +92,7 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
|
||||
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
||||
.run();
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||
}
|
||||
|
||||
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
||||
@@ -147,17 +154,19 @@ export async function updateRecord(
|
||||
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`,
|
||||
)
|
||||
.bind(recordId)
|
||||
.all<{ slot: string; content: string; template_id: string }>();
|
||||
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||
const rows = res.results ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
const values: Record<string, string> = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
||||
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
|
||||
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||
}
|
||||
|
||||
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
|
||||
@@ -192,19 +201,20 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const evRes = await db
|
||||
.prepare(
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id IN (${placeholders})`,
|
||||
)
|
||||
.bind(...chunk)
|
||||
.all<{ record_id: string; slot: string; content: string; template_id: string }>();
|
||||
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||
for (const r of evRes.results ?? []) {
|
||||
let rec = byId.get(r.record_id);
|
||||
if (!rec) {
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||
byId.set(r.record_id, rec);
|
||||
}
|
||||
rec.values[r.slot] = r.content;
|
||||
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||
}
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
||||
|
||||
+305
-13
@@ -12,6 +12,7 @@
|
||||
// base 只認這個通用旗標 → base 維持對內容語意無知。
|
||||
|
||||
import type { Bindings, Entry } from './types';
|
||||
import { maintenanceBudgetToday, addMaintenanceUsage } from './actions/maintenance-quota';
|
||||
|
||||
// ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)────────────────
|
||||
//
|
||||
@@ -132,7 +133,12 @@ export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean
|
||||
},
|
||||
]);
|
||||
// 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。
|
||||
await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(entry.id).run();
|
||||
// content_hash 順手蓋成「這次嵌入用的模型」(世代戳記,見下方 reconcileEmbedGeneration 的
|
||||
// 說明)——這裡是「新寫的立刻算」的路徑,寫入當下 model 必為現行 model,不會有世代落差。
|
||||
await env.DB
|
||||
.prepare('UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?')
|
||||
.bind(embedModel(env), entry.id)
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -173,12 +179,134 @@ function parseMeta(json: string | null): Record<string, unknown> | null {
|
||||
const BACKFILL_PREDICATE =
|
||||
"is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
|
||||
|
||||
// ── 每日額度上限(D68,2026-08-11:leo「補算向量照時間新到舊、且每天有額度上限」)─────────
|
||||
//
|
||||
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日免費 10,000 neurons(UTC 午夜重置,
|
||||
// 見頂層 wiki ops-facts.md「萃取與向量化吃同一份 Workers AI 額度」)。backfill 是背景低優先
|
||||
// 動作,不該把當天額度燒光讓萃取/今天的新寫入整天卡死(embedOnWrite 不受此上限——「新寫的
|
||||
// 立刻算」是 D68 三條之一,不能被 backfill 的節制連坐)。自設「軟上限」,非 Cloudflare 硬限制,
|
||||
// 可用 env.EMBED_BACKFILL_DAILY_LIMIT 覆寫(精神比照 execution-log.ts 的 DEFAULT_DAILY_LIMIT)。
|
||||
//
|
||||
// 預設值怎麼選(不是拍腦袋,2026-08-11 查證 Cloudflare 官方定價後回推):
|
||||
// bge-m3 定價:1,075 neurons / 1,000,000 input tokens(無輸出 token 成本,embedding 只有輸入)。
|
||||
// 保守估計每筆中文知識卡片 ~800 tokens(寧可高估——CJK tokenizer 密度通常高於英文,
|
||||
// 高估 token 數 ⇒ 算出的「每日可嵌筆數」偏保守,不會撞真的 CF 額度):
|
||||
// 800 tokens × 1,075 / 1,000,000 ≈ 0.86 neurons/entry
|
||||
// backfill 分到日配額 20%(比照 execution-log.ts「自我節制、留大部分給主流程」的既有慣例):
|
||||
// 10,000 × 20% = 2,000 neurons/日
|
||||
// 2,000 ÷ 0.86 ≈ 2,325 entries/日,再打八折留緩衝(token 估計誤差/其他背景消耗):
|
||||
// 2,325 × 0.8 ≈ 1,860 → 取整數 1,800。
|
||||
const DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
|
||||
|
||||
function backfillDailyLimit(env: Pick<Bindings, 'EMBED_BACKFILL_DAILY_LIMIT'>): number {
|
||||
const raw = env.EMBED_BACKFILL_DAILY_LIMIT;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
|
||||
}
|
||||
|
||||
function utcDay(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 額度計數器 entries id(單一列/日,UTC 日期字串,換日自然歸零;不分租戶——Workers AI 額度是帳號級)。 */
|
||||
function backfillUsageId(): string {
|
||||
return `embed-backfill-usage:${utcDay()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今天 backfill 已消耗的筆數。儲存精神完全比照 execution-log.ts 的 checkUsage:單一 entries 列/日
|
||||
* (entry_type='embed_backfill_usage',計數包進 metadata_json),不新增表。
|
||||
* 讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open)。
|
||||
*/
|
||||
async function getBackfillUsageToday(db: D1Database): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(backfillUsageId())
|
||||
.first<{ metadata_json: string | null }>();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { embedded?: number }) : {};
|
||||
return Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
return 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死額度機制
|
||||
}
|
||||
}
|
||||
|
||||
/** 今天 backfill 額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
|
||||
async function addBackfillUsage(db: D1Database, by: number): Promise<void> {
|
||||
if (by <= 0) return;
|
||||
const id = backfillUsageId();
|
||||
const existing = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ metadata_json: string | null }>();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { embedded?: number }) : {};
|
||||
prev = Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
prev = 0;
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||||
.bind(JSON.stringify({ day: utcDay(), embedded: prev + by }), id)
|
||||
.run();
|
||||
} else {
|
||||
await db
|
||||
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`)
|
||||
.bind(id, JSON.stringify({ day: utcDay(), embedded: by }))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 「挑哪一批」可以從外面指定(Arcrun#85,2026-08-11 leo 二度裁決)───────────────
|
||||
//
|
||||
// leo 的優先序不是「一律新到舊」的單一佇列,是**分層**:今天寫的立刻/這週在跑的先跑/
|
||||
// 有查詢紀錄的庫優先/半年前的慢慢跑。分層要能實作,前提是「這次補哪一批」要能從外面
|
||||
// (工作流)指定,不能只靠資料層自己決定的固定排序——策略要住在 leo 打得開的地方
|
||||
// (工作流頁),不是焊死在這裡看不見也改不動。
|
||||
//
|
||||
// 這裡不預先幫 caller 決定「四層怎麼切」(那是策略,屬於呼叫端/工作流,見 Arcrun#85
|
||||
// D70 段落的意圖草案),只提供**同一套篩選形狀**讓任何一層都能表達:
|
||||
// - since/until:時間窗(unix seconds,created_at 半開區間 [since, until))——時間分層
|
||||
// (①今天/②本週/④半年前)都是同一個 since/until 參數,差別只在呼叫端傳的值。
|
||||
// - library:依 metadata_json.$.library 過濾——一旦資料身上有庫這個資訊(Arcrun#87),
|
||||
// 「有查詢紀錄的庫優先」這層可以直接用同一個參數,不必再改介面形狀。
|
||||
// 三個操作(backfillEmbeddings/reconcileEmbedGeneration/backfillEntryLibraryTags,
|
||||
// 見 actions/library-backfill.ts)共用這個形狀,這就是「判定標準只有一份」的意思——
|
||||
// 不是先做時間、之後為了庫再回頭改介面。
|
||||
export interface SelectionCriteria {
|
||||
owner_id?: string;
|
||||
source?: string;
|
||||
library?: string; // 精確比對 metadata_json.$.library(未標記的舊資料一律歸 'general',同 embedOnWrite 慣例)
|
||||
since?: number; // created_at >= since(unix seconds)
|
||||
until?: number; // created_at < until(unix seconds)
|
||||
}
|
||||
|
||||
function selectionCriteriaPredicate(opts: SelectionCriteria): { conds: string[]; params: unknown[] } {
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
|
||||
if (opts.library) {
|
||||
conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?");
|
||||
params.push(opts.library);
|
||||
}
|
||||
if (typeof opts.since === 'number') { conds.push('created_at >= ?'); params.push(opts.since); }
|
||||
if (typeof opts.until === 'number') { conds.push('created_at < ?'); params.push(opts.until); }
|
||||
return { conds, params };
|
||||
}
|
||||
|
||||
export interface BackfillResult {
|
||||
enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。
|
||||
processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
|
||||
skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。
|
||||
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。
|
||||
skipped: number; // 掃到但沒嵌(例如 embedText 回 null,或本批被額度擋下)的筆數。
|
||||
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0,與額度無關——單純候選總量)。
|
||||
scanned: number; // 本批掃出的候選筆數(受 limit 限制)。
|
||||
quota_limit: number; // 今日 backfill 額度上限(env.EMBED_BACKFILL_DAILY_LIMIT 或預設值)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的 backfill 額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有可嵌的候選但今天不再打 AI,等明天/調高上限)。
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,9 +321,14 @@ export interface BackfillResult {
|
||||
*/
|
||||
export async function backfillEmbeddings(
|
||||
env: Bindings,
|
||||
opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {},
|
||||
opts: SelectionCriteria & { limit?: number; reindex?: boolean; offset?: number } = {},
|
||||
): Promise<BackfillResult> {
|
||||
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0,
|
||||
quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
||||
const offset = Math.max(opts.offset ?? 0, 0);
|
||||
|
||||
@@ -210,21 +343,39 @@ export async function backfillEmbeddings(
|
||||
// 🔴 2026-08-05:**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。
|
||||
// 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活,
|
||||
// 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
|
||||
// 「挑哪一批」(Arcrun#85):owner_id/source/library/since/until 全部走同一套
|
||||
// selectionCriteriaPredicate,讓呼叫端(工作流)能表達時間分層與庫分層,不必等
|
||||
// base 幫忙決定;本函式不預設任何一層,caller 傳什麼就篩什麼。
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
|
||||
const params: unknown[] = [...sel.params];
|
||||
const where = conds.join(' AND ');
|
||||
|
||||
// D68:由新到舊——最可能被查到的最先補回來(見檔頭 DEFAULT_BACKFILL_DAILY_LIMIT 段的決策脈絡)。
|
||||
const res = await env.DB
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`)
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
||||
.bind(...params, limit, offset)
|
||||
.all<Entry>();
|
||||
const rows = res.results ?? [];
|
||||
const scanned = rows.length;
|
||||
|
||||
// D68:每日額度上限。額度是「這次呼叫要不要打 AI」的唯一守門——reindex 一樣要打 AI.run,
|
||||
// 同樣受限(不因為是 reindex 就例外,會打 Workers AI 的動作都算)。
|
||||
const dailyCap = backfillDailyLimit(env);
|
||||
let usedToday = 0;
|
||||
try {
|
||||
usedToday = await getBackfillUsageToday(env.DB);
|
||||
} catch {
|
||||
usedToday = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連 backfill 都不做
|
||||
}
|
||||
const remainingQuota = Math.max(0, dailyCap - usedToday);
|
||||
|
||||
let processed = 0;
|
||||
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||
const candidates = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||
// 額度截斷:candidates 已按 created_at DESC 排序,取前 remainingQuota 筆=優先保留最新的。
|
||||
const embeddable = candidates.slice(0, remainingQuota);
|
||||
const quotaExceeded = candidates.length > embeddable.length;
|
||||
|
||||
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
||||
const texts = embeddable.map((e) => (e.content ?? '').trim());
|
||||
const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] };
|
||||
@@ -246,8 +397,18 @@ export async function backfillEmbeddings(
|
||||
await env.VECTORIZE.upsert(vectors);
|
||||
const ids = vectors.map((v) => v.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run();
|
||||
// content_hash 順手蓋成現行模型(世代戳記,見 reconcileEmbedGeneration)。
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`)
|
||||
.bind(embedModel(env), ...ids)
|
||||
.run();
|
||||
processed = vectors.length;
|
||||
try {
|
||||
await addBackfillUsage(env.DB, processed);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的嵌入(別讓 bookkeeping 故障吞掉已做的工);
|
||||
// 代價是下次呼叫可能少算一點用量——比「明明做了卻沒生效」安全(誠實限制,mindset §7)。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +420,16 @@ export async function backfillEmbeddings(
|
||||
// 非 reindex:predicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。
|
||||
// reindex:predicate 不含 is_embedded,COUNT 恆等於總數 → 改用 offset 分頁計 remaining(否則永不終止)。
|
||||
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
|
||||
return { enabled: true, processed, skipped: scanned - processed, remaining, scanned };
|
||||
return {
|
||||
enabled: true,
|
||||
processed,
|
||||
skipped: scanned - processed,
|
||||
remaining,
|
||||
scanned,
|
||||
quota_limit: dailyCap,
|
||||
quota_used_today: usedToday + processed,
|
||||
quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */
|
||||
@@ -283,6 +453,128 @@ export async function backfillStatus(
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
|
||||
export interface ReconcileResult {
|
||||
enabled: boolean;
|
||||
checked: number; // 本批「真的核對+寫回」的筆數(受下方 D1 額度截斷後的量)。
|
||||
confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。
|
||||
reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。
|
||||
remaining: number; // 本次之後仍待核對的筆數(不受額度影響,可重複呼叫直到 0)。
|
||||
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
|
||||
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與標庫 backfill 共用,見 maintenance-quota.ts)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有候選但今天不再寫 D1,等明天/調高上限)。
|
||||
}
|
||||
|
||||
/**
|
||||
* 世代核對(Generation reconciliation,D68 配套修復,2026-08-11)。
|
||||
*
|
||||
* 背景:`is_embedded=1` 只代表「曾經對某個 Vectorize index 嵌過」,不保證是**現行**的
|
||||
* index/模型(見檔頭 2026-08-03 換代註解:換模型必須換 index,舊向量收不進新 index、也刪不掉)。
|
||||
* 從備份整批灌回的資料尤其會帶著對**已退役索引**(例:768 維 `arcrun-kbdb-embed`)的
|
||||
* `is_embedded=1`——現行 backfill 的預設路徑(只補 `is_embedded=0`)永遠不會碰它們,
|
||||
* 語意搜尋對現行(1024 維 `arcrun-kbdb-embed-m3`)索引而言永遠搜不到那批東西,畫面不會說壞掉。
|
||||
*
|
||||
* 做法:不猜(`is_embedded` 本身此刻不可信),直接問現行 Vectorize index「這些 id 真的在你這嗎」
|
||||
* (`env.VECTORIZE.getByIds`,ground truth,而非比對 content_hash 字串本身——後者在這次修復
|
||||
* 之前從未被寫過,所有既有 is_embedded=1 的列 content_hash 皆為 NULL,無法只憑字串判斷「哪些是
|
||||
* 這次修復前的正常資料、哪些是真正的舊世代殘留」,必須問 Vectorize 本身):
|
||||
* - 真的在現行 index → 只是這次修復之前的正常資料,沒補寫過 content_hash。補標記,不重打 AI
|
||||
* (不浪費額度在已經正確的資料上)。
|
||||
* - 不在現行 index → 對現行 index 而言等於沒嵌過,重置 is_embedded=0、清空 content_hash,
|
||||
* 交回正常 backfill 佇列(下一輪照樣受「新到舊」排序+每日額度上限保護,不特別優待)。
|
||||
*
|
||||
* 不消耗 Workers AI 額度:零 AI.run,只有一次 D1 掃描 + 一次 Vectorize.getByIds + D1 寫回。
|
||||
*
|
||||
* D69(Arcrun#85,2026-08-11 leo 逐行複核找到的破口):**這一步雖不打 AI,但逐筆寫 D1**——
|
||||
* 每個候選最多消耗一次 row write(補標 content_hash 或重置 is_embedded,兩條路互斥、恰好一次),
|
||||
* 47 萬筆候選 ≈ 4.7 倍 D1 100,000 rows written/日免費額度。與標庫 backfill(同樣是多筆 D1
|
||||
* write、不打 AI)共用 `actions/maintenance-quota.ts` 的同一顆每日計數器——不共用的話,
|
||||
* 補標庫時會把這裡的閘繞過去(反之亦然)。額度用完 → 誠實截斷候選,不再寫 D1,等明天。
|
||||
*
|
||||
* 「挑哪一批」:owner_id/library/since/until 走 SelectionCriteria(同 backfillEmbeddings/
|
||||
* backfillEntryLibraryTags 共用的篩選形狀),讓時間分層/庫分層能從外面指定。
|
||||
*/
|
||||
export async function reconcileEmbedGeneration(
|
||||
env: Bindings,
|
||||
opts: Pick<SelectionCriteria, 'owner_id' | 'library' | 'since' | 'until'> & { limit?: number } = {},
|
||||
): Promise<ReconcileResult> {
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0,
|
||||
scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
const currentModel = embedModel(env);
|
||||
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [
|
||||
'is_embedded = 1',
|
||||
'(content_hash IS NULL OR content_hash != ?)',
|
||||
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
|
||||
...sel.conds,
|
||||
];
|
||||
const params: unknown[] = [currentModel, ...sel.params];
|
||||
const where = conds.join(' AND ');
|
||||
|
||||
const res = await env.DB
|
||||
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write,直接照剩餘額度砍候選清單長度。
|
||||
const budget = await maintenanceBudgetToday(env, env.DB);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
const checked = ids.length;
|
||||
|
||||
let confirmed_current = 0;
|
||||
let reset_to_pending = 0;
|
||||
if (ids.length > 0 && env.VECTORIZE) {
|
||||
const found = await env.VECTORIZE.getByIds(ids);
|
||||
const foundIds = new Set(found.map((v) => v.id));
|
||||
const presentIds = ids.filter((id) => foundIds.has(id));
|
||||
const missingIds = ids.filter((id) => !foundIds.has(id));
|
||||
|
||||
if (presentIds.length > 0) {
|
||||
const ph = presentIds.map(() => '?').join(',');
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`)
|
||||
.bind(currentModel, ...presentIds)
|
||||
.run();
|
||||
confirmed_current = presentIds.length;
|
||||
}
|
||||
if (missingIds.length > 0) {
|
||||
const ph = missingIds.map(() => '?').join(',');
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`)
|
||||
.bind(...missingIds)
|
||||
.run();
|
||||
reset_to_pending = missingIds.length;
|
||||
}
|
||||
}
|
||||
|
||||
const remRow = await env.DB
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
const written = confirmed_current + reset_to_pending;
|
||||
try {
|
||||
await addMaintenanceUsage(env.DB, written);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的核對寫入(精神同 backfillEmbeddings 的
|
||||
// addBackfillUsage 失敗處理——寧可下次呼叫少算一點用量,也不讓計數故障吞掉已做的工)。
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0,
|
||||
scanned, quota_limit: budget.limit, quota_used_today: budget.used + written, quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfTestResult {
|
||||
enabled: boolean; // embed 模組是否開(binding 都在)
|
||||
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed';
|
||||
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest, reconcileEmbedGeneration } from '../embed';
|
||||
|
||||
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -16,11 +16,14 @@ const OFF_HINT =
|
||||
'語義補嵌需先開 embed 模組(Vectorize+AI binding)。叫 CC「幫我開語義查詢」(設 kbdb_embed:true + redeploy 注入 binding)後再呼叫本端點。';
|
||||
|
||||
// POST /embed/backfill — batch-embed existing embeddable entries with is_embedded=0.
|
||||
// body(皆選填):{ limit?:1-100(預設25), owner_id?, source?, reindex?, offset? }。
|
||||
// body(皆選填):{ limit?:1-100(預設25), owner_id?, source?, library?, since?, until?, reindex?, offset? }。
|
||||
// 冪等:重跑不會重複嵌(已 is_embedded=1 的不再入選;upsert 同 id 冪等)。
|
||||
// 分批:單次最多 limit 筆;回傳 remaining>0 表示還有 → 重複呼叫直到 remaining=0。
|
||||
// reindex:true(Arcrun#11):改重推「所有 embeddable」既有向量(含 is_embedded=1),
|
||||
// 讓事後建立的 Vectorize metadata index 收錄它們(否則帶過濾語意查詢回 0);配 offset 分頁。
|
||||
// library/since/until(Arcrun#85,2026-08-11):「挑哪一批」從外面指定——時間分層
|
||||
// (今天/本週/半年前)與庫分層(有查詢紀錄的庫優先)共用同一套 SelectionCriteria,
|
||||
// 由呼叫端(工作流)決定這次要補的是哪一批,不是資料層焊死單一排序(見 embed.ts 檔頭說明)。
|
||||
// 模組未開 → 409 + capability_hint(不假綠)。
|
||||
embedRoutes.post('/backfill', async (c) => {
|
||||
if (!embedEnabled(c.env)) {
|
||||
@@ -33,6 +36,9 @@ embedRoutes.post('/backfill', async (c) => {
|
||||
limit?: number | string;
|
||||
owner_id?: string;
|
||||
source?: string;
|
||||
library?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
reindex?: boolean;
|
||||
offset?: number | string;
|
||||
};
|
||||
@@ -40,6 +46,9 @@ embedRoutes.post('/backfill', async (c) => {
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
owner_id: body.owner_id || undefined,
|
||||
source: body.source || undefined,
|
||||
library: body.library || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
// reindex(Arcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
|
||||
reindex: body.reindex === true,
|
||||
offset: body.offset !== undefined ? Number(body.offset) : undefined,
|
||||
@@ -56,6 +65,38 @@ embedRoutes.get('/backfill/status', async (c) => {
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// POST /embed/reconcile — 世代核對(D68 配套修復,2026-08-11;D69 額度節流同日補上):
|
||||
// 對「is_embedded=1 但 content_hash 非現行模型」的候選,問現行 Vectorize index 是否真的收錄;
|
||||
// 真的在 → 補標 content_hash(不打 AI);不在 → 重置 is_embedded=0,回到正常 /embed/backfill 佇列。
|
||||
// 解「從備份整批灌回、帶著對已退役索引的 is_embedded=1,永遠不被 backfill 碰到」這個坑。
|
||||
// body(皆選填):{ limit?:1-200(預設50), owner_id?, library?, since?, until? }。重複呼叫直到 remaining=0。
|
||||
// D69:每筆候選最多消耗一次 D1 row write,與 POST /entries/backfill-library 共用同一顆每日
|
||||
// 「背景維護 D1 寫入」額度(見 actions/maintenance-quota.ts)——額度用完會誠實回
|
||||
// quota_exceeded:true 並停手,不會把當天 D1 免費額度燒穿(2026-08-11 leo 逐行複核找到的破口)。
|
||||
embedRoutes.post('/reconcile', async (c) => {
|
||||
if (!embedEnabled(c.env)) {
|
||||
return c.json(
|
||||
{ success: false, error: 'embed module not enabled (need VECTORIZE + AI bindings)', capability_hint: OFF_HINT },
|
||||
409,
|
||||
);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
limit?: number | string;
|
||||
owner_id?: string;
|
||||
library?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
};
|
||||
const result = await reconcileEmbedGeneration(c.env, {
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
owner_id: body.owner_id || undefined,
|
||||
library: body.library || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
|
||||
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
|
||||
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
EmbedQueryFailedError,
|
||||
} from '../embed';
|
||||
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
|
||||
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../actions/library-backfill';
|
||||
|
||||
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -367,6 +368,64 @@ entryRoutes.patch('/deprecate-by-library', async (c) => {
|
||||
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
||||
});
|
||||
|
||||
// POST /entries/backfill-library — 標庫補存量(Arcrun#85 二次裁決/相關票 Arcrun#87,2026-08-11)。
|
||||
// body(必填 library + owner_id):{ library, owner_id, page_names?(string[],精準比對,
|
||||
// leo 定案的正解——見 actions/library-backfill.ts 檔頭「拿原稿遍歷」), entry_type?,
|
||||
// source_prefix?, page_name_prefix?(後兩者為過渡 fallback,精度不如 page_names),
|
||||
// since?, until?, limit?(1-500,預設100) }。
|
||||
// 冪等:只選「目前未標記 library」的候選;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
|
||||
// budget:與 /embed/reconcile 共用同一顆每日 D1 寫入額度(見 actions/maintenance-quota.ts)——
|
||||
// 兩者都是「多筆 D1 write、不打 AI」的背景維護操作,不共用額度的話補存量會把世代核對的閘繞過去。
|
||||
// base 對內容語意無知:不猜「這批該貼哪個庫」,呼叫端(ingest/#87)決定 library 與篩選條件;
|
||||
// owner_id 必填(同 /entries/deprecate-by-library 的既有防線——批次改一大片既有資料不准無租戶範圍地掃)。
|
||||
// 此路由必須在 '/:id' 之前註冊,否則 'backfill-library' 會被當成 id 參數。
|
||||
entryRoutes.post('/backfill-library', async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
library?: string;
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
page_names?: string[];
|
||||
source_prefix?: string;
|
||||
page_name_prefix?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
limit?: number | string;
|
||||
};
|
||||
const library = String(body.library ?? '').trim();
|
||||
const ownerId = String(body.owner_id ?? '').trim();
|
||||
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
|
||||
try {
|
||||
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
|
||||
library,
|
||||
owner_id: ownerId,
|
||||
entry_type: body.entry_type || undefined,
|
||||
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : undefined,
|
||||
source_prefix: body.source_prefix || undefined,
|
||||
page_name_prefix: body.page_name_prefix || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /entries/backfill-library/status?owner_id=&entry_type=&source_prefix=&page_name_prefix=&since=&until=
|
||||
// — 符合條件、目前未標記 library 的筆數(backfill 前後都能查,判斷還剩多少)。
|
||||
entryRoutes.get('/backfill-library/status', async (c) => {
|
||||
const status = await libraryBackfillStatus(c.env.DB, {
|
||||
owner_id: c.req.query('owner_id') || undefined,
|
||||
entry_type: c.req.query('entry_type') || undefined,
|
||||
source_prefix: c.req.query('source_prefix') || undefined,
|
||||
page_name_prefix: c.req.query('page_name_prefix') || undefined,
|
||||
since: c.req.query('since') ? Number(c.req.query('since')) : undefined,
|
||||
until: c.req.query('until') ? Number(c.req.query('until')) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
entryRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
+15
-1
@@ -24,6 +24,18 @@ export type Bindings = {
|
||||
// kbdb/src/actions/execution-log.ts DEFAULT_DAILY_LIMIT 說明)。未設 → 20000
|
||||
// (D1 100,000 rows written/日的 20%,留 80% 給知識卡 entries)。
|
||||
EXECUTION_LOG_DAILY_WRITE_LIMIT?: string;
|
||||
// embed backfill 每日軟上限(D68,2026-08-11:補算向量照時間新到舊、且每天有額度上限)。
|
||||
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日 10,000 免費 neurons(見頂層
|
||||
// wiki ops-facts.md);backfill 是背景低優先動作,自設軟上限不把當天額度燒光。未設 → 見
|
||||
// kbdb/src/embed.ts DEFAULT_BACKFILL_DAILY_LIMIT 說明(含選值算式,非拍腦袋)。
|
||||
EMBED_BACKFILL_DAILY_LIMIT?: string;
|
||||
// 背景維護寫入(reconcile 世代核對 + 標庫 backfill)共用的 D1 每日寫入軟上限
|
||||
// (Arcrun#85 D69 修法,2026-08-11:兩者都是「多筆 D1 row write、不打 AI」的操作,
|
||||
// 各自不設防都會單獨燒穿 D1 100,000 rows/日免費額度——reconcile 47 萬筆 candidate
|
||||
// ≈ 4.7 倍全日額度,已在票上實測;標庫 backfill 同樣是逐筆 D1 write,若各管各的,
|
||||
// 補標庫時會把 reconcile 的閘繞過去。兩者共用同一顆「今天還剩多少」計數器。
|
||||
// 未設 → 見 kbdb/src/actions/maintenance-quota.ts DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT。
|
||||
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT?: string;
|
||||
};
|
||||
|
||||
export type EntryType =
|
||||
@@ -35,7 +47,9 @@ export type EntryType =
|
||||
| 'workflow'
|
||||
| 'recipe_stat'
|
||||
| 'execution_log'
|
||||
| 'execution_log_usage';
|
||||
| 'execution_log_usage'
|
||||
| 'embed_backfill_usage'
|
||||
| 'kbdb_maintenance_usage';
|
||||
|
||||
export interface Entry {
|
||||
id: string;
|
||||
|
||||
+410
-108
@@ -1,130 +1,196 @@
|
||||
// embed backfill — D68(2026-08-11 leo 拍板:補算向量照時間新到舊、且每天有額度上限)測試。
|
||||
//
|
||||
// 測試策略比照 execution-log.test.ts/library-map.test.ts:真 SQLite(node:sqlite)套
|
||||
// migrations/0001_base.sql 原檔,比手刻假 DB 更硬——驗的是真實 SQL 語意(ORDER BY/WHERE/
|
||||
// JSON 函式),不是「以為 SQL 長這樣」。AI/VECTORIZE 仍是輕量假物件(Cloudflare binding,
|
||||
// 不是 SQL,沒有真 runtime 可套)。
|
||||
//
|
||||
// 覆蓋 D68 三條 + is_embedded 世代旗標坑,四項都要有實測輸出:
|
||||
// 1. 由新到舊:造 created_at 跨時間的候選,證明先被處理的是最新那幾筆
|
||||
// 2. 每日額度上限真的擋:cap 設小,跑到撞上限,證明它停手不再打 AI(不是繼續打)
|
||||
// 3. 帶著舊世代旗標(is_embedded=1 但對應已退役索引)的列補得回來
|
||||
// 4. 現有 idempotent/batching/reindex 行為不因本次改動而壞掉
|
||||
//
|
||||
// 本檔在 kbdb/tests/(牆外,非 kbdb/src|migrations),依 D38 kbdb-api-wall-guard 規則,
|
||||
// 所有直接對 SQLite 治具下 SQL 的行都集中在下面幾個 helper(每行標 kbdb-sql-ok 留痕)——
|
||||
// 這是**測試治具本身**(node:sqlite→D1 shim,模擬 D1 binding),不是牆外業務邏輯繞過 API。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { backfillEmbeddings, backfillStatus, embedEnabled } from '../src/embed';
|
||||
import type { Bindings, Entry } from '../src/types';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
backfillEmbeddings,
|
||||
backfillStatus,
|
||||
embedEnabled,
|
||||
reconcileEmbedGeneration,
|
||||
} from '../src/embed';
|
||||
import type { Bindings, Entry, EntryType } from '../src/types';
|
||||
|
||||
// ── Minimal in-memory fakes (no Workers runtime) ─────────────────────────────
|
||||
// The fake DB interprets only the 3 statement shapes backfill issues, by keyword:
|
||||
// SELECT * ... LIMIT ? OFFSET ? → candidate rows (embeddable & non-empty content;
|
||||
// +is_embedded=0 for normal backfill, any for reindex)
|
||||
// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids
|
||||
// SELECT COUNT(*) → count of matching candidates
|
||||
// embeddable = metadata.embed===true & non-empty content(reindex predicate)。
|
||||
function isEmbeddable(e: Entry): boolean {
|
||||
if (!e.content || e.content.trim() === '') return false;
|
||||
try {
|
||||
const m = JSON.parse(e.metadata_json ?? 'null');
|
||||
return m?.embed === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// normal backfill 額外要求 is_embedded=0(漏網補嵌)。
|
||||
function isCandidate(e: Entry): boolean {
|
||||
return e.is_embedded === 0 && isEmbeddable(e);
|
||||
}
|
||||
const CURRENT_MODEL = '@cf/baai/bge-m3'; // embed.ts DEFAULT_EMBED_MODEL(未 export,測試按文件字面核對)
|
||||
|
||||
function makeFakeDB(store: Entry[]) {
|
||||
const prepare = (sql: string) => {
|
||||
// reindex predicate 不含 "is_embedded = 0" → 依 SQL 判斷該用哪個 filter(對齊 embed.ts)。
|
||||
const pred = /is_embedded = 0/.test(sql) ? isCandidate : isEmbeddable;
|
||||
let bound: unknown[] = [];
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { bound = args; return stmt; },
|
||||
async all<T>() {
|
||||
// SELECT * ... LIMIT ? OFFSET ? (bound tail = [..., limit, offset])
|
||||
const offset = Number(bound[bound.length - 1]);
|
||||
const limit = Number(bound[bound.length - 2]);
|
||||
const results = store.filter(pred).slice(offset, offset + limit) as unknown as T[];
|
||||
return { results };
|
||||
},
|
||||
async first<T>() {
|
||||
// SELECT COUNT(*) as c ...
|
||||
const c = store.filter(pred).length;
|
||||
return { c } as unknown as T;
|
||||
},
|
||||
// ── 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:測試治具(node:sqlite→D1 shim)套 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:測試治具(node:sqlite→D1 shim)
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async run() {
|
||||
// UPDATE entries SET is_embedded = 1 WHERE id IN (...) → bound = ids
|
||||
const ids = new Set(bound.map(String));
|
||||
for (const e of store) if (ids.has(e.id)) e.is_embedded = 1;
|
||||
return { success: true };
|
||||
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
|
||||
return { success: true, meta: { changes: r.changes } };
|
||||
},
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
return s;
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function mkEntry(id: string, content: string | null, embed: boolean, is_embedded = 0): Entry {
|
||||
return {
|
||||
id, content, entry_type: 'workflow', owner_id: 'leo', 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 }), created_at: 1, updated_at: 1,
|
||||
};
|
||||
// ── 測試專用資料存取 helper:把所有直接下 SQL 的呼叫收斂到這裡(每行標記留痕)──────────
|
||||
function insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
|
||||
const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料
|
||||
e.id,
|
||||
e.content === undefined ? 'x' : e.content, // 區分「沒提供」(undefined→預設'x') 與「顯式 null」(保留 null)
|
||||
|
||||
(e.entry_type ?? 'workflow') as EntryType,
|
||||
e.owner_id ?? 'leo',
|
||||
e.content_hash ?? null,
|
||||
e.is_embedded ?? 0,
|
||||
e.metadata_json ?? JSON.stringify({ embed: true }),
|
||||
e.created_at,
|
||||
e.created_at,
|
||||
).run();
|
||||
}
|
||||
|
||||
function makeEnv(store: Entry[], withBindings: boolean): Bindings {
|
||||
async function getRow(db: D1Database, id: string): Promise<{ id: string; is_embedded: number; content_hash: string | null } | null> {
|
||||
return db.prepare('SELECT id, is_embedded, content_hash FROM entries WHERE id = ?').bind(id).first(); // kbdb-sql-ok:測試治具讀回斷言用
|
||||
}
|
||||
|
||||
async function listAllRows(db: D1Database): Promise<{ id: string; is_embedded: number; content_hash: string | null }[]> {
|
||||
const res = await db.prepare('SELECT id, is_embedded, content_hash FROM entries').all<{ id: string; is_embedded: number; content_hash: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用
|
||||
return res.results;
|
||||
}
|
||||
|
||||
async function listEmbeddedIds(db: D1Database): Promise<string[]> {
|
||||
const res = await db.prepare("SELECT id FROM entries WHERE is_embedded = 1").all<{ id: string }>(); // kbdb-sql-ok:測試治具讀回斷言用
|
||||
return res.results.map((r) => r.id);
|
||||
}
|
||||
|
||||
async function countUsageRows(db: D1Database): Promise<{ id: string; entry_type: string }[]> {
|
||||
const res = await db.prepare("SELECT id, entry_type FROM entries WHERE entry_type = 'embed_backfill_usage'").all<{ id: string; entry_type: string }>(); // kbdb-sql-ok:測試治具驗證「不新增表、單列 upsert」
|
||||
return res.results;
|
||||
}
|
||||
|
||||
function makeEnv(db: D1Database, opts: { withBindings?: boolean; dailyLimit?: string; maintenanceLimit?: string } = {}): Bindings {
|
||||
const withBindings = opts.withBindings ?? true;
|
||||
const upserts: { id: string }[] = [];
|
||||
const aiCalls: string[][] = [];
|
||||
const getByIdsCalls: string[][] = [];
|
||||
const vectorizeStore = new Set<string>(); // ids "present" in the current (fake) Vectorize index
|
||||
const env = {
|
||||
DB: makeFakeDB(store),
|
||||
DB: db,
|
||||
ENVIRONMENT: 'test',
|
||||
EMBED_BACKFILL_DAILY_LIMIT: opts.dailyLimit,
|
||||
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
|
||||
...(withBindings
|
||||
? {
|
||||
AI: { async run(_m: string, i: { text: string[] }) { aiCalls.push(i.text); return { data: i.text.map(() => [0.1, 0.2, 0.3]) }; } },
|
||||
VECTORIZE: { async upsert(v: { id: string }[]) { upserts.push(...v); return { count: v.length }; } },
|
||||
AI: {
|
||||
async run(_m: string, i: { text: string[] }) {
|
||||
aiCalls.push(i.text);
|
||||
return { data: i.text.map(() => [0.1, 0.2, 0.3]) };
|
||||
},
|
||||
},
|
||||
VECTORIZE: {
|
||||
async upsert(v: { id: string }[]) {
|
||||
upserts.push(...v);
|
||||
for (const x of v) vectorizeStore.add(x.id);
|
||||
return { count: v.length };
|
||||
},
|
||||
async getByIds(ids: string[]) {
|
||||
getByIdsCalls.push(ids);
|
||||
return ids.filter((id) => vectorizeStore.has(id)).map((id) => ({ id, values: [0.1] }));
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as unknown as Bindings;
|
||||
(env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__upserts = upserts;
|
||||
(env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__ai = aiCalls;
|
||||
const bag = env as unknown as {
|
||||
__upserts: unknown[]; __ai: unknown[]; __getByIds: unknown[];
|
||||
__seedVectorized: (ids: string[]) => void;
|
||||
};
|
||||
bag.__upserts = upserts;
|
||||
bag.__ai = aiCalls;
|
||||
bag.__getByIds = getByIdsCalls;
|
||||
bag.__seedVectorized = (ids: string[]) => { for (const id of ids) vectorizeStore.add(id); };
|
||||
return env;
|
||||
}
|
||||
|
||||
describe('backfillEmbeddings', () => {
|
||||
it('module off → enabled:false, no-op (誠實不假綠)', async () => {
|
||||
const store = [mkEntry('e1', 'hello', true)];
|
||||
const env = makeEnv(store, false);
|
||||
function aiCallsOf(env: Bindings): string[][] {
|
||||
return (env as unknown as { __ai: string[][] }).__ai;
|
||||
}
|
||||
function upsertsOf(env: Bindings): { id: string }[] {
|
||||
return (env as unknown as { __upserts: { id: string }[] }).__upserts;
|
||||
}
|
||||
function seedVectorized(env: Bindings, ids: string[]): void {
|
||||
(env as unknown as { __seedVectorized: (ids: string[]) => void }).__seedVectorized(ids);
|
||||
}
|
||||
|
||||
describe('backfillEmbeddings — 模組未開', () => {
|
||||
it('誠實不假綠:no-op,含新增的 quota 欄位', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', created_at: 1 });
|
||||
const env = makeEnv(db, { withBindings: false });
|
||||
expect(embedEnabled(env)).toBe(false);
|
||||
const r = await backfillEmbeddings(env);
|
||||
expect(r).toEqual({ enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 });
|
||||
expect(store[0].is_embedded).toBe(0); // untouched
|
||||
expect(r).toEqual({
|
||||
enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0,
|
||||
quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('embeds embeddable+is_embedded=0 entries, marks is_embedded=1, batches AI+upsert', async () => {
|
||||
const store = [
|
||||
mkEntry('e1', 'doorbell workflow', true),
|
||||
mkEntry('e2', 'notify workflow', true),
|
||||
mkEntry('e3', 'not tagged', false), // embed:false → not a candidate
|
||||
mkEntry('e4', 'already done', true, 1), // is_embedded=1 → not a candidate
|
||||
mkEntry('e5', ' ', true), // empty content → not embeddable
|
||||
];
|
||||
const env = makeEnv(store, true);
|
||||
describe('backfillEmbeddings — 基本行為(沿用既有覆蓋,改動後仍要綠)', () => {
|
||||
it('embeds embeddable+is_embedded=0 entries, marks is_embedded=1 + content_hash,批次 AI+upsert', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'doorbell workflow', created_at: 1 });
|
||||
insertEntry(db, { id: 'e2', content: 'notify workflow', created_at: 2 });
|
||||
insertEntry(db, { id: 'e3', content: 'not tagged', created_at: 3, metadata_json: JSON.stringify({ embed: false }) });
|
||||
insertEntry(db, { id: 'e4', content: 'already done', created_at: 4, is_embedded: 1 });
|
||||
insertEntry(db, { id: 'e5', content: null, created_at: 5 }); // NULL content → 排除,非本次改動範圍的既有行為
|
||||
const env = makeEnv(db);
|
||||
const r = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.processed).toBe(2); // only e1,e2
|
||||
expect(r.remaining).toBe(0); // nothing left embeddable
|
||||
expect(store.find((e) => e.id === 'e1')!.is_embedded).toBe(1);
|
||||
expect(store.find((e) => e.id === 'e2')!.is_embedded).toBe(1);
|
||||
expect(store.find((e) => e.id === 'e3')!.is_embedded).toBe(0);
|
||||
const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts;
|
||||
expect(upserts.map((u) => u.id).sort()).toEqual(['e1', 'e2']);
|
||||
const ai = (env as unknown as { __ai: string[][] }).__ai;
|
||||
expect(ai.length).toBe(1); // single batched AI.run for the whole batch
|
||||
expect(ai[0].length).toBe(2);
|
||||
expect(r.processed).toBe(2); // only e1, e2
|
||||
expect(r.remaining).toBe(0);
|
||||
const rows = await listAllRows(db);
|
||||
const byId = Object.fromEntries(rows.map((x) => [x.id, x]));
|
||||
expect(byId.e1.is_embedded).toBe(1);
|
||||
expect(byId.e1.content_hash).toBe(CURRENT_MODEL); // 世代戳記有寫
|
||||
expect(byId.e2.is_embedded).toBe(1);
|
||||
expect(byId.e3.is_embedded).toBe(0);
|
||||
expect(upsertsOf(env).map((u) => u.id).sort()).toEqual(['e1', 'e2']);
|
||||
expect(aiCallsOf(env).length).toBe(1);
|
||||
expect(aiCallsOf(env)[0].length).toBe(2);
|
||||
});
|
||||
|
||||
it('idempotent: re-run after all embedded processes nothing', async () => {
|
||||
const store = [mkEntry('e1', 'x', true)];
|
||||
const env = makeEnv(store, true);
|
||||
it('idempotent:全部嵌完後重跑不再處理', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'x', created_at: 1 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
await backfillEmbeddings(env);
|
||||
const r2 = await backfillEmbeddings(env);
|
||||
expect(r2.processed).toBe(0);
|
||||
expect(r2.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('batches via limit → remaining reported so caller can loop to zero', async () => {
|
||||
const store = [mkEntry('a', 'x', true), mkEntry('b', 'y', true), mkEntry('c', 'z', true)];
|
||||
const env = makeEnv(store, true);
|
||||
it('batches via limit → remaining 讓 caller 可重複呼叫到 0', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', content: 'x', created_at: 1 });
|
||||
insertEntry(db, { id: 'b', content: 'y', created_at: 2 });
|
||||
insertEntry(db, { id: 'c', content: 'z', created_at: 3 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
const r1 = await backfillEmbeddings(env, { limit: 2 });
|
||||
expect(r1.processed).toBe(2);
|
||||
expect(r1.remaining).toBe(1);
|
||||
@@ -133,34 +199,270 @@ describe('backfillEmbeddings', () => {
|
||||
expect(r2.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('reindex: 重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0(Arcrun#11)', async () => {
|
||||
// 三筆皆已 is_embedded=1(既有向量):正常 backfill 不會碰(pending=0),reindex 要全部重推
|
||||
// 讓事後建立的 Vectorize metadata index 收錄。
|
||||
const store = [
|
||||
mkEntry('a', 'x', true, 1), mkEntry('b', 'y', true, 1), mkEntry('c', 'z', true, 1),
|
||||
];
|
||||
const env = makeEnv(store, true);
|
||||
// 正常 backfill:沒有 is_embedded=0 → 什麼都不做(證明「不重推就補不到」)。
|
||||
it('reindex:重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0(Arcrun#11)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', content: 'x', created_at: 1, is_embedded: 1 });
|
||||
insertEntry(db, { id: 'b', content: 'y', created_at: 2, is_embedded: 1 });
|
||||
insertEntry(db, { id: 'c', content: 'z', created_at: 3, is_embedded: 1 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
const normal = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(normal.processed).toBe(0);
|
||||
// reindex 分頁:第一批 2 筆、remaining=1;第二批 1 筆、remaining=0。
|
||||
expect(normal.processed).toBe(0); // 沒有 is_embedded=0 → 什麼都不做
|
||||
const r1 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 0 });
|
||||
expect(r1.processed).toBe(2);
|
||||
expect(r1.remaining).toBe(1);
|
||||
const r2 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 2 });
|
||||
expect(r2.processed).toBe(1);
|
||||
expect(r2.remaining).toBe(0);
|
||||
const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts;
|
||||
expect(upserts.map((u) => u.id).sort()).toEqual(['a', 'b', 'c']);
|
||||
expect(upsertsOf(env).map((u) => u.id).sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('status reports pending/embedded counts', async () => {
|
||||
const store = [mkEntry('e1', 'x', true), mkEntry('e2', 'y', true, 1)];
|
||||
const env = makeEnv(store, true);
|
||||
it('status 回報 pending/embedded 計數', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'x', created_at: 1 });
|
||||
insertEntry(db, { id: 'e2', content: 'y', created_at: 2, is_embedded: 1 });
|
||||
const env = makeEnv(db);
|
||||
const s = await backfillStatus(env);
|
||||
// fake first() returns candidate count for pending; embedded query also runs through
|
||||
// the same COUNT fake, so this asserts the call path works (enabled:true).
|
||||
expect(s.enabled).toBe(true);
|
||||
expect(typeof s.pending).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('D68①:由新到舊排序(實測,不是推論)', () => {
|
||||
it('候選跨時間分佈時,先被嵌入的是 created_at 最新的那幾筆', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// 刻意亂序插入,證明排序看的是 created_at 不是插入順序 / id 字母序
|
||||
insertEntry(db, { id: 'old-2024', content: 'half year ago', created_at: 1_000 });
|
||||
insertEntry(db, { id: 'today', content: 'written today', created_at: 100_000 });
|
||||
insertEntry(db, { id: 'mid-2025', content: 'a few months ago', created_at: 50_000 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
// limit=1:一次只能處理一筆,若排序正確,該筆必須是 'today'(created_at 最大)
|
||||
const r = await backfillEmbeddings(env, { limit: 1 });
|
||||
expect(r.processed).toBe(1);
|
||||
const embedded = await listEmbeddedIds(db);
|
||||
expect(embedded).toEqual(['today']);
|
||||
expect(aiCallsOf(env)[0]).toEqual(['written today']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D68②:每日額度上限真的擋(把上限設小,跑到撞上限)', () => {
|
||||
it('額度耗盡後停手,不再繼續打 AI;未耗盡的仍優先保留最新的(額度截斷 + 排序疊加)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e-oldest', content: 'c1', created_at: 1 });
|
||||
insertEntry(db, { id: 'e-old', content: 'c2', created_at: 2 });
|
||||
insertEntry(db, { id: 'e-new', content: 'c3', created_at: 3 });
|
||||
insertEntry(db, { id: 'e-newest', content: 'c4', created_at: 4 });
|
||||
const env = makeEnv(db, { dailyLimit: '2' }); // 上限設得比候選數(4)小
|
||||
const r = await backfillEmbeddings(env, { limit: 100 });
|
||||
|
||||
// 停手,不是繼續打:AI 只被叫過一次,且只帶 2 筆文字(不是全部 4 筆)
|
||||
expect(aiCallsOf(env).length).toBe(1);
|
||||
expect(aiCallsOf(env)[0].length).toBe(2);
|
||||
expect(r.processed).toBe(2);
|
||||
expect(r.quota_limit).toBe(2);
|
||||
expect(r.quota_used_today).toBe(2);
|
||||
expect(r.quota_exceeded).toBe(true); // 還有候選但今天不再打 AI
|
||||
|
||||
// 被留下處理的兩筆是最新的(e-newest, e-new),不是隨機或最舊的
|
||||
const embedded = await listEmbeddedIds(db);
|
||||
expect(embedded.sort()).toEqual(['e-new', 'e-newest']);
|
||||
|
||||
// 再跑一次(同一天):額度已用完,processed=0,AI 呼叫次數仍是 1(沒有再打)
|
||||
const r2 = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(r2.processed).toBe(0);
|
||||
expect(r2.quota_exceeded).toBe(true);
|
||||
expect(aiCallsOf(env).length).toBe(1); // 沒有新增呼叫
|
||||
});
|
||||
|
||||
it('額度上限被拿掉時本測試會變紅(反向驗證:測試真的在測東西,不是恆真)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
for (let i = 1; i <= 5; i++) insertEntry(db, { id: `e${i}`, content: `c${i}`, created_at: i });
|
||||
// 不設 dailyLimit(用預設 1800,遠大於 5)→ 全部應被處理,模擬「上限被拿掉」的行為
|
||||
const env = makeEnv(db);
|
||||
const r = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(r.processed).toBe(5);
|
||||
expect(r.quota_exceeded).toBe(false);
|
||||
// 對照組:把上限設到比候選數小,行為必須不同(證明上一組「額度=2」的測試不是巧合)
|
||||
const db2 = makeSqliteD1();
|
||||
for (let i = 1; i <= 5; i++) insertEntry(db2, { id: `e${i}`, content: `c${i}`, created_at: i });
|
||||
const env2 = makeEnv(db2, { dailyLimit: '2' });
|
||||
const r2 = await backfillEmbeddings(env2, { limit: 100 });
|
||||
expect(r2.processed).toBe(2);
|
||||
expect(r2.processed).not.toBe(r.processed); // 有 cap vs 沒 cap 必須不同,否則 cap 沒在起作用
|
||||
});
|
||||
|
||||
it('額度計數跨呼叫累加,換日字串變動即歸零(不新增表,entries 單列 upsert)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'c1', created_at: 1 });
|
||||
insertEntry(db, { id: 'e2', content: 'c2', created_at: 2 });
|
||||
const env = makeEnv(db, { dailyLimit: '10' });
|
||||
const r1 = await backfillEmbeddings(env, { limit: 1 });
|
||||
expect(r1.quota_used_today).toBe(1);
|
||||
const r2 = await backfillEmbeddings(env, { limit: 1 });
|
||||
expect(r2.quota_used_today).toBe(2); // 累加,不是每次重算成當批數
|
||||
// 驗證只有一列計數器,且落在既有三表(entries),沒有新表
|
||||
const usageRows = await countUsageRows(db);
|
||||
expect(usageRows.length).toBe(1);
|
||||
expect(usageRows[0].id).toMatch(/^embed-backfill-usage:\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D68③:leo21c 資料還原情境——is_embedded=1 但對應已退役索引的列補得回來', () => {
|
||||
it('reconcile:確認在現行 index 的只補 content_hash,不打 AI', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// 模擬「這次修復之前」就已經正確嵌入現行 index 的資料:is_embedded=1、content_hash 從未寫過(NULL)
|
||||
insertEntry(db, { id: 'ok-legacy', content: 'x', created_at: 1, is_embedded: 1, content_hash: null });
|
||||
const env = makeEnv(db);
|
||||
seedVectorized(env, ['ok-legacy']); // 現行 index 真的有它
|
||||
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r.checked).toBe(1);
|
||||
expect(r.confirmed_current).toBe(1);
|
||||
expect(r.reset_to_pending).toBe(0);
|
||||
expect(aiCallsOf(env).length).toBe(0); // 沒有打 AI
|
||||
|
||||
const row = await getRow(db, 'ok-legacy');
|
||||
expect(row!.is_embedded).toBe(1); // 沒被誤重置
|
||||
expect(row!.content_hash).toBe(CURRENT_MODEL); // 補標記
|
||||
});
|
||||
|
||||
it('reconcile 揪出真正對舊索引的殘留 → 重置回 pending → 正常 backfill 真的把它補回來(端到端)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// leo21c 情境:從備份整批灌回,is_embedded=1 但這是對已退役 768 維索引說的;
|
||||
// 現行(1024 維)Vectorize index 裡沒有這個向量(不呼叫 seedVectorized)。
|
||||
insertEntry(db, { id: 'restored-stale', content: '從備份還原的舊卡片', created_at: 999, is_embedded: 1, content_hash: null });
|
||||
const env = makeEnv(db);
|
||||
|
||||
// step 1:reconcile 應該發現它不在現行 index,重置成 pending
|
||||
const r1 = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r1.checked).toBe(1);
|
||||
expect(r1.confirmed_current).toBe(0);
|
||||
expect(r1.reset_to_pending).toBe(1);
|
||||
const midRow = await getRow(db, 'restored-stale');
|
||||
expect(midRow!.is_embedded).toBe(0);
|
||||
expect(midRow!.content_hash).toBe(null);
|
||||
expect(r1.remaining).toBe(0); // 處理完,沒有更多待核對的了
|
||||
|
||||
// step 2:正常 backfill 現在會撿到它(因為 is_embedded=0 了),真的打 AI 補回來
|
||||
const r2 = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(r2.processed).toBe(1);
|
||||
expect(aiCallsOf(env).length).toBe(1);
|
||||
expect(aiCallsOf(env)[0]).toEqual(['從備份還原的舊卡片']);
|
||||
|
||||
const finalRow = await getRow(db, 'restored-stale');
|
||||
expect(finalRow!.is_embedded).toBe(1); // 補回來了
|
||||
expect(finalRow!.content_hash).toBe(CURRENT_MODEL); // 蓋上現行世代戳記,下次 reconcile 不會再選到它
|
||||
});
|
||||
|
||||
it('已經是現行世代(content_hash 等於現行模型)的列不會被 reconcile 重複選中', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'fresh', content: 'x', created_at: 1, is_embedded: 1, content_hash: CURRENT_MODEL });
|
||||
const env = makeEnv(db);
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r.checked).toBe(0);
|
||||
expect(r.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('模組未開 → 誠實回 enabled:false,不假裝', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'x', created_at: 1, is_embedded: 1 });
|
||||
const env = makeEnv(db, { withBindings: false });
|
||||
const r = await reconcileEmbedGeneration(env);
|
||||
expect(r).toEqual({
|
||||
enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0,
|
||||
scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arcrun#85 D69:reconcile 的 D1 寫入額度(與標庫 backfill 共用的計數器)', () => {
|
||||
it('額度耗盡後 reconcile 停手:不再寫 D1,quota_exceeded=true', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'r1', content: 'c1', created_at: 1, is_embedded: 1, content_hash: null });
|
||||
insertEntry(db, { id: 'r2', content: 'c2', created_at: 2, is_embedded: 1, content_hash: null });
|
||||
insertEntry(db, { id: 'r3', content: 'c3', created_at: 3, is_embedded: 1, content_hash: null });
|
||||
const env = makeEnv(db, { maintenanceLimit: '2' }); // 上限比候選數(3)小
|
||||
seedVectorized(env, ['r1', 'r2', 'r3']); // 全在現行 index(confirmed_current 路徑,仍是 D1 write)
|
||||
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r.scanned).toBe(3); // 掃到 3 筆候選
|
||||
expect(r.checked).toBe(2); // 但只處理了額度允許的 2 筆
|
||||
expect(r.confirmed_current).toBe(2);
|
||||
expect(r.quota_limit).toBe(2);
|
||||
expect(r.quota_used_today).toBe(2);
|
||||
expect(r.quota_exceeded).toBe(true);
|
||||
|
||||
// 只有 2 筆真的被寫回 content_hash(最新的兩筆,ORDER BY created_at DESC)
|
||||
const rows = await listAllRows(db);
|
||||
const byId = Object.fromEntries(rows.map((x) => [x.id, x]));
|
||||
expect(byId.r3.content_hash).toBe(CURRENT_MODEL);
|
||||
expect(byId.r2.content_hash).toBe(CURRENT_MODEL);
|
||||
expect(byId.r1.content_hash).toBe(null); // 額度用完,沒輪到它
|
||||
|
||||
// 再跑一次(同一天):額度已用完,checked=0
|
||||
const r2 = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r2.checked).toBe(0);
|
||||
expect(r2.quota_exceeded).toBe(true);
|
||||
});
|
||||
|
||||
it('額度上限被拿掉時本測試會變紅(反向驗證,同 D68② 手法)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
for (let i = 1; i <= 5; i++) insertEntry(db, { id: `r${i}`, content: `c${i}`, created_at: i, is_embedded: 1, content_hash: null });
|
||||
const env = makeEnv(db); // 不設 maintenanceLimit → 用預設 20000,遠大於 5,全部應被處理
|
||||
seedVectorized(env, ['r1', 'r2', 'r3', 'r4', 'r5']);
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r.checked).toBe(5);
|
||||
expect(r.quota_exceeded).toBe(false);
|
||||
|
||||
const db2 = makeSqliteD1();
|
||||
for (let i = 1; i <= 5; i++) insertEntry(db2, { id: `r${i}`, content: `c${i}`, created_at: i, is_embedded: 1, content_hash: null });
|
||||
const env2 = makeEnv(db2, { maintenanceLimit: '2' });
|
||||
seedVectorized(env2, ['r1', 'r2', 'r3', 'r4', 'r5']);
|
||||
const r2 = await reconcileEmbedGeneration(env2, { limit: 100 });
|
||||
expect(r2.checked).toBe(2);
|
||||
expect(r2.checked).not.toBe(r.checked); // 有 cap vs 沒 cap 必須不同,否則 cap 沒在作用
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arcrun#85:「挑哪一批」可以從外面指定(SelectionCriteria:since/until/library)', () => {
|
||||
it('backfillEmbeddings 帶 since/until 只補時間窗內的候選', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'too-old', content: 'x', created_at: 100 });
|
||||
insertEntry(db, { id: 'in-window-1', content: 'y', created_at: 500 });
|
||||
insertEntry(db, { id: 'in-window-2', content: 'z', created_at: 800 });
|
||||
insertEntry(db, { id: 'too-new', content: 'w', created_at: 1500 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
const r = await backfillEmbeddings(env, { limit: 100, since: 400, until: 1000 });
|
||||
expect(r.processed).toBe(2);
|
||||
expect((await listEmbeddedIds(db)).sort()).toEqual(['in-window-1', 'in-window-2']);
|
||||
});
|
||||
|
||||
it('backfillEmbeddings 帶 library 只補該庫的候選(未標記歸 general)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'finance-1', content: 'x', created_at: 1, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
|
||||
insertEntry(db, { id: 'hr-1', content: 'y', created_at: 2, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||||
insertEntry(db, { id: 'untagged', content: 'z', created_at: 3 }); // 無 library → general
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
const r = await backfillEmbeddings(env, { limit: 100, library: 'finance' });
|
||||
expect(r.processed).toBe(1);
|
||||
expect(await listEmbeddedIds(db)).toEqual(['finance-1']);
|
||||
|
||||
const r2 = await backfillEmbeddings(env, { limit: 100, library: 'general' });
|
||||
expect(r2.processed).toBe(1);
|
||||
expect((await listEmbeddedIds(db)).sort()).toEqual(['finance-1', 'untagged']);
|
||||
});
|
||||
|
||||
it('reconcile 帶 since/until/library 同樣受篩選(同一套 SelectionCriteria,非獨立實作)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'old', content: 'x', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
|
||||
insertEntry(db, { id: 'new', content: 'y', created_at: 100, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
|
||||
insertEntry(db, { id: 'other-lib', content: 'z', created_at: 100, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||||
const env = makeEnv(db);
|
||||
seedVectorized(env, ['old', 'new', 'other-lib']);
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100, library: 'finance', since: 50 });
|
||||
expect(r.checked).toBe(1);
|
||||
const row = await getRow(db, 'new');
|
||||
expect(row!.content_hash).toBe(CURRENT_MODEL);
|
||||
const oldRow = await getRow(db, 'old');
|
||||
expect(oldRow!.content_hash).toBe(null); // 在時間窗外,沒被動到
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// 標庫 backfill(Arcrun#85 二次裁決,2026-08-11)測試。
|
||||
//
|
||||
// 測試策略比照 embed-backfill.test.ts:真 SQLite(node:sqlite)套 migrations/0001_base.sql
|
||||
// 原檔,驗真實 SQL 語意(json_set/WHERE/LIMIT),不是「以為 SQL 長這樣」。
|
||||
//
|
||||
// 覆蓋:
|
||||
// 1. 只補「符合條件、目前未標記 library」的候選;已標記的不動(冪等)
|
||||
// 2. owner_id 必填(缺了要拋錯,防「補錯 owner 等於白做」——2026-08-11 leo 直令)
|
||||
// 3. source_prefix/page_name_prefix/since/until 篩選條件真的在篩
|
||||
// 4. 與 reconcileEmbedGeneration 共用同一顆每日 D1 寫入額度(D69 的核心訴求:
|
||||
// 補標不能把世代核對的閘繞過去,反之亦然)
|
||||
//
|
||||
// 本檔在 kbdb/tests/(牆外),依 D38 kbdb-api-wall-guard 規則,直接對 SQLite 治具下 SQL
|
||||
// 的行集中在 helper(測試治具本身,非牆外業務邏輯繞過 API,每行標 kbdb-sql-ok 留痕)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../src/actions/library-backfill';
|
||||
import { reconcileEmbedGeneration } from '../src/embed';
|
||||
import type { Bindings, Entry, EntryType } from '../src/types';
|
||||
|
||||
// ── node:sqlite → D1 介面最小 adapter(同 embed-backfill.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:測試治具(node:sqlite→D1 shim)套 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:測試治具(node:sqlite→D1 shim)
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async run() {
|
||||
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 insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
|
||||
const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, page_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料
|
||||
e.id,
|
||||
e.content === undefined ? 'x' : e.content,
|
||||
(e.entry_type ?? 'block') as EntryType,
|
||||
e.owner_id ?? 'bfezv28v',
|
||||
e.content_hash ?? null,
|
||||
e.is_embedded ?? 0,
|
||||
e.metadata_json === undefined ? null : e.metadata_json,
|
||||
e.page_name ?? null,
|
||||
e.created_at,
|
||||
e.created_at,
|
||||
).run();
|
||||
}
|
||||
|
||||
async function getLibrary(db: D1Database, id: string): Promise<string | null> {
|
||||
const row = await db.prepare("SELECT json_extract(metadata_json, '$.library') AS library FROM entries WHERE id = ?").bind(id).first<{ library: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用
|
||||
return row?.library ?? null;
|
||||
}
|
||||
|
||||
function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
|
||||
return {
|
||||
DB: db,
|
||||
ENVIRONMENT: 'test',
|
||||
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
describe('backfillEntryLibraryTags — 基本行為', () => {
|
||||
it('只標記符合條件、目前未標記 library 的候選;已標記的不動', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1 }); // 無 metadata_json → 未標記
|
||||
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({}) }); // 有 metadata_json 但無 library
|
||||
insertEntry(db, { id: 'c', created_at: 3, metadata_json: JSON.stringify({ library: 'hr' }) }); // 已標記,不該被動
|
||||
const env = makeEnv(db);
|
||||
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||||
expect(r.tagged).toBe(2);
|
||||
expect(r.remaining).toBe(0);
|
||||
expect(await getLibrary(db, 'a')).toBe('finance');
|
||||
expect(await getLibrary(db, 'b')).toBe('finance');
|
||||
expect(await getLibrary(db, 'c')).toBe('hr'); // 未被覆寫
|
||||
});
|
||||
|
||||
it('冪等:全部標記完後重跑不再處理', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1 });
|
||||
const env = makeEnv(db);
|
||||
await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||||
const r2 = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||||
expect(r2.tagged).toBe(0);
|
||||
expect(r2.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('owner_id 缺了要拋錯(防補錯 owner 等於白做,2026-08-11 leo 直令)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1 });
|
||||
const env = makeEnv(db);
|
||||
await expect(
|
||||
backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: '' }),
|
||||
).rejects.toThrow(/owner_id/);
|
||||
});
|
||||
|
||||
it('library 缺了要拋錯', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const env = makeEnv(db);
|
||||
await expect(
|
||||
backfillEntryLibraryTags(db, env, { library: '', owner_id: 'bfezv28v' }),
|
||||
).rejects.toThrow(/library/);
|
||||
});
|
||||
|
||||
it('owner_id 篩選:只動指定租戶的資料,其他租戶不受影響(跨租戶隔離)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'mine', created_at: 1, owner_id: 'bfezv28v' });
|
||||
insertEntry(db, { id: 'theirs', created_at: 2, owner_id: 'someone-else' });
|
||||
const env = makeEnv(db);
|
||||
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||||
expect(r.tagged).toBe(1);
|
||||
expect(await getLibrary(db, 'mine')).toBe('finance');
|
||||
expect(await getLibrary(db, 'theirs')).toBe(null); // 別的租戶完全沒被動到
|
||||
});
|
||||
|
||||
it('source_prefix/page_name_prefix/since/until 篩選真的在篩', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'match-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/kb/foo.md' }) });
|
||||
insertEntry(db, { id: 'other-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/other/bar.md' }) });
|
||||
const r1 = await backfillEntryLibraryTags(db, makeEnv(db), {
|
||||
library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea://Leo/kb/',
|
||||
});
|
||||
expect(r1.tagged).toBe(1);
|
||||
expect(await getLibrary(db, 'match-source')).toBe('kb');
|
||||
expect(await getLibrary(db, 'other-source')).toBe(null);
|
||||
|
||||
const db2 = makeSqliteD1();
|
||||
insertEntry(db2, { id: 'in-window', created_at: 500, page_name: 'wiki/foo' });
|
||||
insertEntry(db2, { id: 'out-window', created_at: 5000, page_name: 'wiki/bar' });
|
||||
const r2 = await backfillEntryLibraryTags(db2, makeEnv(db2), {
|
||||
library: 'wiki', owner_id: 'bfezv28v', page_name_prefix: 'wiki/', since: 0, until: 1000,
|
||||
});
|
||||
expect(r2.tagged).toBe(1);
|
||||
expect(await getLibrary(db2, 'in-window')).toBe('wiki');
|
||||
expect(await getLibrary(db2, 'out-window')).toBe(null);
|
||||
});
|
||||
|
||||
it('page_names 精準比對(leo 定案的正解:拿 Gitea 原稿卡名逐批遍歷點名)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1, page_name: 'card-alpha' });
|
||||
insertEntry(db, { id: 'b', created_at: 2, page_name: 'card-beta' });
|
||||
insertEntry(db, { id: 'c', created_at: 3, page_name: 'card-gamma' }); // 不在點名清單內
|
||||
const r = await backfillEntryLibraryTags(db, makeEnv(db), {
|
||||
library: 'kb', owner_id: 'bfezv28v', page_names: ['card-alpha', 'card-beta'],
|
||||
});
|
||||
expect(r.tagged).toBe(2);
|
||||
expect(await getLibrary(db, 'a')).toBe('kb');
|
||||
expect(await getLibrary(db, 'b')).toBe('kb');
|
||||
expect(await getLibrary(db, 'c')).toBe(null); // 沒被點名,不動
|
||||
});
|
||||
|
||||
it('libraryBackfillStatus 回報待補標筆數', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1 });
|
||||
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({ library: 'hr' }) });
|
||||
const s = await libraryBackfillStatus(db, { owner_id: 'bfezv28v' });
|
||||
expect(s.pending).toBe(1); // 只有 'a' 未標記
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arcrun#85 D69:標庫 backfill 與 reconcile 共用同一顆 D1 每日寫入額度', () => {
|
||||
it('reconcile 先消耗額度 → 標庫 backfill 看到的剩餘額度真的變少', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// reconcile 的候選:is_embedded=1 且 content_hash 非現行世代
|
||||
// library 已標記('hr')→ 不會被下面的標庫 backfill 選中,讓兩種候選池互不重疊,
|
||||
// 才能單純驗證「額度共用」本身,不被「標庫候選也吃到 reconcile 資料」干擾。
|
||||
insertEntry(db, { id: 'reconcile-1', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||||
insertEntry(db, { id: 'reconcile-2', created_at: 2, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||||
// 標庫的候選:未標記 library
|
||||
insertEntry(db, { id: 'tag-1', created_at: 3 });
|
||||
insertEntry(db, { id: 'tag-2', created_at: 4 });
|
||||
insertEntry(db, { id: 'tag-3', created_at: 5 });
|
||||
|
||||
const maintenanceLimit = '3'; // 5 個候選(2 reconcile + 3 tag),額度只夠 3 個
|
||||
const reconcileEnv = {
|
||||
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
|
||||
AI: { async run() { return { data: [] }; } },
|
||||
VECTORIZE: {
|
||||
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); }, // 全部視為現行 index 已有
|
||||
async upsert() { return { count: 0 }; },
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
|
||||
// 先跑 reconcile:吃掉 2 筆額度(3 - 2 = 1 剩)
|
||||
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
|
||||
expect(rc.checked).toBe(2);
|
||||
expect(rc.quota_used_today).toBe(2);
|
||||
|
||||
// 標庫 backfill 用同一顆 DB/同一個每日上限:只剩 1 筆額度可用,即使候選有 3 筆
|
||||
const tagEnv = makeEnv(db, { maintenanceLimit });
|
||||
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
|
||||
expect(tagResult.scanned).toBe(3); // 3 筆候選都掃到了
|
||||
expect(tagResult.tagged).toBe(1); // 但只剩 1 筆額度,只標了 1 筆
|
||||
expect(tagResult.quota_exceeded).toBe(true);
|
||||
expect(tagResult.quota_used_today).toBe(3); // 2(reconcile)+ 1(本次)= 3,額度用滿
|
||||
});
|
||||
|
||||
it('反過來也一樣:標庫 backfill 先消耗額度 → reconcile 看到的剩餘額度真的變少', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'tag-1', created_at: 1 });
|
||||
insertEntry(db, { id: 'tag-2', created_at: 2 });
|
||||
insertEntry(db, { id: 'reconcile-1', created_at: 3, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true }) });
|
||||
|
||||
const maintenanceLimit = '2';
|
||||
const tagEnv = makeEnv(db, { maintenanceLimit });
|
||||
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
|
||||
expect(tagResult.tagged).toBe(2); // 額度剛好夠標完兩筆
|
||||
|
||||
const reconcileEnv = {
|
||||
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
|
||||
AI: { async run() { return { data: [] }; } },
|
||||
VECTORIZE: {
|
||||
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); },
|
||||
async upsert() { return { count: 0 }; },
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
|
||||
expect(rc.scanned).toBe(1); // 有 1 筆候選
|
||||
expect(rc.checked).toBe(0); // 但額度已被標庫 backfill 用光,reconcile 一筆都動不了
|
||||
expect(rc.quota_exceeded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ensureFreshLibraryMaps,
|
||||
LIBRARY_MAP_SLOTS,
|
||||
} from '../src/actions/library-map';
|
||||
import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud';
|
||||
import { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud';
|
||||
import { createEntry } from '../src/actions/entry-crud';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
@@ -292,6 +292,39 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動
|
||||
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
|
||||
});
|
||||
|
||||
it('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
|
||||
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 status,recomputeLibraryMap 只算
|
||||
// active——只要庫裡混了 superseded triplet,兩邊算出來的數字永遠對不上,
|
||||
// ensureFreshLibraryMaps 就永遠判定 stale,每次讀地圖都重算、每次都新建一筆 record。
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰
|
||||
|
||||
const { app, env } = makeApp(db);
|
||||
|
||||
// 第一次讀:資料是新的(從沒 recompute 過),觸發一次重算是正常的。
|
||||
const first = await app.request('/map', {}, env);
|
||||
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); // 只算 active 那筆
|
||||
|
||||
const countAfterFirst = (await searchByTemplate(db, 'library_map')).length;
|
||||
|
||||
// 第二次讀:中間沒有任何寫入動作。修好之前,這裡會再次判定 stale 並多新建一筆 record。
|
||||
const second = await app.request('/map', {}, env);
|
||||
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
|
||||
|
||||
const countAfterSecond = (await searchByTemplate(db, 'library_map')).length;
|
||||
expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record
|
||||
|
||||
// 第三次也一樣,多讀幾次確認不是巧合。
|
||||
await app.request('/map', {}, env);
|
||||
const countAfterThird = (await searchByTemplate(db, 'library_map')).length;
|
||||
expect(countAfterThird).toBe(countAfterFirst);
|
||||
});
|
||||
|
||||
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// 搜尋框裡的 `%` 與 `_` 是「要找的字」,不是萬用字元 —— Arcrun#94(2026-08-12)
|
||||
//
|
||||
// 病徵(leo 回報):搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字**無關**的東西。
|
||||
// 根因:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有兩個
|
||||
// 萬用字元 `%`/`_` 且**沒有預設跳脫字元** ⇒ 使用者打的符號被當成 pattern 語法。
|
||||
//
|
||||
// 舊病,不是 08-10 斷詞(search-tokenize.test.ts)引進的:pattern 從來就是這樣拼的。
|
||||
// 之前關鍵字搜尋幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
|
||||
//
|
||||
// 測試策略:**用真 SQLite 跑真的 SQL**(node:sqlite,與 library-map/embed-backfill 同款 adapter)。
|
||||
// 只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
|
||||
// 每組驗收都同時跑「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡(legacyPattern)。
|
||||
//
|
||||
// 註:直接對 SQLite 治具下 SQL 的行集中在下面的 helper(測試治具本身,非牆外業務邏輯繞過
|
||||
// API),每行標 kbdb-sql-ok 留痕——與 embed-backfill/library-backfill 等既有測試同慣例。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
escapeLikeLiteral,
|
||||
buildContentLike,
|
||||
buildSearchScore,
|
||||
searchEntries,
|
||||
createEntry,
|
||||
} from '../src/actions/entry-crud';
|
||||
|
||||
// ── node:sqlite → D1 最小 adapter ────────────────────────────────────────────
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok:記憶體測試替身,非真 KBDB D1
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
|
||||
function stmt(sql: string, params: unknown[]) {
|
||||
return {
|
||||
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||
async all<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async first<T>() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async run() { raw.prepare(sql).run(...(params as never[])); return { success: true }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
};
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
/** 舊寫法(本次修掉的那個):使用者輸入直接內插、LIKE 不帶 ESCAPE。前後對照用。 */
|
||||
const legacyPattern = (q: string) => `%${q}%`;
|
||||
|
||||
/** 對真 SQLite 跑一次 `content LIKE ?`,回命中的 content(要不要帶 ESCAPE 可選)。 */
|
||||
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
|
||||
const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
|
||||
const res = await db
|
||||
.prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
|
||||
.bind(pattern)
|
||||
.all<{ content: string }>();
|
||||
return (res.results ?? []).map((x) => x.content);
|
||||
}
|
||||
|
||||
/** 把 searchEntries 送出的 SQL 側錄下來(不改行為,只是中間插一層)。 */
|
||||
function sqlSpy(db: D1Database): { spy: D1Database; sqls: string[] } {
|
||||
const sqls: string[] = [];
|
||||
const spy = {
|
||||
prepare: (sql: string) => { sqls.push(sql); return db.prepare(sql); }, // kbdb-sql-ok:測試治具側錄,轉呼叫同一顆治具 DB
|
||||
} as unknown as D1Database;
|
||||
return { spy, sqls };
|
||||
}
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限(承 2026-08-03 的 500 修復)
|
||||
|
||||
// 一組刻意設計的語料:每一筆都用來分辨「字面命中」與「萬用字元誤中」。
|
||||
const CORPUS = [
|
||||
'毛利率 100% 達成', // 含字面 %
|
||||
'共有 100 個待辦項目', // 含 100 但不含 %,`%100%%` 會誤中它
|
||||
'owner_id 是租戶隔離的欄位', // 含字面 _
|
||||
'ownerXid 是打錯的欄位名', // `_` 當萬用字元才會中
|
||||
'路徑 C:\\_temp 底下', // 含字面「反斜線+底線」(跳脫字元本身 + 萬用字元)
|
||||
'路徑 C:\\Xtemp 底下', // 反斜線後接任一字元——`_` 漏成萬用字元才會中
|
||||
'完全無關的一筆內容', // 對照組:什麼都不該中
|
||||
];
|
||||
|
||||
async function seeded(): Promise<D1Database> {
|
||||
const db = makeSqliteD1();
|
||||
for (const [i, content] of CORPUS.entries()) {
|
||||
await createEntry(db, { id: `e${i}`, content, entry_type: 'block', owner_id: 'leo' });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('① 前後對照:使用者打什麼字,就照那些字找', () => {
|
||||
it('`100%`:舊寫法把 % 當萬用字元、連「100 個待辦」都撈回來;現行只回真的含 100% 的', async () => {
|
||||
const db = await seeded();
|
||||
const before = await likeHits(db, legacyPattern('100%'), false);
|
||||
const after = await likeHits(db, buildContentLike('100%').params[0], true);
|
||||
|
||||
expect(before).toEqual(['毛利率 100% 達成', '共有 100 個待辦項目']); // ← 病徵:多了不相干的
|
||||
expect(after).toEqual(['毛利率 100% 達成']); // ← 只有真的含「100%」的
|
||||
});
|
||||
|
||||
it('`owner_id`:舊寫法 _ 匹配任一字元、把 ownerXid 也撈回來;現行只回字面相符的', async () => {
|
||||
const db = await seeded();
|
||||
expect(await likeHits(db, legacyPattern('owner_id'), false)).toEqual([
|
||||
'owner_id 是租戶隔離的欄位',
|
||||
'ownerXid 是打錯的欄位名', // ← 病徵
|
||||
]);
|
||||
expect(await likeHits(db, buildContentLike('owner_id').params[0], true)).toEqual([
|
||||
'owner_id 是租戶隔離的欄位',
|
||||
]);
|
||||
});
|
||||
|
||||
it('只打一個 `%` 或 `_`:舊寫法把**整個庫**倒回來(leo 回報的那個畫面)', async () => {
|
||||
const db = await seeded();
|
||||
// `%%%` 匹配任何字串;`%_%` 只要有一個字元就中 ⇒ 兩者都等於「全庫」
|
||||
expect(await likeHits(db, legacyPattern('%'), false)).toHaveLength(CORPUS.length);
|
||||
expect(await likeHits(db, legacyPattern('_'), false)).toHaveLength(CORPUS.length);
|
||||
|
||||
// 現行:只回真的含那個字元的(`_` 有兩筆——欄位名那筆與路徑那筆,兩筆都是字面命中)
|
||||
expect(await likeHits(db, buildContentLike('%').params[0], true)).toEqual(['毛利率 100% 達成']);
|
||||
expect(await likeHits(db, buildContentLike('_').params[0], true)).toEqual([
|
||||
'owner_id 是租戶隔離的欄位',
|
||||
'路徑 C:\\_temp 底下',
|
||||
]);
|
||||
});
|
||||
|
||||
it('走完整搜尋路徑(searchEntries,含斷詞與算分)結果一致——不是只有底層函式對', async () => {
|
||||
const db = await seeded();
|
||||
expect((await searchEntries(db, '100%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
|
||||
expect((await searchEntries(db, 'owner_id', 'leo')).map((e) => e.content)).toEqual([
|
||||
'owner_id 是租戶隔離的欄位',
|
||||
]);
|
||||
// 單打一個符號:以前是全庫,現在是「真的含那個字的那一筆」
|
||||
expect((await searchEntries(db, '%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
|
||||
expect((await searchEntries(db, '_', 'leo')).map((e) => e.content).sort()).toEqual(
|
||||
['owner_id 是租戶隔離的欄位', '路徑 C:\\_temp 底下'].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('② 邊界:跳脫字元本身(`\\`)也必須跳脫', () => {
|
||||
// 為什麼這組必須存在:宣告了 ESCAPE 之後,`\` 就變成 pattern 裡有意義的字元。
|
||||
// 只跳脫 % 和 _、不跳脫 `\`,等於用新的漏洞換掉舊的——而且更難發現,因為它
|
||||
// **不會報錯**,只會靜靜地把後面那個字吃掉、去找一個使用者沒打過的字串。
|
||||
const halfDone = (q: string) => `%${q.replace(/[%_]/g, (c) => '\\' + c)}%`; // 只跳脫 %/_ 的假想修法
|
||||
|
||||
it('打 `C:\\`:不跳脫反斜線的話尾巴變成「字面 %」,反而找不到任何真正含 `C:\\` 的內容', async () => {
|
||||
const db = await seeded();
|
||||
// pattern `%C:\%` ⇒ 尾巴的 `\%` 被讀成「字面的 %」⇒ 實際去找 `C:%`,庫裡沒有 ⇒ 全漏
|
||||
expect(await likeHits(db, halfDone('C:\\'), true)).toEqual([]);
|
||||
expect(await likeHits(db, buildContentLike('C:\\').params[0], true)).toEqual([
|
||||
'路徑 C:\\_temp 底下',
|
||||
'路徑 C:\\Xtemp 底下',
|
||||
]);
|
||||
});
|
||||
|
||||
it('打 `C:\\_temp`:反斜線沒跳脫 ⇒ 它把 `_` 的跳脫吃掉,萬用字元漏回來、撈到不相干的', async () => {
|
||||
const db = await seeded();
|
||||
// `%C:\\_temp%`:`\\` 先被讀成「字面 \」,後面那個 `_` 就變回萬用字元 ⇒ C:\Xtemp 也中
|
||||
expect(await likeHits(db, halfDone('C:\\_temp'), true)).toEqual([
|
||||
'路徑 C:\\_temp 底下',
|
||||
'路徑 C:\\Xtemp 底下', // ← 使用者沒打過這個字
|
||||
]);
|
||||
expect(await likeHits(db, buildContentLike('C:\\_temp').params[0], true)).toEqual([
|
||||
'路徑 C:\\_temp 底下',
|
||||
]);
|
||||
});
|
||||
|
||||
it('打 `100\\%`:三個都不跳脫 ⇒ `\\%` 被讀成「字面 %」⇒ 去找 `100%`,跟他打的不一樣', async () => {
|
||||
const db = await seeded();
|
||||
// 原始寫法(一個都不跳脫)= pattern `%100\%%`:`\%`=字面 %、尾巴那個 `%`=萬用字元
|
||||
expect(await likeHits(db, legacyPattern('100\\%'), true)).toEqual(['毛利率 100% 達成']); // ← 找錯東西
|
||||
// 正解:庫裡沒有字面的 `100\%` ⇒ 就該零命中,而不是拿別的東西充數
|
||||
expect(await likeHits(db, buildContentLike('100\\%').params[0], true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('escapeLikeLiteral 只碰這三個字元,且不會把自己剛加的跳脫再跳脫一次', () => {
|
||||
expect(escapeLikeLiteral('100%')).toBe('100\\%');
|
||||
expect(escapeLikeLiteral('owner_id')).toBe('owner\\_id');
|
||||
expect(escapeLikeLiteral('C:\\')).toBe('C:\\\\');
|
||||
expect(escapeLikeLiteral('%_\\')).toBe('\\%\\_\\\\'); // 三個各自跳脫一次,不是兩次
|
||||
// LIKE 沒有 [] ? * 這些萬用字元(那是 GLOB/別的方言)⇒ 不該白白吃掉 byte 預算
|
||||
expect(escapeLikeLiteral('a[b]?c*d 中文')).toBe('a[b]?c*d 中文');
|
||||
});
|
||||
});
|
||||
|
||||
describe('③ 每個 LIKE 都要帶 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面', () => {
|
||||
it('buildContentLike/buildSearchScore 產生的謂詞都含 ESCAPE', () => {
|
||||
for (const c of buildContentLike('100%').conds) expect(c).toContain("ESCAPE '\\'");
|
||||
for (const c of buildContentLike('a'.repeat(200)).conds) expect(c).toContain("ESCAPE '\\'");
|
||||
expect(buildSearchScore('Gemini 逃生口').scoreExpr).toContain("ESCAPE '\\'");
|
||||
expect(buildSearchScore('。。。').scoreExpr).toContain("ESCAPE '\\'"); // 一個詞都拆不出來的退路
|
||||
});
|
||||
|
||||
it('沒有任何 `content LIKE ?` 是裸的(漏掉一個就等於那條路沒修)', async () => {
|
||||
const { spy, sqls } = sqlSpy(await seeded());
|
||||
for (const q of ['100%', 'Gemini 逃生口', '。。。', 'a'.repeat(200)]) await searchEntries(spy, q, 'leo');
|
||||
expect(sqls.length).toBeGreaterThan(0);
|
||||
for (const sql of sqls) expect(sql.match(/content LIKE \?(?! ESCAPE)/g) ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ESCAPE 宣告本身是合法 SQL(D1=SQLite;真的跑得起來,不是形狀對而已)', async () => {
|
||||
const db = await seeded();
|
||||
await expect(likeHits(db, '%100\\%%', true)).resolves.toEqual(['毛利率 100% 達成']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('④ 不退化:不含 % _ \\ 的查詢,行為與修改前逐字相同', () => {
|
||||
it('pattern 一個字都沒變(跳脫對這些字串是恆等變換)', () => {
|
||||
for (const q of ['語意檢索', 'arcrun', 'Gemini 逃生口', '為什麼今天額度用完']) {
|
||||
expect(escapeLikeLiteral(q)).toBe(q);
|
||||
}
|
||||
expect(buildContentLike('語意檢索').params).toEqual(['%語意檢索%']);
|
||||
expect(buildSearchScore('arcrun').scoreParams).toEqual(['%arcrun%']);
|
||||
expect(buildSearchScore('語意檢索').legacyShape).toBe(true); // 最熱路徑仍是單一 LIKE
|
||||
});
|
||||
|
||||
it('斷詞(08-10,Arcrun#84 已判定留下)沒被動到:問句照樣拆得開', () => {
|
||||
expect(buildSearchScore('Gemini 逃生口').scoreParams).toEqual(
|
||||
expect.arrayContaining(['%Gemini%', '%逃生口%']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('⑤ 跳脫會變長 ⇒ byte 預算要用「跳脫後」的長度算,否則退回 2026-08-03 那個 500', () => {
|
||||
it('滿是 % 的長查詢,每個 pattern 仍在 D1 的 50 bytes 上限內', () => {
|
||||
const qs = [
|
||||
'%'.repeat(200), // 每個字元跳脫後變 2 bytes
|
||||
'_'.repeat(60),
|
||||
'\\'.repeat(60),
|
||||
`${'%'.repeat(30)}中文${'_'.repeat(30)}`,
|
||||
'a'.repeat(24) + '%'.repeat(24), // 卡在舊上限附近的混合
|
||||
];
|
||||
for (const q of qs) {
|
||||
for (const p of buildContentLike(q).params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
const plan = buildSearchScore(q);
|
||||
expect(plan.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
|
||||
for (const p of plan.scoreParams) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
}
|
||||
});
|
||||
|
||||
it('48 個 `%`(跳脫前剛好在舊上限內)不會產生 98 bytes 的 pattern', () => {
|
||||
const q = '%'.repeat(48);
|
||||
expect(bytes(q)).toBe(48); // 用舊的算法看,它「在上限內」
|
||||
const m = buildContentLike(q);
|
||||
expect(m.split).toBe(true); // 用跳脫後的長度看,它必須被拆開
|
||||
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
});
|
||||
|
||||
it('拆片段仍切在字元邊界上,不會把跳脫序列切成半個', async () => {
|
||||
const db = await seeded();
|
||||
for (const p of buildContentLike(`${'%'.repeat(40)}中文${'_'.repeat(40)}`).params) {
|
||||
expect(p).not.toContain('\uFFFD');
|
||||
// 切壞的跳脫序列(尾巴是落單的 `\`)會讓 SQLite 把後面的 `%` 讀成字面 ⇒ 語意錯掉
|
||||
expect(/(^|[^\\])(\\\\)*\\%$/.test(p)).toBe(false);
|
||||
await expect(likeHits(db, p, true)).resolves.toBeDefined(); // 真的送得進 SQLite
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,16 @@ import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||
const MAX_PATTERN = 50; // D1 上限
|
||||
// 謂詞字串在 Arcrun#94 多了 ESCAPE 宣告(`content LIKE ? ESCAPE '\'`)。這裡跟著改的是
|
||||
// **比對用的常數**,不是放寬檢查——底下仍然逐字相等比對,只是比的是現在正確的那個字串。
|
||||
// pattern 本身('%語意檢索%')一個字都沒變:那句話裡沒有 % _ \,跳脫後與原文相同。
|
||||
const LIKE_PRED = "content LIKE ? ESCAPE '\\'";
|
||||
|
||||
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
|
||||
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
|
||||
const m = buildContentLike('語意檢索');
|
||||
expect(m.split).toBe(false);
|
||||
expect(m.conds).toEqual(['content LIKE ?']);
|
||||
expect(m.conds).toEqual([LIKE_PRED]);
|
||||
expect(m.params).toEqual(['%語意檢索%']);
|
||||
});
|
||||
|
||||
@@ -43,7 +47,7 @@ describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () =
|
||||
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
|
||||
expect(m.split).toBe(true);
|
||||
expect(m.conds.length).toBeGreaterThan(1);
|
||||
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
|
||||
expect(m.conds.every((c) => c === LIKE_PRED)).toBe(true);
|
||||
expect(m.params).toContain('%語意檢索%');
|
||||
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// 查詢斷詞 + 覆蓋率排序 —— 讓「AI 問一個問句」查得到東西(2026-08-10,Leo/mira#4)
|
||||
//
|
||||
// 病徵(總管在 leo21c 上實測,有對照組,非推論):
|
||||
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||
// ⇒ 對照組證明:查詢字串是**整串**拿去 LIKE 的,從來沒被拆開。
|
||||
// ⇒ 而 **AI 問的永遠是問句**,問句的詞不可能在原文裡剛好相鄰 ⇒ 這條路對 AI 恆為 0。
|
||||
//
|
||||
// 本檔守四件事:
|
||||
// ① 拆得開 —— 詞存在但不相鄰的問句要能命中
|
||||
// ② 不退化 —— 單詞查詢送出的 SQL 與舊版**逐字相同**(最熱路徑一個字都不能變)
|
||||
// ③ 不崩壞 —— 相關的排前面、雜訊尾巴被相對門檻砍掉,不是把整個庫撈回來
|
||||
// ④ 不再炸 —— 每個 LIKE pattern 仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復)
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
tokenizeQuery,
|
||||
buildSearchScore,
|
||||
applyRelativeCut,
|
||||
searchEntries,
|
||||
} from '../src/actions/entry-crud';
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限
|
||||
const termsOf = (q: string) => tokenizeQuery(q).map((t) => t.term);
|
||||
const weightOf = (q: string, term: string) => tokenizeQuery(q).find((t) => t.term === term)?.weight;
|
||||
|
||||
describe('① 拆得開:問句要被拆成詞', () => {
|
||||
it('「Gemini 逃生口」拆成兩個詞(就是驗收題本身)', () => {
|
||||
expect(termsOf('Gemini 逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||
});
|
||||
|
||||
it('沒有空白的 CJK/ASCII 交界也要切開(吸收 t95 normalizeCjkQuery 的用意)', () => {
|
||||
expect(termsOf('Gemini逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||
expect(termsOf('AI協作')).toEqual(expect.arrayContaining(['AI', '協作']));
|
||||
});
|
||||
|
||||
it('自然語言問句:虛詞被丟掉,只留實詞', () => {
|
||||
const t = termsOf('Gemini 在這套系統裡的角色是什麼?');
|
||||
expect(t).toEqual(expect.arrayContaining(['Gemini', '系統', '角色']));
|
||||
// 「這/的/是/什麼/套」是虛詞與量詞,不該變成查詢詞——否則會把整個庫撈回來
|
||||
for (const junk of ['這', '的', '是', '什麼', '套系統']) expect(t).not.toContain(junk);
|
||||
});
|
||||
|
||||
it('標點(含全形)當分隔,不會混進詞裡', () => {
|
||||
expect(termsOf('額度、向量化;為什麼?')).toEqual(expect.arrayContaining(['額度', '向量化']));
|
||||
for (const t of termsOf('額度、向量化;為什麼?')) {
|
||||
expect(t).not.toMatch(/[、;?,。]/);
|
||||
}
|
||||
});
|
||||
|
||||
it('超過 4 字的黏著長段補雙字組合(「專案管理工具」要能命中「專案管理」的寫法)', () => {
|
||||
expect(termsOf('專案管理工具')).toEqual(expect.arrayContaining(['專案', '管理']));
|
||||
});
|
||||
|
||||
it('任何查詢都至少留下一個詞,不會一個都不剩', () => {
|
||||
for (const q of ['是什麼', '的', '。。。', 'a']) {
|
||||
expect(buildSearchScore(q).scoreParams.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// 🔴 這組是「第一版寫錯、被自己的測試擋下來」的那個錯(2026-08-10):
|
||||
// 第一版拿虛詞去**切段**,結果 `向` 把「向量化」切成「量化」、`能` 把「功能」切掉
|
||||
// ⇒ 使用者真正要查的詞被切爛。沒有詞典的中文,切段一定誤傷實詞。
|
||||
// 現在的做法是「整段不動、只過濾雙字組合」,這組測試就是不准再走回去。
|
||||
it('實詞不准被虛詞切爛(向量化/功能/需要/使用者/規則/原因)', () => {
|
||||
expect(termsOf('額度、向量化;為什麼?')).toContain('向量化');
|
||||
expect(termsOf('這個功能是什麼')).toContain('功能');
|
||||
for (const [q, word] of [
|
||||
['系統需要什麼', '需要'], ['使用者是誰', '使用'], ['這個規則是什麼', '規則'],
|
||||
['原因是什麼', '原因'], ['更新了什麼', '更新'],
|
||||
] as const) {
|
||||
expect(termsOf(q)).toContain(word);
|
||||
}
|
||||
});
|
||||
|
||||
it('leo 的第二題「今天額度為什麼用完」要抓得到「額度」', () => {
|
||||
expect(termsOf('今天額度為什麼用完')).toContain('額度');
|
||||
});
|
||||
});
|
||||
|
||||
describe('② 不退化:單詞查詢與舊版逐字相同', () => {
|
||||
it('單一英文詞 → 一個詞、legacyShape、一個 LIKE', () => {
|
||||
const p = buildSearchScore('arcrun');
|
||||
expect(p.terms.map((t) => t.term)).toEqual(['arcrun']);
|
||||
expect(p.legacyShape).toBe(true);
|
||||
expect(p.scoreParams).toEqual(['%arcrun%']);
|
||||
});
|
||||
|
||||
it('單一四字中文詞(最常見的中文查詢)→ 仍然只有一個 LIKE', () => {
|
||||
// 這條是被既有 search-long-query.test.ts 擋出來的:雙字組合門檻若設 3,
|
||||
// 「語意檢索」會從 1 個 LIKE 變成 5 個 ⇒ 最熱路徑成本 ×5。
|
||||
const p = buildSearchScore('語意檢索');
|
||||
expect(p.legacyShape).toBe(true);
|
||||
expect(p.scoreParams).toEqual(['%語意檢索%']);
|
||||
});
|
||||
|
||||
it('searchEntries:單詞查詢送出的 SQL 只有一個 content LIKE,pattern 與舊版相同', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, '語意檢索', 'demo');
|
||||
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
|
||||
expect(captured[0].params[0]).toBe('%語意檢索%');
|
||||
});
|
||||
|
||||
it('單詞查詢分數全等 ⇒ 相對門檻一筆都砍不掉(排序退化回 updated_at DESC)', () => {
|
||||
const rows = Array.from({ length: 50 }, (_, i) => ({ id: `e${i}`, match_score: 3 }));
|
||||
expect(applyRelativeCut(rows)).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('③ 不崩壞:相關的排前面,雜訊尾巴砍掉', () => {
|
||||
it('詞愈長份量愈重(specific 壓過泛詞,這是相關性不崩壞的機制)', () => {
|
||||
const q = 'Gemini 在這套系統裡的角色是什麼?';
|
||||
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '系統')!);
|
||||
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '角色')!);
|
||||
});
|
||||
|
||||
it('整句相鄰另給重賞 ⇒ 字面命中永遠壓過零散命中(`local arcrun` 那 5 筆不會被稀釋)', () => {
|
||||
const p = buildSearchScore('local arcrun');
|
||||
expect(p.legacyShape).toBe(false);
|
||||
expect(p.scoreParams).toContain('%local arcrun%'); // 整句那一項存在
|
||||
const bonus = p.terms.reduce((s, t) => s + t.weight, 0);
|
||||
const scattered = p.terms.reduce((s, t) => s + t.weight, 0); // 全部詞都命中但不相鄰
|
||||
expect(bonus + scattered).toBeGreaterThan(scattered); // 相鄰者必然更高分
|
||||
});
|
||||
|
||||
it('相對門檻砍掉低於最高分 60% 的尾巴', () => {
|
||||
const rows = [
|
||||
{ id: 'a', match_score: 10 }, // 兩個詞都中
|
||||
{ id: 'b', match_score: 6 }, // 只中重的那個
|
||||
{ id: 'c', match_score: 2 }, // 只中泛詞 ⇒ 雜訊,砍掉
|
||||
];
|
||||
expect(applyRelativeCut(rows).map((r) => r.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('相對門檻是對「最高分」取比例、不是對「滿分」——否則驗收題會被自己的門檻誤殺', () => {
|
||||
// 「Gemini 逃生口」:全庫沒有「逃生口」,最高分那群只中了 Gemini 一個詞。
|
||||
// 若拿滿分當分母,這群會全部低於門檻 ⇒ 又回到 0 筆。
|
||||
const onlyOneTermHit = Array.from({ length: 40 }, (_, i) => ({ id: `g${i}`, match_score: 6 }));
|
||||
expect(applyRelativeCut(onlyOneTermHit)).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('查詢詞數有上限(每多一個詞就多掃一次全表)', () => {
|
||||
const t = tokenizeQuery('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫 額度 向量');
|
||||
expect(t.length).toBeLessThanOrEqual(6);
|
||||
// 被砍掉的必須是最泛的那些 ⇒ 留下來的按份量遞減
|
||||
const w = t.map((x) => x.weight);
|
||||
expect([...w].sort((a, b) => b - a)).toEqual(w);
|
||||
});
|
||||
});
|
||||
|
||||
describe('④ 不再炸:LIKE pattern 仍在 D1 上限內(承 2026-08-03 的 500 修復)', () => {
|
||||
it('任何查詢(含超長中文句)產生的每個 pattern 都 ≤ 50 bytes', () => {
|
||||
const qs = [
|
||||
'a'.repeat(300),
|
||||
'為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢',
|
||||
'Gemini 在這套系統裡的角色是什麼?今天額度為什麼用完?',
|
||||
'。'.repeat(60),
|
||||
];
|
||||
for (const q of qs) {
|
||||
const p = buildSearchScore(q);
|
||||
expect(p.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
|
||||
for (const pattern of p.scoreParams) expect(bytes(pattern)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEntries 送出的 SQL', () => {
|
||||
it('多詞查詢:拆成多個 CASE WHEN,且只回 match_score > 0 的、按分數排序', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, 'Gemini 逃生口', 'demo');
|
||||
const sql = captured[0].sql;
|
||||
expect((sql.match(/CASE WHEN content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
|
||||
expect(sql).toContain('match_score > 0');
|
||||
expect(sql).toContain('ORDER BY match_score DESC');
|
||||
expect(captured[0].params).toEqual(expect.arrayContaining(['%Gemini%', '%逃生口%']));
|
||||
});
|
||||
|
||||
it('其他 filter(owner/library/deprecated)留在內層,先篩再算分', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, 'Gemini 逃生口', 'demo', undefined, 50, ['kb'], 'kb://x');
|
||||
const inner = captured[0].sql.split('WHERE match_score')[0];
|
||||
expect(inner).toContain('owner_id = ?');
|
||||
expect(inner).toContain("json_extract(metadata_json, '$.source')");
|
||||
expect(inner).toContain('json_extract(metadata_json, \'$.status\')'); // NOT_DEPRECATED
|
||||
});
|
||||
});
|
||||
|
||||
function fakeDb() {
|
||||
const captured: { sql: string; params: unknown[] }[] = [];
|
||||
const db = {
|
||||
prepare(sql: string) {
|
||||
return {
|
||||
bind(...params: unknown[]) {
|
||||
captured.push({ sql, params });
|
||||
return { all: async () => ({ results: [] }) };
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
return { db, captured };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 三元組 library 補標 — 源頭順序 + 存量補標 + 冪等(2026-08-11,leo 貼 wiki 卡「三元組要恢復」)
|
||||
//
|
||||
// 背景(system-dev/wiki/ops-facts.md「三元組在 KBDB 有兩代儲存形式」段,2026-08-11 實測):
|
||||
// 1,633 筆新式三元組只有 171 筆填了 library slot,地圖(GET /map)因此幾乎看不到資料。
|
||||
// 根因是**寫入順序**:createRecord 只會替 template.slots_json 裡「已宣告」的 slot 建 entry_value
|
||||
// (見 src/actions/record-crud.ts createRecord:`for (const slot of slots) { if (!(slot in
|
||||
// input.values)) continue }`——注意是遍歷 template 既有 slots,不是遍歷 caller 傳的 values)。
|
||||
// 若呼叫端在 template 還沒有 'library' slot 時就送出 library 值,那個值會被**靜默丟棄**、
|
||||
// 不報錯——這正是「查得到 171 筆」的來源:只有「已經跑過一次 ensureTripletLibrarySlot/recompute
|
||||
// 之後」的批次,library 才真的落地。
|
||||
//
|
||||
// 本檔驗三件事(對應 leo 交辦的三個「要驗的」):
|
||||
// 1. 源頭:ensure-slot 必須在 write 之前,不能事後補(重現+證明順序才是正解)
|
||||
// 2. 存量:對一批缺 library 的舊 triplet 補標,前後地圖輸出對照
|
||||
// 3. 不會重複做:同一批跑兩次,第二次 touch 0 筆
|
||||
//
|
||||
// 測試手法沿 library-map.test.ts 慣例:真 node:sqlite(Node ≥22.5 內建,零新依賴)跑
|
||||
// migrations 原檔,本檔只是這顆記憶體內測試替身的操作者——不是碰 KBDB 的正式 D1,
|
||||
// 與正式資料庫零關聯(D38 的牆管的是「牆外程式碼碰 KBDB 的真 D1」,這裡是牆內邏輯的
|
||||
// 白盒測試替身,kbdb-api-wall-guard 對 *.test.ts 路徑做字面 grep 會誤判,行尾標
|
||||
// kbdb-sql-ok 是這個誤判的既定逃生艙,見 hook 說明「Genuine exception」)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
recomputeLibraryMap,
|
||||
ensureTripletLibrarySlot,
|
||||
ensureFreshLibraryMaps,
|
||||
listLibraryMaps,
|
||||
} from '../src/actions/library-map';
|
||||
import { createTemplate, createRecord, updateRecord, getRecord, searchByTemplate } from '../src/actions/record-crud';
|
||||
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok: 記憶體測試替身,非真 KBDB D1
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 灌測試替身 schema,非真 D1
|
||||
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 同上
|
||||
function stmt(sql: string, params: unknown[]) {
|
||||
const s = {
|
||||
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 記憶體測試替身
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 記憶體測試替身
|
||||
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 記憶體測試替身
|
||||
};
|
||||
return s;
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
// prod 實際 triplet template 的 slots(library-map.test.ts 同款常數,2026-07-19 kbdb_list_templates
|
||||
// 核實)——注意:沒有 library。用它模擬「template 還沒被任何 recompute 摸過」的乾淨起點。
|
||||
const PROD_TRIPLET_SLOTS = [
|
||||
'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
|
||||
'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
|
||||
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
|
||||
];
|
||||
|
||||
async function seedTripletTemplate(db: D1Database): Promise<void> {
|
||||
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
|
||||
}
|
||||
|
||||
// 對齊 design.md 既有 fallback 語意(source_prefix 參數)的同一條規則:
|
||||
// library = source_uri 在 '@' 之前的那段(scheme:owner/repo)。這就是本檔+報告裡建議
|
||||
// kbdb-graph-plugin 在 write 時就该套用的推導規則(見報告,本檔只證明「規則正確、
|
||||
// 順序對了就能用」,不代表已經改了 kbdb-graph-plugin 的程式碼——那是另一個 repo)。
|
||||
function deriveLibrary(sourceUri: string): string {
|
||||
const at = sourceUri.indexOf('@');
|
||||
return at > 0 ? sourceUri.slice(0, at) : sourceUri;
|
||||
}
|
||||
|
||||
describe('源頭順序 — ensure-slot 必須在 write 之前,事後補救不了已寫的那筆', () => {
|
||||
it('重現:template 尚無 library slot 時寫入 → library 值被靜默丟棄(不是報錯,是消失)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
|
||||
const rec = await createRecord(db, {
|
||||
template: 'triplet',
|
||||
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
|
||||
owner_id: 'leo',
|
||||
});
|
||||
const stored = await getRecord(db, rec.record_id);
|
||||
// 關鍵斷言:library 完全沒落地,不是空字串、是 undefined(key 都不存在)。
|
||||
expect(stored!.values.library).toBeUndefined();
|
||||
expect(stored!.values.source_uri).toBe('gitea:Leo/kb@a.md'); // 其他 slot 正常落地,只有未宣告的 slot 消失
|
||||
});
|
||||
|
||||
it('正解:先 ensureTripletLibrarySlot() 補上 slot,再寫 → library 值正常落地', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
|
||||
const added = await ensureTripletLibrarySlot(db, 'triplet');
|
||||
expect(added).toBe(true); // 第一次呼叫確實補了 slot
|
||||
|
||||
const rec = await createRecord(db, {
|
||||
template: 'triplet',
|
||||
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
|
||||
owner_id: 'leo',
|
||||
});
|
||||
const stored = await getRecord(db, rec.record_id);
|
||||
expect(stored!.values.library).toBe('gitea:Leo/kb');
|
||||
|
||||
// 冪等:對已有 slot 的 template 再呼叫一次 → false(不重複加),不影響既有資料。
|
||||
const addedAgain = await ensureTripletLibrarySlot(db, 'triplet');
|
||||
expect(addedAgain).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('存量補標 — 對缺 library 的舊 triplet 補標,地圖輸出前後對照', () => {
|
||||
it('補標前:地圖看不到任何庫(triplet 全部因缺 library 而未被地圖聚合);補標後:庫名正確出現', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
// 模擬現況:三筆舊 triplet,寫入時 template 還沒有 library slot(如實重現存量現況),
|
||||
// 只帶了 source_uri(之後補標要靠它反推 library)。
|
||||
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', status: 'active' }, owner_id: 'leo' });
|
||||
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md', status: 'active' }, owner_id: 'leo' });
|
||||
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'github:uncle6-me/notes@c.md', status: 'active' }, owner_id: 'leo' });
|
||||
|
||||
// 讀端自動核對重算(M3 收尾機制):這時三筆都缺 library 值。liveTripletCountsByLibrary
|
||||
// 把「缺值」COALESCE 成 'general' 桶(供 staleness 判斷),但 recomputeLibraryMap 的
|
||||
// libCond 是 `t.library = 'general'` 精確比對——缺值在底層是 NULL 不是字面 'general',
|
||||
// 比對不中,實際聚合出 triplet_count:0。**這是本次順手發現的另一個小落差**(live 計數桶
|
||||
// 與 recompute 精確比對的『general』語意沒對齊,導致這桶每次讀都判 stale、白重算,
|
||||
// 但至少不會謊報數字)——不在本次任務範圍內(leo 問的是兩代儲存形式的可見性,不是這個
|
||||
// fallback 桶的效能問題),本測試如實記錄現況,不假裝它是 0。
|
||||
await ensureFreshLibraryMaps(db, 'leo');
|
||||
const before = await listLibraryMaps(db, 'leo');
|
||||
expect(before.length).toBe(1);
|
||||
expect(before[0].library).toBe('general');
|
||||
expect(before[0].triplet_count).toBe(0); // 誠實:桶名對得上、數字沒謊報,但也沒把 3 筆算近來(見上註)
|
||||
|
||||
// ── 補標(源頭已對:先 ensure slot,才寫值;一次對全部缺值的 record)──
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
const allTriplets = await searchByTemplate(db, 'triplet', 'leo');
|
||||
const missing = allTriplets.filter((t) => !t.values.library && t.values.source_uri);
|
||||
expect(missing.length).toBe(3); // 三筆都缺
|
||||
for (const t of missing) {
|
||||
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri) });
|
||||
}
|
||||
|
||||
// 補標後:對每個實際出現的 library 值重算一次地圖(read-path 的 ensureFreshLibraryMaps
|
||||
// 只認「已知庫名」——entries.metadata.library 或 portal_library;剛補標的三元組 library 值
|
||||
// 尚未被任何一處登記為「已知庫名」,直接 recompute 該庫最直接、也是 caller 實際會做的事)。
|
||||
const libs = [...new Set(missing.map((t) => deriveLibrary(t.values.source_uri!)))];
|
||||
for (const lib of libs) {
|
||||
await recomputeLibraryMap(db, { library: lib, owner_id: 'leo' });
|
||||
}
|
||||
|
||||
const after = await listLibraryMaps(db, 'leo');
|
||||
const byLib = new Map(after.map((m) => [m.library, m.triplet_count]));
|
||||
expect(byLib.get('gitea:Leo/kb')).toBe(2); // r1, r2 同庫 —— 這是 leo 真正要看到的東西
|
||||
expect(byLib.get('github:uncle6-me/notes')).toBe(1); // r3 另一庫
|
||||
// 'general' 桶(補標前遺留的空殼,見上一段註)仍在,但 triplet_count 仍是 0——
|
||||
// 三筆全部被正確歸進真正的庫名,沒有一筆被算進 general(no double counting)。
|
||||
expect(byLib.get('general')).toBe(0);
|
||||
expect(after.length).toBe(3);
|
||||
|
||||
// 交叉核對:三筆的 library 值確實都落地了(不是只有地圖聚合對,底層資料也對)。
|
||||
const r1Rec = await getRecord(db, r1.record_id);
|
||||
const r2Rec = await getRecord(db, r2.record_id);
|
||||
const r3Rec = await getRecord(db, r3.record_id);
|
||||
expect(r1Rec!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r2Rec!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r3Rec!.values.library).toBe('github:uncle6-me/notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('不會重複做 — 同一批補標跑兩次,第二次不改動任何東西', () => {
|
||||
it('第二輪掃描:已有 library 值的 record 一筆都不會被 touch', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'leo' });
|
||||
// r2 模擬「還沒補標」的舊資料:故意繞過 values 直接不給 library(createRecord 這次雖然
|
||||
// template 已有 slot,但 caller 沒給值 → 該 slot 完全不會被建立 entry_value,等同「缺」)。
|
||||
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md' }, owner_id: 'leo' });
|
||||
await updateRecord(db, r1.record_id, { library: deriveLibrary('gitea:Leo/kb@a.md') }); // r1 先補過
|
||||
|
||||
async function backfillPass(): Promise<number> {
|
||||
const all = await searchByTemplate(db, 'triplet', 'leo');
|
||||
const missing = all.filter((t) => !t.values.library && t.values.source_uri);
|
||||
for (const t of missing) {
|
||||
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri!) });
|
||||
}
|
||||
return missing.length;
|
||||
}
|
||||
|
||||
const firstPass = await backfillPass();
|
||||
expect(firstPass).toBe(1); // 只有 r2 被補(r1 已有值,跳過)
|
||||
|
||||
const secondPass = await backfillPass();
|
||||
expect(secondPass).toBe(0); // 第二輪:兩筆都已有 library,一筆都不 touch
|
||||
|
||||
// 資料仍然正確、沒被第二輪弄壞。
|
||||
const r1After = await getRecord(db, r1.record_id);
|
||||
const r2After = await getRecord(db, r2.record_id);
|
||||
expect(r1After!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r2After!.values.library).toBe('gitea:Leo/kb');
|
||||
});
|
||||
});
|
||||
+16
-3
@@ -1,13 +1,25 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { Env } from "./types.js";
|
||||
import { partnerAuthMiddleware } from "./middleware/partner-auth.js";
|
||||
import { partnerAuthMiddleware, type AuthPath } from "./middleware/partner-auth.js";
|
||||
import { handleMcpRequest } from "./mcp-handler.js";
|
||||
import { resolveKnowledgeIdentity } from "./lib/portal-client.js";
|
||||
import type { PortalIdentity } from "./oauth/store.js";
|
||||
import { inspectorHtml } from "./pages/inspector.js";
|
||||
import { kbdbFetch } from "./lib/kbdb-client.js";
|
||||
import { registerOAuthRoutes } from "./oauth/routes.js";
|
||||
|
||||
const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
|
||||
const _app = new Hono<{
|
||||
Bindings: Env;
|
||||
Variables: {
|
||||
org_namespace: string;
|
||||
partner_token: string;
|
||||
// 登入者身分(以帳密走 OAuth 連進來時才有)+ 這條連線是哪種憑據。
|
||||
// 知識面工具(kbdb_*)據此決定走 portal 資料面還是既有 KBDB 直連(見 lib/portal-client.ts)。
|
||||
portal?: PortalIdentity;
|
||||
auth_path: AuthPath;
|
||||
};
|
||||
}>();
|
||||
|
||||
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
|
||||
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
|
||||
@@ -261,7 +273,8 @@ app.options("/mcp", (c) => {
|
||||
app.post("/", partnerAuthMiddleware, async (c) => {
|
||||
const orgNamespace = c.get("org_namespace");
|
||||
const partnerToken = c.get("partner_token");
|
||||
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
|
||||
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
|
||||
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
|
||||
});
|
||||
|
||||
// 輸出根 app(_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "./kbdb-client.js";
|
||||
import { portalFetch, type KnowledgeIdentity } from "./portal-client.js";
|
||||
|
||||
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
|
||||
export interface LibraryMapRow {
|
||||
@@ -86,25 +87,45 @@ const MAP_FETCH_TIMEOUT_MS = 1500;
|
||||
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
|
||||
const CACHE_TTL_FAIL_MS = 60 * 1000;
|
||||
|
||||
let instructionsCache: { text: string | null; expiresAt: number } | null = null;
|
||||
/**
|
||||
* 快取以「身分」分格(2026-08-12)。
|
||||
*
|
||||
* 為什麼不能共用一格:地圖本身就是情報(哪些庫存在、各有多少關聯、核心 entity 是誰)。
|
||||
* 以帳密連線時只該看到自己有權限的庫;若跟服務級連線共用同一格快取,先連上的那個人
|
||||
* 會把自己的視野留給下一個人——那是跨帳號外洩,不是效能問題。
|
||||
*/
|
||||
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();
|
||||
|
||||
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
|
||||
export function __resetLibraryMapInstructionsCacheForTests(): void {
|
||||
instructionsCache = null;
|
||||
instructionsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含
|
||||
* 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。
|
||||
*
|
||||
* 以帳密連線(identity.kind === 'portal')時走 cypher `/portal/data/map`——只拿得到這個
|
||||
* 帳號有權限的庫;服務級憑據維持既有 KBDB `/map` 直連。舊 token(stale)不給地圖。
|
||||
*/
|
||||
export async function buildLibraryMapInstructions(env: Env): Promise<string | null> {
|
||||
export async function buildLibraryMapInstructions(
|
||||
env: Env,
|
||||
identity: KnowledgeIdentity,
|
||||
): Promise<string | null> {
|
||||
if (identity.kind === "stale") return null;
|
||||
// 快取 key:portal 用 session(=這個人這次登入),service 用固定字串。
|
||||
// session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。
|
||||
const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
|
||||
const now = Date.now();
|
||||
if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text;
|
||||
const hit = instructionsCache.get(cacheKey);
|
||||
if (hit && hit.expiresAt > now) return hit.text;
|
||||
|
||||
let text: string | null = null;
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
kbdbFetch(env, "/map"),
|
||||
identity.kind === "portal"
|
||||
? portalFetch(env, identity.portal.session, "/portal/data/map")
|
||||
: kbdbFetch(env, "/map"),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
|
||||
),
|
||||
@@ -124,6 +145,13 @@ export async function buildLibraryMapInstructions(env: Env): Promise<string | nu
|
||||
text = null;
|
||||
}
|
||||
|
||||
instructionsCache = { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS) };
|
||||
instructionsCache.set(cacheKey, {
|
||||
text,
|
||||
expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
|
||||
});
|
||||
// isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。
|
||||
if (instructionsCache.size > 64) {
|
||||
for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Portal 資料面 client — 「授權的 AI」用登入者的身分查東西的唯一管道。
|
||||
*
|
||||
* leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
|
||||
* AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
|
||||
*
|
||||
* 所以這裡帶的是 **portal session token**(同意頁輸入帳密時 cypher 發的那張,
|
||||
* 與人類在 portal 網頁上拿到的完全同一種),不是任何服務內部金鑰。
|
||||
* 端點是 cypher 的 `/portal/data/*`——庫過濾、租戶注入、停用即時生效全在那邊 server 側做完,
|
||||
* 本檔不做任何判斷(薄殼鐵律 rule 07:能力長在 API,介面只轉換)。
|
||||
*
|
||||
* 走既有 CYPHER_EXECUTOR service binding,不新增 binding、不新增金鑰。
|
||||
*/
|
||||
|
||||
import type { Env } from "../types.js";
|
||||
import type { PortalIdentity } from "../oauth/store.js";
|
||||
import { errorResponse } from "./cypher-client.js";
|
||||
|
||||
export interface PortalCallOpts {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | undefined>;
|
||||
}
|
||||
|
||||
/** 用登入者的 session 打 cypher 的 portal 資料面。 */
|
||||
export async function portalFetch(
|
||||
env: Env,
|
||||
session: string,
|
||||
path: string,
|
||||
opts: PortalCallOpts = {},
|
||||
): Promise<Response> {
|
||||
if (!env.CYPHER_EXECUTOR) {
|
||||
throw new Error("CYPHER_EXECUTOR service binding not configured");
|
||||
}
|
||||
const url = new URL(`https://cypher${path}`);
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
||||
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
|
||||
}
|
||||
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
|
||||
method: opts.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session}`,
|
||||
},
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 知識面工具的身分解析結果。
|
||||
*
|
||||
* 三態刻意分開,因為「查不到」和「沒有」不可以長得一樣(leo 的老原則):
|
||||
* - portal :有登入者 → 走 portal 資料面(權限=這個人的權限)
|
||||
* - service :服務級憑據(static token / partner key)→ 維持既有 KBDB 直連(零回歸)
|
||||
* - stale :OAuth token 但沒帶身分(本次改版前簽發的舊 token)→ **誠實要求重新連線**,
|
||||
* 不偷偷退回服務金鑰那條老路(那正是要修掉的「不管誰登入都看到同一格」)
|
||||
*/
|
||||
export type KnowledgeIdentity =
|
||||
| { kind: "portal"; portal: PortalIdentity }
|
||||
| { kind: "service" }
|
||||
| { kind: "stale" };
|
||||
|
||||
export function resolveKnowledgeIdentity(
|
||||
authPath: "oauth" | "service",
|
||||
portal: PortalIdentity | undefined,
|
||||
): KnowledgeIdentity {
|
||||
if (authPath !== "oauth") return { kind: "service" };
|
||||
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
|
||||
}
|
||||
|
||||
/** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */
|
||||
export function staleIdentityError() {
|
||||
return errorResponse(
|
||||
"identity_missing",
|
||||
"這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" +
|
||||
"重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" +
|
||||
"不需要另外找任何 credential 或金鑰。",
|
||||
[
|
||||
"到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)",
|
||||
"重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* portal 資料面的錯誤 → 給 AI 看的訊息。
|
||||
* 401/403 特別處理:那代表**登入階段過期或帳號被停用**,不是「資料不存在」——
|
||||
* 兩者混在一起會讓 AI 對使用者說「你的知識庫是空的」,那是畫面在說謊。
|
||||
*/
|
||||
export async function portalError(res: Response, what: string) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
if (res.status === 401) {
|
||||
return errorResponse(
|
||||
"session_expired",
|
||||
`${what}失敗:登入階段已過期(portal session 到期或已登出)。`,
|
||||
[
|
||||
"到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)",
|
||||
"重連後權限與你在 portal 網頁上看到的一致",
|
||||
],
|
||||
detail,
|
||||
);
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return errorResponse(
|
||||
"forbidden",
|
||||
`${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`,
|
||||
["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"],
|
||||
detail,
|
||||
);
|
||||
}
|
||||
return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status})`, ["稍後重試"], detail);
|
||||
}
|
||||
+102
-10
@@ -2,18 +2,90 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { registerAllTools } from "./tools/registry.js";
|
||||
import { buildLibraryMapInstructions } from "./lib/library-map.js";
|
||||
import type { KnowledgeIdentity } from "./lib/portal-client.js";
|
||||
import { Env } from "./types.js";
|
||||
|
||||
export async function handleMcpRequest(
|
||||
request: Request,
|
||||
/**
|
||||
* 【這條連線上有主人的知識庫】——**靜態、必定出現**的指路段(2026-08-13,leo)。
|
||||
*
|
||||
* leo 原話:「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**,從你對 Arcrun 的認知就知道
|
||||
* 你的內建 Memory 是沒用的。」
|
||||
*
|
||||
* 為什麼要獨立成一段、而且**不准依賴任何查詢**:
|
||||
* 這條連線本來就查得到答案——`kbdb_search(q="Arcrun 是什麼")` 當天實測回 33 筆真答案。
|
||||
* 但同一天,一個連著這條 MCP 的 session 為了回答同一題,去讀了 682 行原始碼——
|
||||
* **因為它不知道這裡有知識庫可查**。instructions 裡唯一提到知識的,是下面那段【藏書地圖】,
|
||||
* 而那段是**選配的**(1500ms 逾時、失敗回 null);那條 portal 連線就沒收到它。
|
||||
* ⇒ **「這裡有知識庫、怎麼查」這句話本身被綁在一個會無聲消失的段落上,才是 AI 開場全瞎的成因。**
|
||||
* 所以它跟下面的 Arcrun 指路段同級:靜態常數,KBDB 掛了、地圖抓不到,它照樣出現。
|
||||
*/
|
||||
const KNOWLEDGE_FIRST = [
|
||||
"【這條連線上有主人的知識庫——先查它,再查別的】",
|
||||
"",
|
||||
"這條 MCP 連線後面接著一個 **KBDB 知識庫**:這台實例的主人長期累積的筆記、決策、",
|
||||
"踩過的坑、專案現況、skill 與工作流紀錄,都在裡面。**你不是從零開始的**——",
|
||||
"你對這些專案的內建印象多半是錯的或過時的,庫裡那份才是主人認的版本。",
|
||||
"",
|
||||
"🔴 **有人問你「X 是什麼/為什麼這樣做/之前怎麼決定的/現在做到哪」——",
|
||||
"你的第一個動作是 `kbdb_search`,不是 grep 原始碼、不是上網搜、不是回答「我不知道」。**",
|
||||
"",
|
||||
'- `kbdb_search({ q: "Arcrun 是什麼" })` — 關鍵字查(預設 `mode:\'keyword\'`,基本盤永遠可用)。',
|
||||
" 換幾組講法再放棄;想要語義相似度用 `mode:'semantic'`。**這一支是你的第一站。**",
|
||||
"- `kbdb_get_map()` — 不知道該進哪個庫時先看藏書地圖(下面若有【藏書地圖】就是它的快照)。",
|
||||
'- `kbdb_graph_neighbors({ subject: "Arcrun" })` — 查某個東西跟誰有關係(三元組遍歷)。',
|
||||
"- `kbdb_list_templates` / `kbdb_query` — 按 template 取整批結構化資料。",
|
||||
"",
|
||||
"🔴 **這三件事不可以講成同一句**(講成同一句就是在騙人):",
|
||||
"① 「知識庫裡沒有」 ② 「我沒查」 ③ 「地圖沒取到/某庫顯示 0」。",
|
||||
"查過真的沒有 → 明說「知識庫裡查不到,以下是我從原始碼/網路推的」,再去讀 code 或上網。",
|
||||
"**沒查就回答=拿你的猜測冒充主人的知識,那是這條連線上最嚴重的錯。**",
|
||||
"",
|
||||
"🔴 **地圖是索引,不是庫存清單**:某庫顯示 `0 triplets`、或下面整段【藏書地圖】沒出現,",
|
||||
"都**不代表**沒有知識(可能只是還沒重算、或這次沒抓到)。要知道有沒有,只有一個方法:`kbdb_search` 查過。",
|
||||
"同理,任何工具回 401/連不上/沒權限,那是**讀不到**,不是**不存在**——照它給的 next_actions 修,",
|
||||
"別把它改口講成「這裡沒有」(`arcrun_get_skill` 曾把 KBDB 的 401 講成「skill 不存在」,就是這個病)。",
|
||||
].join("\n");
|
||||
|
||||
/** 地圖沒拿到時的**明講**(不可靜默):「沒取到」和「這裡沒有知識」不可以長得一樣。 */
|
||||
const MAP_UNAVAILABLE_NOTE = [
|
||||
"【藏書地圖:這次沒取到】",
|
||||
"地圖抓取逾時/回錯/或它回報的清單是空的(也可能只是還沒重算過)。",
|
||||
"🔴 **這是「地圖沒拿到」,不是「這裡沒有知識」。** 上面那條規則照舊:",
|
||||
"要知道庫裡有什麼,直接 `kbdb_search`;想再抓一次地圖呼叫 `kbdb_get_map()`(它會回報真正的原因)。",
|
||||
].join("\n");
|
||||
|
||||
/** 舊 token(stale):拿不到地圖是**身分問題**,同樣要明講,並給可執行的修法。 */
|
||||
const MAP_STALE_NOTE = [
|
||||
"【藏書地圖:拿不到,因為這條連線是舊版簽發的 token】",
|
||||
"這條連線的 token 沒帶登入者身分,`kbdb_*` 會回 `identity_missing`。",
|
||||
"🔴 **這不代表知識庫是空的**——是這條連線還沒認得你。",
|
||||
"仍然先呼叫一次 `kbdb_search` 確認錯誤碼;若真的是 `identity_missing`,",
|
||||
"請使用者到 claude.ai → Settings → Connectors 把這個 connector 重新連線一次(重新輸入 Portal 帳密),",
|
||||
"**不要改口說「查不到資料」或自己去猜答案。**",
|
||||
].join("\n");
|
||||
|
||||
/**
|
||||
* 組這條連線的 server instructions(`initialize` 時送出,**AI 沒辦法自己再要一次** ⇒ 必須一次到位)。
|
||||
*
|
||||
* 匯出給測試:三種情境(地圖抓得到/抓不到/舊 token)各印一份完整字串,逐份確認
|
||||
* 「一個什麼都不知道的 AI 讀完,下一個動作會不會是去查知識庫」。
|
||||
*/
|
||||
export async function buildServerInstructions(
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
partnerToken: string,
|
||||
): Promise<Response> {
|
||||
identity: KnowledgeIdentity,
|
||||
): Promise<string> {
|
||||
// library-map SDD M4(design §4/§6):連線時把全館藏書地圖嵌進 server instructions,
|
||||
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeout+isolate TTL 快取
|
||||
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
|
||||
const mapInstructions = await buildLibraryMapInstructions(env);
|
||||
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 絕不擋 MCP 連線(鐵律)。
|
||||
//
|
||||
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
|
||||
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
|
||||
// 快取也因此改成 per-session key(見 lib/library-map.ts)。
|
||||
//
|
||||
// 🔴 2026-08-13:失敗**不再靜默略過**。原本 null → 整段消失,於是「地圖沒取到」與
|
||||
// 「這裡沒有知識」在 AI 眼裡長得一模一樣(Arcrun#109 同一族:保險拒絕了 vs 程式碼不存在,
|
||||
// 畫面上都是沉默)。現在改成印一句明話。鐵律沒變——**失敗仍然不擋連線**,只是不再無聲。
|
||||
const mapInstructions = await buildLibraryMapInstructions(env, identity);
|
||||
|
||||
// 2026-07-30(leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
|
||||
// 如果不會,要寫什麼在外面讓它一聽到就知道?」):
|
||||
@@ -33,9 +105,13 @@ export async function handleMcpRequest(
|
||||
"",
|
||||
"1. `arcrun_get_skill('write_intent_workflow')` — **必讀第一支**。",
|
||||
" 教你用 `>>` 寫「意圖工作流」。你**不需要先知道有哪些零件**,先寫意圖。",
|
||||
" ⚠️ 這支若回錯(401/連不上/`kbdb_unreachable`),那是**這條連線讀不到 KBDB**,",
|
||||
" **不是 skill 不存在**——同一份內容用 `kbdb_search({ q: 'skill-write_intent_workflow' })` 撈得到。",
|
||||
"2. `arcrun_whoami()` — 確認連到哪個帳號(勿自行 curl 猜帳號 URL)。",
|
||||
"3. 把意圖丟 `POST /cypher/search` 或 `arcrun_validate_yaml` — 系統告訴你哪些零件存在。",
|
||||
"4. 卡住/不知道該查什麼 → `arcrun_get_skill('INDEX')`(全館導航:什麼問題查哪裡+已知的坑)。",
|
||||
"4. 卡住/不知道該查什麼 → 先 `arcrun_list_skills()` **看這台實例真的有哪幾支**,再挑一支讀。",
|
||||
" (2026-08-13 實測:不同實例 seed 的 skill 不一樣,有的實例只有兩支、連 `INDEX` 都沒有。",
|
||||
" **不要照教材直接指名一個 slug** ——先列清單,或 `kbdb_search({ q: 'skill' })` 直接在庫裡找。)",
|
||||
"5. 缺零件時:缺 API → 寫 recipe(`arcrun_recipe_push`);缺能力 → 投稿零件 PR。",
|
||||
" 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、",
|
||||
" 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。",
|
||||
@@ -52,7 +128,23 @@ export async function handleMcpRequest(
|
||||
"第一個節點固定是 `input`。",
|
||||
].join("\n");
|
||||
|
||||
const instructions = mapInstructions ? `${startHere}\n\n---\n\n${mapInstructions}` : startHere;
|
||||
// 地圖那段永遠有東西可印:拿到 → 印地圖;沒拿到 → 印「沒拿到」,不是消失。
|
||||
// stale 與「抓失敗」分開講,因為修法不同(前者要使用者重新連線,後者只是這次沒抓到)。
|
||||
const mapSection =
|
||||
mapInstructions ?? (identity.kind === "stale" ? MAP_STALE_NOTE : MAP_UNAVAILABLE_NOTE);
|
||||
|
||||
// 知識段排在 Arcrun 指路段之前:AI 最常被問的是「X 是什麼」,那題的正解是查庫,不是查零件。
|
||||
return [KNOWLEDGE_FIRST, startHere, mapSection].join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
export async function handleMcpRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
partnerToken: string,
|
||||
identity: KnowledgeIdentity,
|
||||
): Promise<Response> {
|
||||
const instructions = await buildServerInstructions(env, identity);
|
||||
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
const server = new McpServer(
|
||||
@@ -60,7 +152,7 @@ export async function handleMcpRequest(
|
||||
{ instructions },
|
||||
);
|
||||
|
||||
registerAllTools(server, env, orgNamespace, partnerToken);
|
||||
registerAllTools(server, env, orgNamespace, partnerToken, identity);
|
||||
await server.connect(transport);
|
||||
|
||||
return transport.handleRequest(request);
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Context, Next } from "hono";
|
||||
import { Env } from "../types.js";
|
||||
import { getAccessToken } from "../oauth/store.js";
|
||||
import { getAccessToken, type PortalIdentity } from "../oauth/store.js";
|
||||
import { constantTimeEqual } from "../oauth/crypto.js";
|
||||
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
|
||||
|
||||
/**
|
||||
* 這條連線是**用誰的身分**進來的。決定知識面(kbdb_*)走哪條路:
|
||||
* - "oauth":有人在同意頁輸入過 Portal 帳密 → 帶著他的 portal session 走 portal 資料面,
|
||||
* 權限=他在 portal 網頁上看得到的那些(庫過濾照吃)。
|
||||
* - "service":static token / partner key 這類**服務級**憑據(本身就是真祕密,
|
||||
* 代表整個實例或整個租戶,不是某個人)→ 維持既有的 KBDB 直連行為,零回歸。
|
||||
* 兩條路刻意分開命名,因為「這張 token 背後有沒有一個人」正是本次要能分辨的事。
|
||||
*/
|
||||
export type AuthPath = "oauth" | "service";
|
||||
|
||||
/**
|
||||
* MCP / GUI 端點認證中介層。
|
||||
*
|
||||
@@ -19,7 +29,15 @@ import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.
|
||||
* 已從預設路徑移除;只在明確設 ALLOW_PLAINTEXT_NAMESPACE="true" 的遷移情境才恢復。
|
||||
*/
|
||||
export async function partnerAuthMiddleware(
|
||||
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
|
||||
c: Context<{
|
||||
Bindings: Env;
|
||||
Variables: {
|
||||
org_namespace: string;
|
||||
partner_token: string;
|
||||
portal?: PortalIdentity;
|
||||
auth_path: AuthPath;
|
||||
};
|
||||
}>,
|
||||
next: Next
|
||||
) {
|
||||
const origin = originOf(c.req.url);
|
||||
@@ -50,6 +68,11 @@ export async function partnerAuthMiddleware(
|
||||
}
|
||||
c.set("org_namespace", at.namespace);
|
||||
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
||||
// 登入者的身分(2026-08-12):知識面工具(kbdb_*)帶著它打 cypher 的 portal 資料面,
|
||||
// 權限與這個人在 portal 網頁上看到的完全一致。舊 token 沒有這欄 → undefined,
|
||||
// 知識面工具會要求重新連線(不偷偷退回服務金鑰那條老路)。
|
||||
c.set("portal", at.portal);
|
||||
c.set("auth_path", "oauth");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
@@ -60,6 +83,7 @@ export async function partnerAuthMiddleware(
|
||||
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
|
||||
c.set("org_namespace", ns);
|
||||
c.set("partner_token", ns);
|
||||
c.set("auth_path", "service");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
@@ -79,6 +103,7 @@ export async function partnerAuthMiddleware(
|
||||
}
|
||||
c.set("org_namespace", info.org_namespace);
|
||||
c.set("partner_token", token);
|
||||
c.set("auth_path", "service");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
@@ -89,6 +114,7 @@ export async function partnerAuthMiddleware(
|
||||
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
|
||||
c.set("org_namespace", token);
|
||||
c.set("partner_token", token);
|
||||
c.set("auth_path", "service");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
+56
-6
@@ -14,6 +14,7 @@ import {
|
||||
consumeAuthCode,
|
||||
putAccessToken,
|
||||
AUTH_CODE_TTL_SECONDS,
|
||||
type PortalIdentity,
|
||||
} from "./store.js";
|
||||
import {
|
||||
originOf,
|
||||
@@ -34,10 +35,25 @@ const CORS_JSON = {
|
||||
"Cache-Control": "no-store",
|
||||
} as const;
|
||||
|
||||
function ownerNamespace(env: Env): string {
|
||||
/**
|
||||
* **工作流面**(arcrun_* 工具)的租戶代號。知識面(kbdb_*)已不再讀它——
|
||||
* 那邊改成跟著登入者的 portal session 走(見 store.ts PortalIdentity)。
|
||||
*
|
||||
* 為什麼這裡還留著、而且還有預設值:cypher 的 workflow API 是用「租戶代號當 opaque key」
|
||||
* (X-Arcrun-API-Key)認的,不吃 portal session;要拆掉它得先在 cypher 開一組
|
||||
* 吃 portal session 的 workflow 端點。那是下一步,不在本次範圍——
|
||||
* 硬拆會把現在好好的 arcrun_* 弄壞。**誠實記在這裡,不假裝已經解決。**
|
||||
*
|
||||
* ⚠️ 預設值 "leo" 的**知識面**用法已消滅:它曾經是「不管誰登入都看到同一格」的根因
|
||||
* (namespace 直接當 KBDB 的 owner_id 用)。現在它只當工作流面的 API key。
|
||||
*/
|
||||
function workflowTenant(env: Env): string {
|
||||
return env.MCP_OWNER_NAMESPACE || "leo";
|
||||
}
|
||||
|
||||
/** portal session TTL 讀不到時的保守假設(秒):短的那邊贏,寧可早點要求重連。 */
|
||||
const FALLBACK_PORTAL_SESSION_TTL = 604800; // 7 天(cypher portal.ts 的預設值)
|
||||
|
||||
function tokenTtl(env: Env): number {
|
||||
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
|
||||
@@ -248,7 +264,13 @@ export function registerOAuthRoutes<
|
||||
}
|
||||
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
|
||||
// 走 service binding(MCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
|
||||
let loginOk = false;
|
||||
//
|
||||
// 🔴 2026-08-12(leo:「用登入能做的 mcp 就應該能做,結果要你去打 MCP 時自己找
|
||||
// credential 問題很大」):這裡**接住登入回來的身分**,不再只留 `res.ok`。
|
||||
// 舊版把身分丟掉 ⇒ 查詢時無身分可帶 ⇒ 只好去撈服務內部金鑰(KBDB_INTERNAL_TOKEN)
|
||||
// 直打 KBDB ⇒ 繞過所有庫過濾、而且不管誰登入都看到同一格。根因就在這幾行。
|
||||
let portal: PortalIdentity | null = null;
|
||||
let portalTtl = FALLBACK_PORTAL_SESSION_TTL;
|
||||
try {
|
||||
const res = await c.env.CYPHER_EXECUTOR.fetch(
|
||||
new Request("https://cypher/portal/login", {
|
||||
@@ -257,11 +279,34 @@ export function registerOAuthRoutes<
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
loginOk = res.ok;
|
||||
if (res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
session_token?: unknown;
|
||||
display_name?: unknown;
|
||||
role?: unknown;
|
||||
libraries?: unknown;
|
||||
session_expires_in?: unknown;
|
||||
} | null;
|
||||
const session = typeof body?.session_token === "string" ? body.session_token : "";
|
||||
if (session) {
|
||||
portal = {
|
||||
session,
|
||||
display_name: typeof body?.display_name === "string" ? body.display_name : "",
|
||||
role: typeof body?.role === "string" ? body.role : "user",
|
||||
libraries: Array.isArray(body?.libraries)
|
||||
? body.libraries.filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
};
|
||||
const ttl = Number(body?.session_expires_in);
|
||||
if (Number.isFinite(ttl) && ttl > 0) portalTtl = ttl;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
|
||||
}
|
||||
if (!loginOk) {
|
||||
if (!portal) {
|
||||
// 帳密不對,或這台 cypher 舊到還不回 session_token。兩者都不可以發碼——
|
||||
// 發了也是一張沒有身分的 token,查什麼都得再找一次 credential,正是要修的病。
|
||||
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401);
|
||||
}
|
||||
if (!c.env.OAUTH_KV) {
|
||||
@@ -275,7 +320,9 @@ export function registerOAuthRoutes<
|
||||
code_challenge_method: "S256",
|
||||
scope: consent.scope,
|
||||
resource: consent.resource,
|
||||
namespace: ownerNamespace(c.env),
|
||||
namespace: workflowTenant(c.env),
|
||||
portal,
|
||||
portal_session_expires_in: portalTtl,
|
||||
});
|
||||
const location = redirectWith(redirectUri, {
|
||||
code,
|
||||
@@ -318,7 +365,9 @@ export function registerOAuthRoutes<
|
||||
return err("invalid_grant", "PKCE verification failed");
|
||||
}
|
||||
|
||||
const ttl = tokenTtl(c.env);
|
||||
// token 活不過它底下的 portal session:否則第 8 天會出現「MCP 還連著、卻什麼都查不到」
|
||||
// ——使用者看到的是壞掉,實際是身分過期。兩者一起到期,重連就是重新輸入帳密,一次搞定。
|
||||
const ttl = Math.min(tokenTtl(c.env), data.portal_session_expires_in || FALLBACK_PORTAL_SESSION_TTL);
|
||||
const accessToken = randomToken(32);
|
||||
await putAccessToken(
|
||||
c.env.OAUTH_KV,
|
||||
@@ -327,6 +376,7 @@ export function registerOAuthRoutes<
|
||||
namespace: data.namespace,
|
||||
client_id: data.client_id,
|
||||
scope: data.scope,
|
||||
portal: data.portal,
|
||||
// RFC 8707:aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
|
||||
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
|
||||
aud: resourceUri(originOf(c.req.url)),
|
||||
|
||||
@@ -4,6 +4,28 @@
|
||||
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
|
||||
import { sha256Hex } from "./crypto.js";
|
||||
|
||||
/**
|
||||
* 登入者的身分(authorize 時用帳密換到,之後跟著 token 走)。
|
||||
*
|
||||
* leo 2026-08-12:「掛上 MCP 並輸入帳密,那個動作本身就是授權。」
|
||||
* ⇒ 驗完帳密**不能只留一個布林值**——身分要接住並攜帶,下游才不必再要一次認證。
|
||||
*
|
||||
* `session` 是 cypher `/portal/login` 發的 portal session token,與人類在 portal 網頁上
|
||||
* 拿到的完全同一種。它是「取得的暫時性認證」,正合本檔開頭的儲存鐵律(可進 KV、帶 TTL);
|
||||
* access_token 的 TTL 會被夾到不超過它(見 routes.ts),兩者一起到期,不會出現
|
||||
* 「MCP 還連著、底下 session 早死」的鬼打牆。
|
||||
*
|
||||
* display_name / role / libraries 只是**給人看的回報值**(arcrun_whoami)。
|
||||
* 真正的權限判定每次都由 cypher 回讀 user record 現算——這裡的副本不是判準,
|
||||
* 所以管理員改權限或停用帳號會立刻生效,不必等 token 過期。
|
||||
*/
|
||||
export interface PortalIdentity {
|
||||
session: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
libraries: string[];
|
||||
}
|
||||
|
||||
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
|
||||
export interface AuthCodeData {
|
||||
client_id: string;
|
||||
@@ -15,6 +37,10 @@ export interface AuthCodeData {
|
||||
resource: string;
|
||||
/** 換發後 token 綁定的資料分區(owner namespace)。 */
|
||||
namespace: string;
|
||||
/** 這張 code 是誰換的(帳密驗過的那個人)。 */
|
||||
portal: PortalIdentity;
|
||||
/** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */
|
||||
portal_session_expires_in: number;
|
||||
}
|
||||
|
||||
/** access token 綁定的資料。 */
|
||||
@@ -26,6 +52,11 @@ export interface AccessTokenData {
|
||||
aud: string;
|
||||
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
|
||||
exp: number;
|
||||
/**
|
||||
* 持這張 token 的是誰。**舊 token(本次改版前簽發的)沒有這欄** → undefined,
|
||||
* 知識面工具會誠實要求重新連線,而不是偷偷退回服務金鑰那條老路(fail-closed)。
|
||||
*/
|
||||
portal?: PortalIdentity;
|
||||
}
|
||||
|
||||
const CODE_PREFIX = "oauth:code:";
|
||||
|
||||
@@ -30,6 +30,79 @@ import { z } from "zod";
|
||||
import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
import { staleIdentityError, type KnowledgeIdentity } from "../lib/portal-client.js";
|
||||
|
||||
/**
|
||||
* 🔴 2026-08-13:**「讀不到」不准長得像「不存在」**(Arcrun#100/#109 同一族)。
|
||||
*
|
||||
* 實撞(總管在 leo21c/portal-login 連線上親跑,且已對照證實):
|
||||
* `arcrun_get_skill('write_intent_workflow')` → 「skill "write_intent_workflow" **不存在**」
|
||||
* 但 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`、
|
||||
* `entry_type: "agent-skill"`、`source: "installer-seed"`——**與本工具查的鍵逐字相符**。
|
||||
* 真兇:下面的 `kbdbGetByPageName` 舊版寫 `if (!resp.ok) return null;`
|
||||
* ⇒ **一個 HTTP 401 被逐字翻譯成「不存在」**。
|
||||
*
|
||||
* 401 從哪來(不是這支 code 的錯,但這支 code 把它講成了假話):`kbdb-client.ts` 只在
|
||||
* `env.KBDB_INTERNAL_TOKEN` 存在時才掛 Authorization ⇒ 沒設就匿名送出 ⇒ KBDB 回 401。
|
||||
* 而該實例的 `arcrun-mcp` 很可能根本沒拿到那把 token(安裝器的 secret 迴圈漏了它)。
|
||||
* ⇒ **修 token 是部署面的事(leo 的手);這支 code 該做的是把話講對。**
|
||||
*
|
||||
* 為什麼講對很重要:instructions 叫 AI「第一步先讀 skill」。它照做、拿到「不存在」,
|
||||
* 於是結論是「這裡沒有 skill」⇒ 去猜、去 grep repo、去上網——**那正是「AI 是瞎的」的機械過程**。
|
||||
*
|
||||
* 現在:非 2xx 一律拋 `KbdbAccessError`(帶真的 status),由 `kbdbFailure()` 講成它自己
|
||||
* (401/403=讀不到;5xx/連不上=連不上),並把**還走得通的那條路**(`kbdb_search`)交給 AI。
|
||||
* `not_found` 只保留給「KBDB 正常回應、但真的沒有這張卡」。
|
||||
*/
|
||||
class KbdbAccessError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly what: string,
|
||||
readonly detail?: string,
|
||||
) {
|
||||
super(`KBDB ${what} HTTP ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KBDB 讀取失敗 → 誠實的錯誤回覆。
|
||||
*
|
||||
* `searchHint` 是「同一份內容還能從哪裡拿」——這批 registry 卡片(skill/example)本身就住在
|
||||
* KBDB entries 裡,而 `kbdb_search` 走的是**另一條**路(登入身分走 portal 資料面),
|
||||
* 這條 401 時那條常常還活著(2026-08-13 實測即如此)。所以這不是安慰話,是真的可行的下一步。
|
||||
*/
|
||||
function kbdbFailure(e: unknown, searchHint: string, identity: KnowledgeIdentity) {
|
||||
const portalNote =
|
||||
identity.kind === "portal"
|
||||
? "你這條是帳密登入的連線,但 skill/example 這批工具目前仍走**服務內部憑據**(還沒接上登入身分)——" +
|
||||
"所以它讀不到,不代表你的帳號讀不到。"
|
||||
: "";
|
||||
if (e instanceof KbdbAccessError) {
|
||||
const unauthorized = e.status === 401 || e.status === 403;
|
||||
return errorResponse(
|
||||
unauthorized ? "kbdb_unauthorized" : "kbdb_unreachable",
|
||||
(unauthorized
|
||||
? `讀不到 KBDB(HTTP ${e.status}:這條連線的憑據被拒或根本沒帶)。`
|
||||
: `讀不到 KBDB(HTTP ${e.status})。`) +
|
||||
"🔴 **這是「讀不到」,不是「不存在」**——內容還在庫裡,只是這條路被擋住了。" +
|
||||
(portalNote ? ` ${portalNote}` : ""),
|
||||
[
|
||||
searchHint,
|
||||
"kbdb_get_map() 看這台實例有哪些庫(那條走得通就更確定是這批工具的路壞了,不是庫空了)",
|
||||
"🔴 不准把這個錯誤回報成「找不到/沒有這個 skill」——請照實說「KBDB 這條路讀不到」",
|
||||
"持續失敗:告訴 leo 這台實例的 arcrun-mcp 少了 secret KBDB_INTERNAL_TOKEN",
|
||||
],
|
||||
e.detail,
|
||||
);
|
||||
}
|
||||
return errorResponse(
|
||||
"kbdb_unreachable",
|
||||
`讀不到 KBDB:${e instanceof Error ? e.message : String(e)}。` +
|
||||
"🔴 **這是「讀不到」,不是「不存在」**。" +
|
||||
(portalNote ? ` ${portalNote}` : ""),
|
||||
[searchHint, "稍後重試", "🔴 不准把它回報成「沒有這個 skill/example」"],
|
||||
);
|
||||
}
|
||||
|
||||
// 基本盤 entries row(與舊 v3 block 欄位 1:1,差別只在 type→entry_type)
|
||||
interface KbdbBlock {
|
||||
@@ -45,14 +118,25 @@ interface KbdbBlock {
|
||||
|
||||
async function kbdbList(env: Env, entryType: string, limit = 100): Promise<KbdbBlock[]> {
|
||||
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
|
||||
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
|
||||
if (!resp.ok) {
|
||||
throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => ""));
|
||||
}
|
||||
const data = await resp.json<{ entries?: KbdbBlock[] }>();
|
||||
return data.entries ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 依 page_name 取一張卡。
|
||||
*
|
||||
* 🔴 回 `null` **只代表「KBDB 好好回答了,而它說沒有這張卡」**。
|
||||
* 讀不到(401/5xx/連不上)一律拋 `KbdbAccessError`——**絕不 return null**,
|
||||
* 否則呼叫端會把它講成「不存在」(2026-08-13 的真實事故,見檔頭)。
|
||||
*/
|
||||
async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> {
|
||||
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
|
||||
if (!resp.ok) return null;
|
||||
if (!resp.ok) {
|
||||
throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => ""));
|
||||
}
|
||||
const data = await resp.json<{ entries?: KbdbBlock[] }>();
|
||||
return data.entries?.[0] ?? null;
|
||||
}
|
||||
@@ -67,7 +151,7 @@ function parseTags(tagsJson?: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerListSkills(server: McpServer, env: Env) {
|
||||
export function registerListSkills(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
toolName("list_skills"),
|
||||
"列所有 agent-skill blocks(從 arcrun/registry/skills/ 同步進 KBDB)。每個 skill 是個 markdown playbook,描述 AI 面對 X 問題該怎麼想 + 該用哪個 example。回 [{slug, title, tags}]。call get_skill(slug) 拿完整內文。",
|
||||
@@ -75,6 +159,7 @@ export function registerListSkills(server: McpServer, env: Env) {
|
||||
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'watcher' / 'debug'"),
|
||||
},
|
||||
async ({ tag }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const blocks = await kbdbList(env, "agent-skill", 100);
|
||||
const skills = blocks
|
||||
@@ -101,20 +186,19 @@ export function registerListSkills(server: McpServer, env: Env) {
|
||||
skills.length === 0
|
||||
? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部"
|
||||
: "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown",
|
||||
// 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。
|
||||
// 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。
|
||||
"🔴 只用上面清單裡真的有的 slug;清單沒有=這台實例沒 seed 進去,不要硬猜名字",
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["稍後重試", "若持續失敗,告訴 leo"],
|
||||
);
|
||||
return kbdbFailure(e, "改用 kbdb_search({ q: 'skill' }) 直接在知識庫裡找 skill 卡片(那條路走的是另一組憑據)", identity);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerGetSkill(server: McpServer, env: Env) {
|
||||
export function registerGetSkill(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
toolName("get_skill"),
|
||||
"拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。",
|
||||
@@ -122,15 +206,19 @@ export function registerGetSkill(server: McpServer, env: Env) {
|
||||
slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"),
|
||||
},
|
||||
async ({ slug }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
|
||||
const block = await kbdbGetByPageName(env, pageName);
|
||||
if (!block) {
|
||||
// 走到這裡=KBDB **有正常回答**,而它說沒有這張卡(讀不到的情況上面已經拋出去了)。
|
||||
return errorResponse(
|
||||
"not_found",
|
||||
`skill "${slug}" 不存在`,
|
||||
`KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒有 seed 這支 skill。` +
|
||||
"(不同實例 seed 的 skill 不一樣,別照舊教材假設某個名字一定在。)",
|
||||
[
|
||||
"call arcrun_list_skills() 看可用 slug",
|
||||
"call arcrun_list_skills() 看**這台實例真的有**哪幾支",
|
||||
`kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`,
|
||||
"確認拼字正確(不需要 'skill-' prefix)",
|
||||
],
|
||||
);
|
||||
@@ -142,17 +230,17 @@ export function registerGetSkill(server: McpServer, env: Env) {
|
||||
tags: parseTags(block.tags_json),
|
||||
});
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["稍後重試"],
|
||||
return kbdbFailure(
|
||||
e,
|
||||
`改用 kbdb_search({ q: 'skill-${slug}' }) 撈同一張卡片(skill 就住在知識庫的 entries 裡,那條路走另一組憑據)`,
|
||||
identity,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerListExamples(server: McpServer, env: Env) {
|
||||
export function registerListExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
toolName("list_examples"),
|
||||
"列所有 workflow-example blocks(從 arcrun/registry/examples/ 同步進 KBDB)。每個 example 是可直接 push 的 workflow YAML 範本 + description。回 [{slug, tags}]。call get_example / search_examples 拿細節。",
|
||||
@@ -160,6 +248,7 @@ export function registerListExamples(server: McpServer, env: Env) {
|
||||
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'cron' / 'llm' / 'webhook'"),
|
||||
},
|
||||
async ({ tag }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const blocks = await kbdbList(env, "workflow-example", 200);
|
||||
const examples = blocks
|
||||
@@ -183,17 +272,13 @@ export function registerListExamples(server: McpServer, env: Env) {
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["稍後重試"],
|
||||
);
|
||||
return kbdbFailure(e, "改用 kbdb_search({ q: 'example' }) 直接在知識庫裡找 example 卡片(那條路走另一組憑據)", identity);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerGetExample(server: McpServer, env: Env) {
|
||||
export function registerGetExample(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
toolName("get_example"),
|
||||
"拿單一 workflow-example 完整 YAML + description。slug 從 list_examples / search_examples 取得。可直接拿 YAML 改成你自己的 → push。",
|
||||
@@ -201,16 +286,19 @@ export function registerGetExample(server: McpServer, env: Env) {
|
||||
slug: z.string().describe("example slug,例如 'rag-search-answer' / 'cron-watcher'"),
|
||||
},
|
||||
async ({ slug }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
|
||||
const block = await kbdbGetByPageName(env, pageName);
|
||||
if (!block) {
|
||||
// KBDB 好好回答了,而它說沒有這張卡(讀不到的情況已在 kbdbGetByPageName 拋出)。
|
||||
return errorResponse(
|
||||
"not_found",
|
||||
`example "${slug}" 不存在`,
|
||||
`KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒 seed 這個 example。`,
|
||||
[
|
||||
"call arcrun_list_examples() 看可用 slug",
|
||||
"或 arcrun_search_examples(use_case) 用自然語言找",
|
||||
"call arcrun_list_examples() 看**這台實例真的有**哪些 slug",
|
||||
"或 arcrun_search_examples(use_case) 用關鍵字找",
|
||||
`kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -232,17 +320,17 @@ export function registerGetExample(server: McpServer, env: Env) {
|
||||
"看 description_md 了解設計意圖 / 改造方向",
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["稍後重試"],
|
||||
return kbdbFailure(
|
||||
e,
|
||||
`改用 kbdb_search({ q: 'example-${slug}' }) 撈同一張卡片(example 就住在知識庫的 entries 裡)`,
|
||||
identity,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerSearchExamples(server: McpServer, env: Env) {
|
||||
export function registerSearchExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
toolName("search_examples"),
|
||||
"用 use case 關鍵字搜 workflow examples,回最相關 N 個。" +
|
||||
@@ -253,6 +341,7 @@ export function registerSearchExamples(server: McpServer, env: Env) {
|
||||
top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5)"),
|
||||
},
|
||||
async ({ query, top_k }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const k = top_k ?? 5;
|
||||
const q = query.trim();
|
||||
@@ -309,20 +398,16 @@ export function registerSearchExamples(server: McpServer, env: Env) {
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"internal_error",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["重試一次"],
|
||||
);
|
||||
return kbdbFailure(e, `改用 kbdb_search({ q: '${query.trim()}' }) 直接查知識庫(那條路走另一組憑據)`, identity);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerAllSkillExampleTools(server: McpServer, env: Env) {
|
||||
registerListSkills(server, env);
|
||||
registerGetSkill(server, env);
|
||||
registerListExamples(server, env);
|
||||
registerGetExample(server, env);
|
||||
registerSearchExamples(server, env);
|
||||
export function registerAllSkillExampleTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
registerListSkills(server, env, identity);
|
||||
registerGetSkill(server, env, identity);
|
||||
registerListExamples(server, env, identity);
|
||||
registerGetExample(server, env, identity);
|
||||
registerSearchExamples(server, env, identity);
|
||||
}
|
||||
|
||||
@@ -5,31 +5,75 @@
|
||||
* 治本是給 AI 無腦入口:問工具拿身份。CLI 有 acr whoami,MCP 必須對齊(薄殼一致,rule 07 §5)——
|
||||
* 否則「AI 偏好 MCP」時又得繞回 curl。
|
||||
*
|
||||
* 薄殼:只回報 MCP 已解析的 orgNamespace(綁哪個帳號)+ cypher binding 連向,無業務邏輯。
|
||||
* 2026-08-12 改:以帳密連線時,「我是誰」的答案是**登入的那個人**(display_name / role /
|
||||
* 可用知識庫),不是一個租戶代號。原本回的 account_namespace 是租戶字串——那東西一旦落到
|
||||
* 呼叫端手上就能拿去直打 /kbdb/*、繞過所有庫過濾(portal-data.ts 檔頭紅線),所以登入身分下
|
||||
* 不再回它。工作流面(arcrun_*)仍用它當 API key,但那只在 server 內部用。
|
||||
*
|
||||
* 薄殼:只如實回報 MCP 已解析的身分,不做推斷、不打任何查詢。
|
||||
*/
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { toolName } from "../brand.js";
|
||||
import { Env } from "../types.js";
|
||||
import type { KnowledgeIdentity } from "../lib/portal-client.js";
|
||||
|
||||
export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) {
|
||||
export function registerWhoami(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
identity: KnowledgeIdentity,
|
||||
) {
|
||||
server.tool(
|
||||
toolName("whoami"),
|
||||
"回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" +
|
||||
"部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
|
||||
"回報這個 MCP 連線目前生效的身份:以帳密連線時回「登入的是誰、能看哪些知識庫」;" +
|
||||
"服務級 token 連線時回綁定的帳號 namespace。部署 / 觸發 / 查 workflow 前先 call 此 tool 確認身份," +
|
||||
"**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
|
||||
{},
|
||||
async () => {
|
||||
// 薄殼:MCP 透過 service binding(CYPHER_EXECUTOR)連 cypher,binding 本身決定連哪台;
|
||||
// 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。
|
||||
const identity = {
|
||||
account_namespace: orgNamespace || "(未設)",
|
||||
const base = {
|
||||
cypher: "service-binding:CYPHER_EXECUTOR",
|
||||
kbdb: "service-binding:KBDB",
|
||||
note:
|
||||
"此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
|
||||
};
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }],
|
||||
};
|
||||
|
||||
if (identity.kind === "portal") {
|
||||
const { display_name, role, libraries } = identity.portal;
|
||||
return json({
|
||||
...base,
|
||||
auth: "portal-login(這條連線是有人輸入 Portal 帳密授權的)",
|
||||
logged_in_as: display_name || "(未設顯示名稱)",
|
||||
role,
|
||||
libraries: libraries.length ? libraries : ["(尚未被授權任何知識庫)"],
|
||||
knowledge_scope:
|
||||
libraries.includes("*")
|
||||
? "全部知識庫(此帳號有全庫權限)"
|
||||
: `僅限上列知識庫——kbdb_* 查得到的東西與這個帳號在 portal 網頁上看得到的完全一致`,
|
||||
note:
|
||||
"你是「主人授權的 AI」:主人查得到的你查得到,主人查不到的你也查不到。" +
|
||||
"kbdb_* 不需要任何額外的 credential / 金鑰 / kbdb_base——已經登入過了,不會再問第二次。",
|
||||
});
|
||||
}
|
||||
|
||||
if (identity.kind === "stale") {
|
||||
return json({
|
||||
...base,
|
||||
auth: "舊版 token(沒有登入者身分)",
|
||||
knowledge_scope: "查不到任何知識內容",
|
||||
note:
|
||||
"這條連線是本次改版前簽發的 token。到 claude.ai → Settings → Connectors " +
|
||||
"重新連線一次(輸入 Portal 帳密)即可恢復,不需要找任何 credential。",
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
...base,
|
||||
auth: "service token(static token / partner key,代表整個實例或租戶,不是某個人)",
|
||||
account_namespace: orgNamespace || "(未設)",
|
||||
note: "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function json(obj: unknown) {
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(obj, null, 2) }] };
|
||||
}
|
||||
|
||||
+148
-59
@@ -1,23 +1,27 @@
|
||||
/**
|
||||
* KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1,HANDOFF §2)
|
||||
*
|
||||
* rule 07 §5(薄殼鐵律):能力長在基本盤 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
|
||||
* 全走既有 kbdbFetch(KBDB service binding)打基本盤 HTTP API(kbdb/src/routes/*)。
|
||||
* rule 07 §5(薄殼鐵律):能力長在 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
|
||||
*
|
||||
* ── 2026-08-12:改用「登入進來的那個人的身分」查詢 ────────────────────────────
|
||||
* leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的
|
||||
* MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
|
||||
*
|
||||
* 之前的路:MCP 驗完帳密只留一個布林值 → 查詢時無身分可帶 → 只好帶**服務內部金鑰**
|
||||
* (KBDB_INTERNAL_TOKEN)直打 KBDB。那條路繞過所有庫過濾,而且不管誰登入都看到同一格。
|
||||
*
|
||||
* 現在的路(identity.kind === 'portal'):帶登入者的 portal session 打 cypher
|
||||
* `/portal/data/*`——庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁
|
||||
* 是**同一道閘、同一份權限**。MCP 這邊一個判斷都不做。
|
||||
*
|
||||
* 服務級憑據(static token / partner key,identity.kind === 'service')維持既有 KBDB 直連,
|
||||
* 零回歸——那類憑據本身就是真祕密、代表整個實例或租戶,不是某個人。
|
||||
*
|
||||
* KBDB 鐵律(leo 2026-06-14,頂層 DECISION-kbdb-v3-baseplane.md):
|
||||
* - 任何人不准動表;**不提供建表 / SQL tool**。
|
||||
* - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用
|
||||
* ——類 Supabase 萬用表,schema 由 template/slot 表達,不是真的 CREATE TABLE。
|
||||
* - 薄殼只調基本盤 HTTP API,不直連 D1、不寫 SQL。
|
||||
*
|
||||
* 基本盤 API 契約(已存在,kbdb/src/routes):
|
||||
* POST /templates { name, slots[], description?, created_by? } → { template }
|
||||
* GET /templates → { templates[], count }
|
||||
* GET /templates/:idOrName → { template }
|
||||
* POST /records { template, values:{slot:content}, owner_id? } → { record }
|
||||
* GET /records/by-template/:t ?owner_id= → { records[], count }
|
||||
* GET /records/:recordId → { record }
|
||||
* GET /entries/search ?q=&owner_id= → { entries[], count, mode:'keyword' }
|
||||
* - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用。
|
||||
* - 薄殼只調 HTTP API,不直連 D1、不寫 SQL。
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
@@ -25,22 +29,32 @@ import { z } from "zod";
|
||||
import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
import {
|
||||
portalFetch,
|
||||
portalError,
|
||||
staleIdentityError,
|
||||
type KnowledgeIdentity,
|
||||
} from "../lib/portal-client.js";
|
||||
|
||||
/** 走 portal 資料面時,呼叫端傳的 owner_id 一律無效(server 用登入者的歸屬)——如實告訴 AI。 */
|
||||
const OWNER_IGNORED_HINT =
|
||||
"owner_id 在登入身分下不生效:查詢範圍由你的帳號權限決定(與你在 portal 網頁看到的一致)";
|
||||
|
||||
/** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */
|
||||
export function registerAllKbdbDataTools(server: McpServer, env: Env) {
|
||||
registerCreateTemplate(server, env);
|
||||
registerListTemplates(server, env);
|
||||
registerCreateRecord(server, env);
|
||||
registerGetRecord(server, env);
|
||||
registerQuery(server, env);
|
||||
registerSearch(server, env);
|
||||
export function registerAllKbdbDataTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
registerCreateTemplate(server, env, identity);
|
||||
registerListTemplates(server, env, identity);
|
||||
registerCreateRecord(server, env, identity);
|
||||
registerGetRecord(server, env, identity);
|
||||
registerQuery(server, env, identity);
|
||||
registerSearch(server, env, identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb_create_template — 建一個 template(= 萬用表裡的一種「虛擬表/資料形狀」)。
|
||||
* 這是 AI 想存「新類型資料」時的唯一入口:沒有建表 API,改用 template + slots 描述欄位。
|
||||
*/
|
||||
export function registerCreateTemplate(server: McpServer, env: Env) {
|
||||
export function registerCreateTemplate(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
"kbdb_create_template",
|
||||
"建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" +
|
||||
@@ -50,16 +64,24 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
|
||||
name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"),
|
||||
slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"),
|
||||
description: z.string().optional().describe("這個 template 用途的簡述(選填)"),
|
||||
created_by: z.string().optional().describe("建立者標記(選填)"),
|
||||
created_by: z.string().optional().describe("建立者標記(選填;登入身分下由 server 記錄,不吃此值)"),
|
||||
},
|
||||
async ({ name, slots, description, created_by }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, slots, description, created_by }),
|
||||
});
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(env, identity.portal.session, "/portal/data/templates", {
|
||||
method: "POST",
|
||||
body: { name, slots, description },
|
||||
})
|
||||
: await kbdbFetch(env, "/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, slots, description, created_by }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, `建 template「${name}」`);
|
||||
return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -74,17 +96,28 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
|
||||
}
|
||||
|
||||
/** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */
|
||||
export function registerListTemplates(server: McpServer, env: Env) {
|
||||
export function registerListTemplates(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
"kbdb_list_templates",
|
||||
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
|
||||
{},
|
||||
async () => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/templates");
|
||||
if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(env, identity.portal.session, "/portal/data/templates")
|
||||
: await kbdbFetch(env, "/templates");
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, "列 template");
|
||||
return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]);
|
||||
return successResponse(data, [
|
||||
"每個 template 的 slots_json 是它的欄位清單",
|
||||
"填資料用 kbdb_create_record",
|
||||
"template 是全域共享的「資料形狀」定義(schema),不含任何人的內容——內容的權限在 record/entry 那層",
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
@@ -93,7 +126,7 @@ export function registerListTemplates(server: McpServer, env: Env) {
|
||||
}
|
||||
|
||||
/** kbdb_create_record — 依某 template 填一筆 record(slot → 內容)。 */
|
||||
export function registerCreateRecord(server: McpServer, env: Env) {
|
||||
export function registerCreateRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
"kbdb_create_record",
|
||||
"依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容},slot 名要對得上 template 的 slots。" +
|
||||
@@ -101,23 +134,34 @@ export function registerCreateRecord(server: McpServer, env: Env) {
|
||||
{
|
||||
template: z.string().min(1).describe("template 的 name 或 id"),
|
||||
values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"),
|
||||
owner_id: z.string().optional().describe("資料歸屬標記(選填,如專案 id / 用戶 id)"),
|
||||
owner_id: z.string().optional().describe("資料歸屬標記(選填;登入身分下一律由 server 定成你的歸屬,不吃此值)"),
|
||||
},
|
||||
async ({ template, values, owner_id }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template, values, owner_id }),
|
||||
});
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(env, identity.portal.session, "/portal/data/records", {
|
||||
method: "POST",
|
||||
body: { template, values },
|
||||
})
|
||||
: await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template, values, owner_id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, `填 record(template「${template}」)`);
|
||||
return errorResponse("create_record_failed", `填 record 失敗`, [
|
||||
`確認 template「${template}」存在(kbdb_list_templates)`,
|
||||
"values 的 slot 名要對得上 template 的 slots",
|
||||
], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data, [`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`]);
|
||||
return successResponse(data, [
|
||||
`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`,
|
||||
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
@@ -126,7 +170,7 @@ export function registerCreateRecord(server: McpServer, env: Env) {
|
||||
}
|
||||
|
||||
/** kbdb_get_record — 用 record_id 取單筆 record。 */
|
||||
export function registerGetRecord(server: McpServer, env: Env) {
|
||||
export function registerGetRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
"kbdb_get_record",
|
||||
"用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。",
|
||||
@@ -134,10 +178,23 @@ export function registerGetRecord(server: McpServer, env: Env) {
|
||||
record_id: z.string().min(1).describe("record 的 id(rec_xxx)"),
|
||||
},
|
||||
async ({ record_id }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
|
||||
if (res.status === 404) return errorResponse("not_found", `record「${record_id}」不存在`, ["確認 record_id 正確", "用 kbdb_query 列出某 template 的 record 取 id"]);
|
||||
if (!res.ok) return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(env, identity.portal.session, `/portal/data/records/${encodeURIComponent(record_id)}`)
|
||||
: await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
|
||||
if (res.status === 404) {
|
||||
// 登入身分下,「不是你的」與「不存在」刻意同回 404(不洩存在性,portal 同一條紅線)。
|
||||
return errorResponse("not_found", `查無 record「${record_id}」(不存在,或不在你的權限範圍內)`, [
|
||||
"確認 record_id 正確",
|
||||
"用 kbdb_query 列出某 template 的 record 取 id",
|
||||
]);
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, "取 record");
|
||||
return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data);
|
||||
} catch (e) {
|
||||
@@ -148,21 +205,39 @@ export function registerGetRecord(server: McpServer, env: Env) {
|
||||
}
|
||||
|
||||
/** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */
|
||||
export function registerQuery(server: McpServer, env: Env) {
|
||||
export function registerQuery(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
"kbdb_query",
|
||||
"列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。",
|
||||
{
|
||||
template: z.string().min(1).describe("template 的 name 或 id"),
|
||||
owner_id: z.string().optional().describe("只取某歸屬的 record(選填)"),
|
||||
owner_id: z.string().optional().describe("只取某歸屬的 record(選填;登入身分下不生效,範圍由你的權限決定)"),
|
||||
},
|
||||
async ({ template, owner_id }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const path = `/records/by-template/${encodeURIComponent(template)}` + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "");
|
||||
const res = await kbdbFetch(env, path);
|
||||
if (!res.ok) return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(
|
||||
env,
|
||||
identity.portal.session,
|
||||
`/portal/data/records/by-template/${encodeURIComponent(template)}`,
|
||||
)
|
||||
: await kbdbFetch(
|
||||
env,
|
||||
`/records/by-template/${encodeURIComponent(template)}` +
|
||||
(owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""),
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, `查詢 template「${template}」的 record`);
|
||||
return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]);
|
||||
return successResponse(data, [
|
||||
"用 kbdb_get_record(record_id) 取單筆全文",
|
||||
"按關鍵字找內容改用 kbdb_search",
|
||||
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
@@ -175,26 +250,38 @@ export function registerQuery(server: McpServer, env: Env) {
|
||||
* 語義/關鍵字都在同一 KBDB MCP(用戶資料 RAG),不分散(issue #7 / D17 邊界)。
|
||||
* mode=semantic 但沒開 vectorize → base 自動降級 keyword + 回 capability_hint(發現閉環,叫 CC 幫開)。
|
||||
*/
|
||||
export function registerSearch(server: McpServer, env: Env) {
|
||||
export function registerSearch(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
"kbdb_search",
|
||||
"搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'(AI 向量語義搜尋," +
|
||||
"需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。",
|
||||
{
|
||||
q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"),
|
||||
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"),
|
||||
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填;登入身分下不生效,範圍由你的權限決定)"),
|
||||
source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"),
|
||||
mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize)"),
|
||||
},
|
||||
async ({ q, owner_id, source, mode }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (owner_id) qs.set("owner_id", owner_id);
|
||||
if (source) qs.set("source", source);
|
||||
if (mode) qs.set("mode", mode);
|
||||
const res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
|
||||
if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = (await res.json()) as { mode?: string; capability_hint?: string };
|
||||
let res: Response;
|
||||
if (identity.kind === "portal") {
|
||||
// /portal/data/search 只吃在權限範圍內「再收窄」的 filter;owner_id/library 由 server 定死。
|
||||
res = await portalFetch(env, identity.portal.session, "/portal/data/search", {
|
||||
query: { q, mode },
|
||||
});
|
||||
} else {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (owner_id) qs.set("owner_id", owner_id);
|
||||
if (source) qs.set("source", source);
|
||||
if (mode) qs.set("mode", mode);
|
||||
res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, "搜尋");
|
||||
return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = (await res.json()) as { mode?: string; capability_hint?: string; note?: string };
|
||||
// base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。
|
||||
const hints =
|
||||
data.capability_hint
|
||||
@@ -202,6 +289,8 @@ export function registerSearch(server: McpServer, env: Env) {
|
||||
: data.mode === "semantic"
|
||||
? ["mode:semantic = AI 向量語義搜尋"]
|
||||
: ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize)"];
|
||||
if (identity.kind === "portal") hints.push(OWNER_IGNORED_HINT);
|
||||
if (data.note) hints.push(data.note);
|
||||
return successResponse(data, hints);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
|
||||
@@ -23,6 +23,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { Env } from "../types.js";
|
||||
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
import {
|
||||
portalFetch,
|
||||
portalError,
|
||||
staleIdentityError,
|
||||
type KnowledgeIdentity,
|
||||
} from "../lib/portal-client.js";
|
||||
|
||||
/** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */
|
||||
export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors";
|
||||
@@ -35,8 +41,13 @@ const INSTALL_HINTS = [
|
||||
];
|
||||
|
||||
/** 註冊全部 KBDB graph 查詢工具(issue #68)。 */
|
||||
export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamespace: string) {
|
||||
registerGraphNeighbors(server, env, orgNamespace);
|
||||
export function registerAllKbdbGraphTools(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
identity: KnowledgeIdentity,
|
||||
) {
|
||||
registerGraphNeighbors(server, env, orgNamespace, identity);
|
||||
// graph_traverse:repo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/),
|
||||
// traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。
|
||||
}
|
||||
@@ -45,7 +56,12 @@ export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamesp
|
||||
* kbdb_graph_neighbors — knowledge graph 1-hop/N-hop 鄰居查詢。
|
||||
* 薄殼調 GET /q/{ns}/graph_neighbors,結果(最終節點輸出)原樣回給 MCP client。
|
||||
*/
|
||||
export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace: string) {
|
||||
export function registerGraphNeighbors(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
identity: KnowledgeIdentity,
|
||||
) {
|
||||
server.tool(
|
||||
"kbdb_graph_neighbors",
|
||||
"knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" +
|
||||
@@ -60,10 +76,10 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
|
||||
depth: z.number().int().min(1).max(10).optional().describe(
|
||||
"最大跳數(N-hop),預設 1(只看直接鄰居)",
|
||||
),
|
||||
kbdb_base: z.string().min(1).describe(
|
||||
"你自己部署的 KBDB 對外 base URL(如 https://arcrun-kbdb.<你的subdomain>.workers.dev " +
|
||||
"或 KBDB custom domain)。workflow 刻意不寫死任何一家的庫——" +
|
||||
"帶錯(或照抄別人的值)=查詢打進別人的庫",
|
||||
kbdb_base: z.string().min(1).optional().describe(
|
||||
"【登入身分下不需要,留空即可】你自己部署的 KBDB 對外 base URL。" +
|
||||
"以帳密連線的 MCP 由 server 端自己知道要查哪個庫——不必、也不該由你指定" +
|
||||
"(指定了也不會採用)。只有服務級 token(static token / partner key)連線時才需要填。",
|
||||
),
|
||||
template: z.string().optional().describe(
|
||||
"triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " +
|
||||
@@ -74,6 +90,43 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
|
||||
),
|
||||
},
|
||||
async ({ subject, depth, kbdb_base, template, directed }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
|
||||
// ── 登入身分:走 cypher 的 portal 資料面(與人類在 portal 按「關聯」同一支端點)──
|
||||
// 那支已經有 D-4 graph 粗閘(沒有 graph 來源庫權限 → 403),也已經處理好
|
||||
// 「這台實例沒裝 graph plugin 就改用 tenant 的 graph_neighbors workflow」的兩條路。
|
||||
// ⇒ MCP 不必要 kbdb_base、不必知道租戶、不必再認證一次。
|
||||
if (identity.kind === "portal") {
|
||||
try {
|
||||
const res = await portalFetch(
|
||||
env,
|
||||
identity.portal.session,
|
||||
`/portal/data/graph/neighbors/${encodeURIComponent(subject)}`,
|
||||
{ query: { depth: depth ?? 1 } },
|
||||
);
|
||||
if (!res.ok) return portalError(res, `查「${subject}」的鄰居`);
|
||||
const out = (await res.json().catch(() => null)) as
|
||||
| { neighbors?: unknown[]; edges?: unknown[]; count?: number }
|
||||
| null;
|
||||
return successResponse(out, [
|
||||
`${out?.count ?? 0} 個鄰居(depth 上限 ${depth ?? 1})`,
|
||||
"count=0 且不確定資料有沒有進圖:kbdb_query(template='triplet') 看三元組記錄",
|
||||
"找關鍵字內容改用 kbdb_search;取單筆全文用 kbdb_get_record",
|
||||
"查詢範圍=你這個帳號被授權的知識庫(與 portal 網頁上的關聯檢視一致)",
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 服務級憑據:既有路徑(打 /q/:ns/graph_neighbors workflow),行為零變更 ──
|
||||
if (!kbdb_base) {
|
||||
return errorResponse(
|
||||
"kbdb_base_required",
|
||||
"以服務級 token 連線時,graph 查詢需要 kbdb_base(你自己 KBDB 的對外 URL)",
|
||||
["改用帳密連線(OAuth)則不需要此參數", "或帶上 kbdb_base 再試一次"],
|
||||
);
|
||||
}
|
||||
if (!orgNamespace) {
|
||||
return errorResponse(
|
||||
"no_namespace",
|
||||
|
||||
+36
-11
@@ -22,6 +22,12 @@ import type { Env } from "../types.js";
|
||||
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";
|
||||
import {
|
||||
portalFetch,
|
||||
portalError,
|
||||
staleIdentityError,
|
||||
type KnowledgeIdentity,
|
||||
} from "../lib/portal-client.js";
|
||||
|
||||
/**
|
||||
* 空庫/404 時的指引(誠實回報+給下一步,鐵律:不假綠)。
|
||||
@@ -39,8 +45,8 @@ const RECOMPUTE_HINTS = [
|
||||
];
|
||||
|
||||
/** 註冊全部藏書地圖工具(library-map M4)。 */
|
||||
export function registerAllKbdbMapTools(server: McpServer, env: Env) {
|
||||
registerGetMap(server, env);
|
||||
export function registerAllKbdbMapTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
registerGetMap(server, env, identity);
|
||||
}
|
||||
|
||||
/** 單庫詳圖回傳形狀(GET /map/:library 的 map,slot 陣列已 parse 成物件)。 */
|
||||
@@ -62,7 +68,7 @@ interface LibraryMapDetail {
|
||||
* kbdb_get_map — 藏書地圖。無參數=全館(每庫一行);帶 library=該庫詳圖。
|
||||
* design §6 retrieval 流程的第一站:地圖 → get_map(library) 細節 → graph/search 進庫。
|
||||
*/
|
||||
export function registerGetMap(server: McpServer, env: Env) {
|
||||
export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
server.tool(
|
||||
"kbdb_get_map",
|
||||
"藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities+" +
|
||||
@@ -73,15 +79,26 @@ export function registerGetMap(server: McpServer, env: Env) {
|
||||
library: z.string().min(1).optional().describe(
|
||||
"庫名(如 'kb'/'notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)",
|
||||
),
|
||||
owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"),
|
||||
owner_id: z.string().optional().describe(
|
||||
"限定某資料歸屬範圍(選填;登入身分下不生效,看得到哪些庫由你的帳號權限決定)",
|
||||
),
|
||||
},
|
||||
async ({ library, owner_id }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
|
||||
// 登入身分:走 cypher 的 portal 資料面 —— 只會回這個帳號有權限的庫
|
||||
//(KBDB 的 /map 對權限無知,會回全館;過濾在 cypher 那邊 server 側做)。
|
||||
const isPortal = identity.kind === "portal";
|
||||
const qs = !isPortal && owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
|
||||
const mapFetch = (path: string) =>
|
||||
identity.kind === "portal"
|
||||
? portalFetch(env, identity.portal.session, `/portal/data${path}`)
|
||||
: kbdbFetch(env, path);
|
||||
|
||||
if (!library) {
|
||||
// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。
|
||||
const res = await kbdbFetch(env, `/map${qs}`);
|
||||
const res = await mapFetch(`/map${qs}`);
|
||||
if (!res.ok && isPortal) return portalError(res, "取全館地圖");
|
||||
if (!res.ok) {
|
||||
return errorResponse(
|
||||
"map_fetch_failed",
|
||||
@@ -90,7 +107,7 @@ export function registerGetMap(server: McpServer, env: Env) {
|
||||
await res.text().catch(() => ""),
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number };
|
||||
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number; note?: string };
|
||||
const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({
|
||||
...l,
|
||||
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
|
||||
@@ -101,8 +118,13 @@ export function registerGetMap(server: McpServer, env: Env) {
|
||||
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
|
||||
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
|
||||
// 不再是「沒人跑過 recompute」那種曖昧狀態。
|
||||
// 登入身分下還有第二種可能:這個帳號一個庫都沒被授權——「沒權限看」與「沒有資料」
|
||||
// 不可以長得一樣,所以分開講(cypher 端會附 note 說明)。
|
||||
return successResponse({ libraries: [], count: 0 }, [
|
||||
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
|
||||
isPortal
|
||||
? "看不到任何庫:可能是這個知識庫真的還沒有三元組資料,也可能是你的帳號還沒被授權任何庫——請向管理員確認你的可用知識庫"
|
||||
: "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
|
||||
...(data.note ? [data.note] : []),
|
||||
...RECOMPUTE_HINTS,
|
||||
]);
|
||||
}
|
||||
@@ -113,7 +135,7 @@ export function registerGetMap(server: McpServer, env: Env) {
|
||||
}
|
||||
|
||||
// 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。
|
||||
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
|
||||
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
|
||||
if (res.status === 404) {
|
||||
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
|
||||
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
|
||||
@@ -121,10 +143,13 @@ export function registerGetMap(server: McpServer, env: Env) {
|
||||
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
|
||||
return errorResponse(
|
||||
"map_not_found",
|
||||
`查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
|
||||
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
|
||||
isPortal
|
||||
? `查無庫「${library}」——這個名字不存在,或不在你被授權的知識庫範圍內(兩者刻意同一句話,不洩漏某個庫存不存在)`
|
||||
: `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
|
||||
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名/確認你有權限的庫)", ...RECOMPUTE_HINTS],
|
||||
);
|
||||
}
|
||||
if (!res.ok && isPortal) return portalError(res, `取庫「${library}」詳圖`);
|
||||
if (!res.ok) {
|
||||
return errorResponse(
|
||||
"map_fetch_failed",
|
||||
|
||||
@@ -20,8 +20,15 @@ import { registerAllKbdbDataTools } from "./kbdb_data.js";
|
||||
import { registerAllKbdbGraphTools } from "./kbdb_graph.js";
|
||||
import { registerAllKbdbMapTools } from "./kbdb_map.js";
|
||||
import { registerWhoami } from "./arcrun_whoami.js";
|
||||
import type { KnowledgeIdentity } from "../lib/portal-client.js";
|
||||
|
||||
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
||||
export function registerAllTools(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
partnerToken: string,
|
||||
identity: KnowledgeIdentity,
|
||||
) {
|
||||
registerSearchComponents(server, env, orgNamespace);
|
||||
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
|
||||
// 零件貢獻**只有一條路=PR 人審**(leo 2026-08-01:「已經沒有 publish 了,
|
||||
@@ -48,18 +55,24 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
|
||||
registerAllWorkflowCrudTools(server, env);
|
||||
// LI SDD M3.2: skills + examples lookup(KBDB-backed)
|
||||
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
|
||||
registerAllSkillExampleTools(server, env);
|
||||
// 2026-08-13:吃 identity 只為了「把話講對」——舊 token 誠實回 identity_missing,
|
||||
// 且 401 時能告訴登入者「是這批工具還走服務憑據,不是你的帳號讀不到」。
|
||||
// ⚠️ 這批**尚未**改走 portal 資料面(portal 沒有 by-entry_type 的 listing 端點;
|
||||
// 要接得補 API,不是在這層拼裝——rule 07 §3.1)。見該檔檔頭。
|
||||
registerAllSkillExampleTools(server, env, identity);
|
||||
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
|
||||
registerAllRecipeTools(server, env);
|
||||
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2)
|
||||
// 鐵律:不提供建表/SQL tool,AI 只有 template+slot 可用(類 Supabase 萬用表)
|
||||
registerAllKbdbDataTools(server, env);
|
||||
// 2026-08-12:知識面(kbdb_*)全部改吃 identity——以帳密連線者走 portal 資料面
|
||||
// (權限=那個人的權限),服務級憑據維持既有 KBDB 直連。見 lib/portal-client.ts。
|
||||
registerAllKbdbDataTools(server, env, identity);
|
||||
// issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點)
|
||||
// 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷)
|
||||
registerAllKbdbGraphTools(server, env, orgNamespace);
|
||||
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
|
||||
// library-map SDD M4(Arcrun#39): 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map//map/:library)
|
||||
// retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6)
|
||||
registerAllKbdbMapTools(server, env);
|
||||
registerAllKbdbMapTools(server, env, identity);
|
||||
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
|
||||
registerWhoami(server, env, orgNamespace);
|
||||
registerWhoami(server, env, orgNamespace, identity);
|
||||
}
|
||||
|
||||
+18
-4
@@ -2,6 +2,15 @@ export interface Env {
|
||||
COMPONENT_REGISTRY: Fetcher;
|
||||
CYPHER_EXECUTOR: Fetcher;
|
||||
KBDB: Fetcher;
|
||||
/**
|
||||
* KBDB 的服務內部金鑰。
|
||||
*
|
||||
* 2026-08-12 後**知識面(kbdb_*)以帳密連線時完全不用它**——那條路改走 cypher 的
|
||||
* `/portal/data/*`,帶的是登入者自己的 portal session。它現在只剩兩個用途:
|
||||
* ① 官方 SaaS 的 partner-key 驗證(middleware/partner-auth.ts 第 3 條)
|
||||
* ② 服務級 token(static token)連線時的既有 KBDB 直連(零回歸)
|
||||
* 兩者都拆掉之後,這個 binding 才能從 MCP 移除。
|
||||
*/
|
||||
KBDB_INTERNAL_TOKEN: string;
|
||||
API_KEY?: string;
|
||||
// Platform telemetry / feedback aggregation key (optional)
|
||||
@@ -20,11 +29,16 @@ export interface Env {
|
||||
// 短效認證儲存:authorization code(TTL ~600s)+ access token(TTL = MCP_TOKEN_TTL)。
|
||||
// 只放「取得的暫時性認證」,key 用 SHA-256 hash(KV list 不外洩可用 token)。長效機密不進 KV。
|
||||
OAUTH_KV?: KVNamespace;
|
||||
// Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。
|
||||
// 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。
|
||||
// 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)。
|
||||
// 【已停用,2026-07-30】舊的 owner 祕密。把關改成「使用者自己的 Portal 帳密」——
|
||||
// 沒人給得了封測者這把祕密(安裝器產生後從不顯示、CF secret 又讀不回),
|
||||
// 而且全實例共用一把、分不出是誰連上來的。程式已不再讀它;欄位留著只為不讓舊 toml 炸掉。
|
||||
MCP_OWNER_SECRET?: string;
|
||||
// OAuth 換發出的 access_token 綁定的 namespace(owner 的資料分區)。預設 "leo"。
|
||||
// **工作流面**(arcrun_* 工具)的租戶代號,當 cypher 的 X-Arcrun-API-Key 用。預設 "leo"。
|
||||
//
|
||||
// ⚠️ 2026-08-12 起**知識面(kbdb_*)不再讀這個欄位**:那邊改成跟著登入者的 portal session
|
||||
// 走(oauth/store.ts PortalIdentity)。此欄位曾被當成 KBDB 的 owner_id ⇒ 不管誰登入
|
||||
// 都看到同一格、而且是全部——那個用法已經消滅。
|
||||
// 要連工作流面也拆掉它,得先在 cypher 開一組吃 portal session 的 workflow 端點(下一步)。
|
||||
MCP_OWNER_NAMESPACE?: string;
|
||||
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 2592000(30 天)。
|
||||
// 過期後 claude.ai 重走 OAuth(owner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* server instructions(`initialize` 時送出的那份,AI **沒辦法自己再要一次**)。
|
||||
*
|
||||
* 這份測試守的是 2026-08-13 leo 那句話:
|
||||
* 「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**。」
|
||||
*
|
||||
* 病灶(實測):instructions 裡唯一提到「這裡有知識可查」的,是**選配的**【藏書地圖】段
|
||||
* (1500ms 逾時、失敗回 null、無聲消失)。一條 portal 連線就沒收到它 ⇒ 那個 session
|
||||
* 為了回答「Arcrun 是什麼」去讀了 682 行原始碼,而 `kbdb_search` 當下回得出 33 筆真答案。
|
||||
*
|
||||
* 所以本檔的驗收判準只有一句:
|
||||
* **不管地圖抓不抓得到、身分是哪一種,讀完這份 instructions 的下一個動作都必須是「去查知識庫」。**
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { buildServerInstructions } from "../../src/mcp-handler.js";
|
||||
import { __resetLibraryMapInstructionsCacheForTests } from "../../src/lib/library-map.js";
|
||||
import type { Env } from "../../src/types.js";
|
||||
import type { KnowledgeIdentity } from "../../src/lib/portal-client.js";
|
||||
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
/** 假 KBDB service binding(服務級憑據路徑)。 */
|
||||
function makeEnv(respond: () => Response | Promise<Response>): Env {
|
||||
return { KBDB: { fetch: async () => respond() } } as unknown as Env;
|
||||
}
|
||||
|
||||
const MAP_OK = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: "kb", narrative: "leo 的知識庫主庫", top_entities: ["Arcrun"], triplet_count: 1854 },
|
||||
],
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* 「一個什麼都不知道的 AI 讀完,下一個動作會是什麼」的機械化判準。
|
||||
* 三份 instructions 都必須通過同一組斷言——通不過就是還沒修好。
|
||||
*/
|
||||
function expectPointsAtKnowledge(text: string) {
|
||||
// ① 明講這條連線後面有主人的知識庫
|
||||
expect(text).toContain("知識庫");
|
||||
// ② 指名工具與呼叫法(AI 不必猜工具名)
|
||||
expect(text).toContain("kbdb_search");
|
||||
expect(text).toContain("Arcrun 是什麼");
|
||||
// ③ 明確排除三條錯路
|
||||
expect(text).toContain("不是 grep 原始碼");
|
||||
expect(text).toContain("不是上網搜");
|
||||
expect(text).toContain("不是回答「我不知道」");
|
||||
// ④ 「沒查到」與「沒有」不可以混為一談
|
||||
expect(text).toContain("那是**讀不到**,不是**不存在**");
|
||||
}
|
||||
|
||||
describe("buildServerInstructions — 三種情境都必須指向知識庫", () => {
|
||||
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
|
||||
|
||||
it("① 地圖抓得到:地圖照舊注入,且知識段仍在", async () => {
|
||||
const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE);
|
||||
expectPointsAtKnowledge(text);
|
||||
expect(text).toContain("【藏書地圖】");
|
||||
expect(text).toContain("kb:leo 的知識庫主庫");
|
||||
// 地圖成功時不該同時出現「沒取到」的話
|
||||
expect(text).not.toContain("【藏書地圖:這次沒取到】");
|
||||
});
|
||||
|
||||
it("② 地圖抓不到(HTTP 500):明講「沒取到」,不靜默、也不等於沒有知識", async () => {
|
||||
const text = await buildServerInstructions(makeEnv(() => new Response("boom", { status: 500 })), SERVICE);
|
||||
expectPointsAtKnowledge(text);
|
||||
expect(text).toContain("【藏書地圖:這次沒取到】");
|
||||
expect(text).toContain("這是「地圖沒拿到」,不是「這裡沒有知識」");
|
||||
});
|
||||
|
||||
it("② 地圖逾時/binding 爆炸:同樣明講,不擋連線(不 throw)", async () => {
|
||||
const env = {
|
||||
KBDB: {
|
||||
fetch: async () => {
|
||||
throw new Error("kbdb down");
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
const text = await buildServerInstructions(env, SERVICE);
|
||||
expectPointsAtKnowledge(text);
|
||||
expect(text).toContain("【藏書地圖:這次沒取到】");
|
||||
});
|
||||
|
||||
it("② 地圖回空清單:也算沒取到,不可以長得像「這裡沒有知識」", async () => {
|
||||
const text = await buildServerInstructions(
|
||||
makeEnv(() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 }))),
|
||||
SERVICE,
|
||||
);
|
||||
expect(text).toContain("【藏書地圖:這次沒取到】");
|
||||
expect(text).toContain("不是「這裡沒有知識」");
|
||||
});
|
||||
|
||||
it("③ 舊 token(stale):講成身分問題+給可執行的修法,仍指向知識庫", async () => {
|
||||
const text = await buildServerInstructions(makeEnv(MAP_OK), STALE);
|
||||
expectPointsAtKnowledge(text);
|
||||
expect(text).toContain("舊版簽發的 token");
|
||||
expect(text).toContain("identity_missing");
|
||||
expect(text).toContain("重新連線");
|
||||
// 🔴 不可以講成「知識庫是空的」
|
||||
expect(text).toContain("這不代表知識庫是空的");
|
||||
});
|
||||
|
||||
it("stale 不去打 KBDB(地圖本身就是情報,舊 token 不給)", async () => {
|
||||
let called = 0;
|
||||
const env = {
|
||||
KBDB: {
|
||||
fetch: async () => {
|
||||
called += 1;
|
||||
return MAP_OK();
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
await buildServerInstructions(env, STALE);
|
||||
expect(called).toBe(0);
|
||||
});
|
||||
|
||||
it("Arcrun 指路段照舊存在(知識段是新增的,不是取代)", async () => {
|
||||
const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE);
|
||||
expect(text).toContain("# Arcrun — 你已經配備了這套工具,別上網找");
|
||||
expect(text).toContain("【先讀這裡】");
|
||||
// 步驟 4 不再指名一個可能沒被 seed 的 slug(leo21c 實測連 INDEX 都沒有)
|
||||
expect(text).toContain("arcrun_list_skills()");
|
||||
expect(text).not.toContain("arcrun_get_skill('INDEX')");
|
||||
});
|
||||
});
|
||||
+202
-12
@@ -59,14 +59,55 @@ async function pkcePair() {
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* cypher `/portal/login` 的假替身(2026-08-12 起 MCP 的把關就是這支——用使用者自己的
|
||||
* Portal 帳密,沒有另一把 owner secret)。帳密對 → 回 session_token + 身分欄位;不對 → 401。
|
||||
*/
|
||||
const GOOD_EMAIL = "leo@example.com";
|
||||
const GOOD_PASSWORD = "correct horse";
|
||||
|
||||
function cypherMock(
|
||||
over: {
|
||||
/** null = 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */
|
||||
sessionToken?: string | null;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
libraries?: string[];
|
||||
sessionExpiresIn?: number;
|
||||
} = {},
|
||||
): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } {
|
||||
const calls: Array<{ email: string; password: string }> = [];
|
||||
const fetcher = {
|
||||
async fetch(req: Request) {
|
||||
const body = (await req.json()) as { email: string; password: string };
|
||||
calls.push(body);
|
||||
if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) {
|
||||
return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 });
|
||||
}
|
||||
const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
...(sessionToken ? { session_token: sessionToken } : {}),
|
||||
display_name: over.displayName ?? "Leo",
|
||||
role: over.role ?? "admin",
|
||||
libraries: over.libraries ?? ["*"],
|
||||
session_expires_in: over.sessionExpiresIn ?? 604800,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
} as unknown as Fetcher;
|
||||
return { fetcher, calls };
|
||||
}
|
||||
|
||||
function baseEnv(over: Partial<Env> = {}): Env {
|
||||
return {
|
||||
COMPONENT_REGISTRY: {} as Fetcher,
|
||||
CYPHER_EXECUTOR: {} as Fetcher,
|
||||
CYPHER_EXECUTOR: cypherMock().fetcher,
|
||||
KBDB: {} as Fetcher,
|
||||
KBDB_INTERNAL_TOKEN: "internal",
|
||||
OAUTH_KV: makeKV(),
|
||||
MCP_OWNER_SECRET: "s3cr3t-owner",
|
||||
MCP_OWNER_NAMESPACE: "leo",
|
||||
...over,
|
||||
} as Env;
|
||||
@@ -121,6 +162,8 @@ describe("oauth/store", () => {
|
||||
scope: "mcp",
|
||||
resource: "https://mcp/mcp",
|
||||
namespace: "leo",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||||
portal_session_expires_in: 604800,
|
||||
});
|
||||
const first = await consumeAuthCode(kv, "code-1");
|
||||
expect(first?.namespace).toBe("leo");
|
||||
@@ -271,7 +314,11 @@ describe("oauth flow (整合)", () => {
|
||||
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.text()).toContain("Owner 祕密");
|
||||
const consentHtml = await ok.text();
|
||||
// 同意頁問的是 Portal 帳密(不是另一把 owner secret)
|
||||
expect(consentHtml).toContain("Portal");
|
||||
expect(consentHtml).toContain('name="email"');
|
||||
expect(consentHtml).toContain('name="password"');
|
||||
// 缺 PKCE → 400
|
||||
const bad = await app.req(
|
||||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||||
@@ -281,18 +328,19 @@ describe("oauth flow (整合)", () => {
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /authorize:MCP_OWNER_SECRET 未設 → 503(不留不安全預設)", async () => {
|
||||
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
|
||||
it("GET /authorize:不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁)", async () => {
|
||||
// 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。
|
||||
const app = buildApp(baseEnv());
|
||||
const { challenge } = await pkcePair();
|
||||
const r = await app.req(
|
||||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||||
"https://claude.ai/cb",
|
||||
)}&code_challenge=${challenge}&code_challenge_method=S256`,
|
||||
);
|
||||
expect(r.status).toBe(503);
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
|
||||
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
|
||||
const env = baseEnv();
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
@@ -310,7 +358,8 @@ describe("oauth flow (整合)", () => {
|
||||
code_challenge_method: "S256",
|
||||
scope: "mcp",
|
||||
resource: "https://mcp.arcrun.dev/mcp",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -344,6 +393,143 @@ describe("oauth flow (整合)", () => {
|
||||
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
|
||||
});
|
||||
|
||||
// ── 2026-08-12:身分要接住並攜帶(本次修的病根)─────────────────────────────
|
||||
describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => {
|
||||
it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => {
|
||||
const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher });
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const authRes = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
||||
const tokRes = await app.req("/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirect,
|
||||
}).toString(),
|
||||
});
|
||||
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
|
||||
expect(at?.portal?.session).toBe("sess-abc");
|
||||
expect(at?.portal?.display_name).toBe("小明");
|
||||
expect(at?.portal?.role).toBe("user");
|
||||
expect(at?.portal?.libraries).toEqual(["kb"]);
|
||||
});
|
||||
|
||||
it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => {
|
||||
// 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。
|
||||
const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher });
|
||||
const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher });
|
||||
|
||||
async function tokenFor(env: Env) {
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const a = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
|
||||
const t = await app.req("/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirect,
|
||||
}).toString(),
|
||||
});
|
||||
return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token);
|
||||
}
|
||||
|
||||
const a = await tokenFor(envA);
|
||||
const b = await tokenFor(envB);
|
||||
expect(a?.portal?.session).not.toBe(b?.portal?.session);
|
||||
expect(a?.portal?.libraries).toEqual(["*"]);
|
||||
expect(b?.portal?.libraries).toEqual(["kb"]);
|
||||
});
|
||||
|
||||
it("access_token 活不過它底下的 portal session(TTL 取兩者較小)", async () => {
|
||||
const env = baseEnv({
|
||||
MCP_TOKEN_TTL: "2592000", // 30 天
|
||||
CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時
|
||||
});
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const a = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
|
||||
const t = await app.req("/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirect,
|
||||
}).toString(),
|
||||
});
|
||||
expect((await t.json()).expires_in).toBe(3600);
|
||||
});
|
||||
|
||||
it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => {
|
||||
const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher }));
|
||||
const { challenge } = await pkcePair();
|
||||
const r = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: "https://claude.ai/cb",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(r.status).toBe(401);
|
||||
expect(r.headers.get("location")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("錯誤 owner 祕密 → 401、不發 code", async () => {
|
||||
const app = buildApp(baseEnv());
|
||||
const { challenge } = await pkcePair();
|
||||
@@ -355,7 +541,8 @@ describe("oauth flow (整合)", () => {
|
||||
redirect_uri: "https://claude.ai/cb",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
owner_secret: "WRONG",
|
||||
email: GOOD_EMAIL,
|
||||
password: "WRONG",
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -377,7 +564,8 @@ describe("oauth flow (整合)", () => {
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -445,7 +633,8 @@ describe("oauth resource(RFC 8707)簽發端把關", () => {
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
resource,
|
||||
owner_secret: "s3cr3t-owner",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -570,7 +759,8 @@ describe("oauth store drift guard:OAUTH_KV 的 put 一律帶 TTL", () => {
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* kbdb_* 資料層工具:**用登入進來的那個人的身分查詢**(2026-08-12)。
|
||||
*
|
||||
* leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的
|
||||
* MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
|
||||
*
|
||||
* 本檔守三件事:
|
||||
* ① 以帳密連線時,查詢**帶登入者的 portal session** 打 cypher `/portal/data/*`
|
||||
* ——不再拿 KBDB 的服務內部金鑰直打 KBDB(那條路繞過所有庫過濾)。
|
||||
* ② 呼叫端自帶的 owner_id **一律不生效**(範圍由帳號權限決定,不由呼叫端指定)。
|
||||
* ③ 舊 token(沒有身分)**fail-closed**:誠實要求重新連線,不偷偷退回服務金鑰那條老路。
|
||||
* ④ 服務級憑據(static token / partner key)維持既有 KBDB 直連(零回歸)。
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { Env } from "../../../src/types.js";
|
||||
import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js";
|
||||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||
content: { type: string; text: string }[];
|
||||
isError?: boolean;
|
||||
}>;
|
||||
|
||||
function makeServer() {
|
||||
const tools = new Map<string, { description: string; handler: ToolHandler }>();
|
||||
const server = {
|
||||
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
|
||||
tools.set(name, { description, handler });
|
||||
},
|
||||
};
|
||||
return { server: server as unknown as McpServer, tools };
|
||||
}
|
||||
|
||||
/** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */
|
||||
function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) {
|
||||
const cypherCalls: { url: URL; init?: RequestInit }[] = [];
|
||||
const kbdbCalls: { url: URL; init?: RequestInit }[] = [];
|
||||
const env = {
|
||||
CYPHER_EXECUTOR: {
|
||||
fetch: async (input: string, init?: RequestInit) => {
|
||||
const url = new URL(input);
|
||||
cypherCalls.push({ url, init });
|
||||
return respond("cypher", url, init);
|
||||
},
|
||||
},
|
||||
KBDB: {
|
||||
fetch: async (input: string, init?: RequestInit) => {
|
||||
const url = new URL(input);
|
||||
kbdbCalls.push({ url, init });
|
||||
return respond("kbdb", url, init);
|
||||
},
|
||||
},
|
||||
KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path",
|
||||
} as unknown as Env;
|
||||
return { env, cypherCalls, kbdbCalls };
|
||||
}
|
||||
|
||||
function parseResult(r: { content: { text: string }[] }) {
|
||||
return JSON.parse(r.content[0].text) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const PORTAL: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
|
||||
};
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
function tools(identity: KnowledgeIdentity, respond: Parameters<typeof makeEnv>[0]) {
|
||||
const { server, tools } = makeServer();
|
||||
const e = makeEnv(respond);
|
||||
registerAllKbdbDataTools(server, e.env, identity);
|
||||
return { tools, ...e };
|
||||
}
|
||||
|
||||
const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 }));
|
||||
|
||||
describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => {
|
||||
const cases: Array<{ tool: string; args: Record<string, unknown>; path: string; method?: string }> = [
|
||||
{ tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" },
|
||||
{ tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" },
|
||||
{ tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" },
|
||||
{ tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" },
|
||||
{ tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" },
|
||||
{ tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => {
|
||||
const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK);
|
||||
const res = await t.get(c.tool)!.handler(c.args);
|
||||
expect(res.isError).toBeUndefined();
|
||||
|
||||
// 走的是 cypher 的 portal 資料面,不是 KBDB 直連
|
||||
expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0);
|
||||
expect(cypherCalls).toHaveLength(1);
|
||||
expect(cypherCalls[0].url.pathname).toBe(c.path);
|
||||
expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET");
|
||||
|
||||
// 帶的是「那個人的 session」,不是任何服務金鑰
|
||||
const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization");
|
||||
expect(auth).toBe("Bearer sess-abc");
|
||||
expect(auth).not.toContain("service-key");
|
||||
});
|
||||
}
|
||||
|
||||
it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => {
|
||||
const { tools: t, cypherCalls } = tools(PORTAL, OK);
|
||||
await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" });
|
||||
await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" });
|
||||
for (const call of cypherCalls) {
|
||||
expect(call.url.searchParams.get("owner_id")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => {
|
||||
const { tools: t, cypherCalls } = tools(PORTAL, OK);
|
||||
await t.get("kbdb_create_record")!.handler({
|
||||
template: "contact",
|
||||
values: { name: "Leo" },
|
||||
owner_id: "someone-else",
|
||||
});
|
||||
const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty("owner_id");
|
||||
});
|
||||
|
||||
it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => {
|
||||
const { tools: t } = tools(PORTAL, () =>
|
||||
new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }),
|
||||
);
|
||||
const res = await t.get("kbdb_create_record")!.handler({
|
||||
template: "note",
|
||||
values: { library: "secret", body: "x" },
|
||||
});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => {
|
||||
const { tools: t } = tools(PORTAL, () =>
|
||||
new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }),
|
||||
);
|
||||
const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("not_found");
|
||||
expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內");
|
||||
});
|
||||
|
||||
it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => {
|
||||
const { tools: t } = tools(PORTAL, () =>
|
||||
new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||
);
|
||||
const res = await t.get("kbdb_search")!.handler({ q: "x" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("session_expired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => {
|
||||
for (const name of [
|
||||
"kbdb_search",
|
||||
"kbdb_query",
|
||||
"kbdb_get_record",
|
||||
"kbdb_list_templates",
|
||||
"kbdb_create_template",
|
||||
"kbdb_create_record",
|
||||
]) {
|
||||
it(`${name} → identity_missing,且一個查詢都不發`, async () => {
|
||||
const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK);
|
||||
const res = await t.get(name)!.handler({
|
||||
q: "x",
|
||||
template: "t",
|
||||
record_id: "r",
|
||||
name: "n",
|
||||
slots: ["a"],
|
||||
values: { a: "b" },
|
||||
});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||
expect(cypherCalls).toHaveLength(0);
|
||||
expect(kbdbCalls).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("回歸:服務級憑據維持既有 KBDB 直連", () => {
|
||||
it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => {
|
||||
const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK);
|
||||
const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(cypherCalls).toHaveLength(0);
|
||||
expect(kbdbCalls).toHaveLength(1);
|
||||
expect(kbdbCalls[0].url.pathname).toBe("/entries/search");
|
||||
expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo");
|
||||
});
|
||||
|
||||
it("kbdb_query / kbdb_get_record 路徑不變", async () => {
|
||||
const { tools: t, kbdbCalls } = tools(SERVICE, OK);
|
||||
await t.get("kbdb_query")!.handler({ template: "triplet" });
|
||||
await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" });
|
||||
expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([
|
||||
"/records/by-template/triplet",
|
||||
"/records/rec_1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,17 @@ import {
|
||||
registerGraphNeighbors,
|
||||
GRAPH_NEIGHBORS_WORKFLOW,
|
||||
} from "../../../src/tools/kbdb_graph.js";
|
||||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||
|
||||
/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */
|
||||
const PORTAL: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||||
};
|
||||
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||
@@ -45,7 +56,7 @@ describe("kbdb_graph_neighbors: registration", () => {
|
||||
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("{}"));
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
|
||||
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
|
||||
});
|
||||
@@ -61,7 +72,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "Arcrun",
|
||||
depth: 2,
|
||||
@@ -88,7 +99,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -108,7 +119,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -124,7 +135,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -143,7 +154,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -156,7 +167,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makeEnv(() => new Response("{}"));
|
||||
registerGraphNeighbors(server, env, "");
|
||||
registerGraphNeighbors(server, env, "", SERVICE);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -166,3 +177,77 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)──
|
||||
describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => {
|
||||
it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makeEnv(
|
||||
() =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }],
|
||||
edges: [],
|
||||
count: 1,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 });
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A");
|
||||
expect(calls[0].url.searchParams.get("depth")).toBe("2");
|
||||
const headers = new Headers(calls[0].init!.headers as HeadersInit);
|
||||
expect(headers.get("Authorization")).toBe("Bearer sess-abc");
|
||||
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect((parseResult(res).data as { count: number }).count).toBe(1);
|
||||
});
|
||||
|
||||
it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(calls).toHaveLength(1);
|
||||
// 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫)
|
||||
expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull();
|
||||
});
|
||||
|
||||
it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("session_expired");
|
||||
});
|
||||
|
||||
it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makeEnv(() => new Response("{}"));
|
||||
registerGraphNeighbors(server, env, "leo", STALE);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||
expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,32 @@ import {
|
||||
renderLibraryMapLines,
|
||||
__resetLibraryMapInstructionsCacheForTests,
|
||||
} from "../../../src/lib/library-map.js";
|
||||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||
|
||||
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
|
||||
const PORTAL: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
|
||||
};
|
||||
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
/** 假 CYPHER_EXECUTOR binding(portal 資料面用)。 */
|
||||
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
|
||||
const calls: { url: URL; init?: RequestInit }[] = [];
|
||||
const env = {
|
||||
CYPHER_EXECUTOR: {
|
||||
fetch: async (input: string, init?: RequestInit) => {
|
||||
const url = new URL(input);
|
||||
calls.push({ url, init });
|
||||
return respond(url, init);
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
return { env, calls };
|
||||
}
|
||||
|
||||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||
@@ -56,7 +82,7 @@ describe("kbdb_get_map: registration", () => {
|
||||
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("{}"));
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
expect(tools.has("kbdb_get_map")).toBe(true);
|
||||
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
|
||||
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
|
||||
@@ -69,7 +95,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
@@ -90,7 +116,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
|
||||
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
|
||||
});
|
||||
@@ -105,7 +131,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const data = parseResult(res).data as {
|
||||
libraries: { top_entities: string[]; triplet_count: number }[];
|
||||
@@ -119,7 +145,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const body = parseResult(res);
|
||||
expect(body.ok).toBe(true);
|
||||
@@ -135,7 +161,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const body = parseResult(res);
|
||||
const hintsText = JSON.stringify(body.hints);
|
||||
@@ -149,7 +175,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
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 }));
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
@@ -178,7 +204,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(calls[0].url.pathname).toBe("/map/kb");
|
||||
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
|
||||
@@ -197,7 +223,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
triplet_count: "111",
|
||||
};
|
||||
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(res.isError).toBeUndefined();
|
||||
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
|
||||
@@ -212,7 +238,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
@@ -233,7 +259,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("internal_error");
|
||||
@@ -258,7 +284,7 @@ describe("buildLibraryMapInstructions", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
const text = await buildLibraryMapInstructions(env);
|
||||
const text = await buildLibraryMapInstructions(env, SERVICE);
|
||||
expect(text).not.toBeNull();
|
||||
// design §4 格式:{library}:{narrative}|核心:{top3}|{triplet_count} triplets
|
||||
expect(text!).toContain("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets");
|
||||
@@ -269,7 +295,7 @@ describe("buildLibraryMapInstructions", () => {
|
||||
|
||||
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
|
||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("binding throws → null(靜默略過)", async () => {
|
||||
@@ -280,22 +306,22 @@ describe("buildLibraryMapInstructions", () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("caches within TTL:same isolate 第二次不再打 /map", async () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const first = await buildLibraryMapInstructions(env);
|
||||
const second = await buildLibraryMapInstructions(env);
|
||||
const first = await buildLibraryMapInstructions(env, SERVICE);
|
||||
const second = await buildLibraryMapInstructions(env, SERVICE);
|
||||
expect(second).toBe(first);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
@@ -318,3 +344,95 @@ describe("renderLibraryMapLines", () => {
|
||||
expect(renderLibraryMapLines([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
|
||||
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
|
||||
// 一個只有部分權限的帳號。
|
||||
describe("藏書地圖:登入身分(portal 資料面)", () => {
|
||||
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
|
||||
|
||||
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||||
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
|
||||
});
|
||||
|
||||
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
|
||||
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
|
||||
});
|
||||
|
||||
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
expect(body.error_code).toBe("map_not_found");
|
||||
expect(String(body.human_message)).toContain("不在你被授權");
|
||||
});
|
||||
|
||||
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("session_expired");
|
||||
});
|
||||
|
||||
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||||
registerGetMap(server, env, STALE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const text = await buildLibraryMapInstructions(env, PORTAL);
|
||||
expect(text).toContain("kb");
|
||||
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||||
});
|
||||
|
||||
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const other: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
|
||||
};
|
||||
await buildLibraryMapInstructions(env, PORTAL);
|
||||
await buildLibraryMapInstructions(env, other);
|
||||
expect(calls).toHaveLength(2); // 兩次真的各打一次
|
||||
await buildLibraryMapInstructions(env, PORTAL);
|
||||
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
|
||||
});
|
||||
|
||||
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
|
||||
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||||
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* skill/example 工具的**誠實性**測試(2026-08-13,Arcrun#100/#109 同一族)。
|
||||
*
|
||||
* 真實事故:`arcrun_get_skill('write_intent_workflow')` 回「skill ... **不存在**」,
|
||||
* 但同一台實例的 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`
|
||||
*(`entry_type: agent-skill`,`source: installer-seed`)——**查的鍵逐字相符**。
|
||||
* 真兇是 `if (!resp.ok) return null;`:一個 HTTP 401 被翻譯成「不存在」。
|
||||
*
|
||||
* 這個謊有實害:instructions 叫 AI 第一步先讀 skill,它照做、收到「不存在」,
|
||||
* 於是結論「這裡沒有 skill」⇒ 去猜/grep repo/上網。
|
||||
*
|
||||
* 判準:**讀不到(401/403/5xx/連不上)與不存在(KBDB 正常回應但沒這張卡)必須長得不一樣**,
|
||||
* 且讀不到時要交出還走得通的下一步(`kbdb_search`)。
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { Env } from "../../../src/types.js";
|
||||
import {
|
||||
registerGetSkill,
|
||||
registerListSkills,
|
||||
registerGetExample,
|
||||
} from "../../../src/tools/arcrun_skills_examples.js";
|
||||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
const PORTAL: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||||
};
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||
content: { type: string; text: string }[];
|
||||
isError?: boolean;
|
||||
}>;
|
||||
|
||||
function fakeServer() {
|
||||
const tools = new Map<string, ToolHandler>();
|
||||
const server = {
|
||||
tool: (name: string, _desc: string, _schema: unknown, handler: ToolHandler) => {
|
||||
tools.set(name, handler);
|
||||
},
|
||||
} as unknown as McpServer;
|
||||
return { server, tools };
|
||||
}
|
||||
|
||||
function makeEnv(respond: (url: string) => Response): Env {
|
||||
return {
|
||||
KBDB_INTERNAL_TOKEN: "",
|
||||
KBDB: { fetch: async (url: string) => respond(url) },
|
||||
} as unknown as Env;
|
||||
}
|
||||
|
||||
const parse = (res: { content: { text: string }[] }) => JSON.parse(res.content[0].text);
|
||||
|
||||
describe("arcrun_get_skill — 401 不准講成「不存在」", () => {
|
||||
it("KBDB 回 401 → kbdb_unauthorized,訊息明說「讀不到不是不存在」並給 kbdb_search 這條路", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), SERVICE);
|
||||
const res = await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" });
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parse(res);
|
||||
expect(body.error_code).toBe("kbdb_unauthorized");
|
||||
// 🔴 這句是本次事故的核心:不可以再出現舊版那句「skill "x" 不存在」的結論
|
||||
expect(body.human_message).not.toMatch(/skill "[^"]*" 不存在/);
|
||||
expect(body.human_message).toContain("這是「讀不到」,不是「不存在」");
|
||||
expect(body.human_message).toContain("讀不到");
|
||||
expect(body.human_message).toContain("HTTP 401");
|
||||
// 還走得通的那條路要交到 AI 手上(實測 kbdb_search 撈得到同一張卡)
|
||||
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'skill-write_intent_workflow' })");
|
||||
// 並明白禁止它改口
|
||||
expect(body.next_actions.join("\n")).toContain("不准把這個錯誤回報成");
|
||||
});
|
||||
|
||||
it("以帳密登入的連線收到 401 → additionally 說清楚「不是你的帳號讀不到」", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), PORTAL);
|
||||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_recipe" }));
|
||||
expect(body.human_message).toContain("服務內部憑據");
|
||||
expect(body.human_message).toContain("不代表你的帳號讀不到");
|
||||
});
|
||||
|
||||
it("KBDB 5xx → kbdb_unreachable(連不上,也不是不存在)", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
registerGetSkill(server, makeEnv(() => new Response("boom", { status: 503 })), SERVICE);
|
||||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "whatever" }));
|
||||
expect(body.error_code).toBe("kbdb_unreachable");
|
||||
expect(body.human_message).toContain("HTTP 503");
|
||||
});
|
||||
|
||||
it("binding 直接爆炸 → 也回 kbdb_unreachable,不吞成 not_found", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
const env = {
|
||||
KBDB: {
|
||||
fetch: async () => {
|
||||
throw new Error("kbdb down");
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
registerGetSkill(server, env, SERVICE);
|
||||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
|
||||
expect(body.error_code).toBe("kbdb_unreachable");
|
||||
expect(body.human_message).toContain("kbdb down");
|
||||
});
|
||||
|
||||
it("KBDB 正常回應但真的沒這張卡 → not_found,且說明是「這台實例沒 seed」不是全世界沒有", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
registerGetSkill(
|
||||
server,
|
||||
makeEnv(() => new Response(JSON.stringify({ entries: [] }))),
|
||||
SERVICE,
|
||||
);
|
||||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "INDEX" }));
|
||||
expect(body.error_code).toBe("not_found");
|
||||
expect(body.human_message).toContain("KBDB 正常回應");
|
||||
expect(body.human_message).toContain("skill-INDEX");
|
||||
expect(body.next_actions.join("\n")).toContain("arcrun_list_skills()");
|
||||
});
|
||||
|
||||
it("卡片真的在 → 照舊回內容(沒改壞正路)", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
let seen = "";
|
||||
registerGetSkill(
|
||||
server,
|
||||
makeEnv((url) => {
|
||||
seen = url;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
entries: [{ id: "e1", page_name: "skill-write_intent_workflow", content: "# 意圖工作流", tags_json: '["skill:core"]' }],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
SERVICE,
|
||||
);
|
||||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" }));
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.content).toBe("# 意圖工作流");
|
||||
expect(seen).toContain("page_name=skill-write_intent_workflow");
|
||||
});
|
||||
|
||||
it("舊 token(stale)→ identity_missing(誠實要求重新連線,不謊稱找不到)", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
registerGetSkill(server, makeEnv(() => new Response("{}")), STALE);
|
||||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
|
||||
expect(body.error_code).toBe("identity_missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arcrun_list_skills / arcrun_get_example — 同一條規則", () => {
|
||||
it("list_skills 撞 401 → kbdb_unauthorized(舊版是 fetch_failed 一句技術話)", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
registerListSkills(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
|
||||
const body = parse(await tools.get("arcrun_list_skills")!({}));
|
||||
expect(body.error_code).toBe("kbdb_unauthorized");
|
||||
expect(body.next_actions.join("\n")).toContain("kbdb_search");
|
||||
});
|
||||
|
||||
it("list_skills 成功時提醒「只用清單裡真的有的 slug」(別再猜 INDEX)", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
registerListSkills(
|
||||
server,
|
||||
makeEnv(() =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
entries: [{ id: "e1", page_name: "skill-write_recipe", content: "x", tags_json: "[]" }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
SERVICE,
|
||||
);
|
||||
const body = parse(await tools.get("arcrun_list_skills")!({}));
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.count).toBe(1);
|
||||
expect(body.hints.join("\n")).toContain("不要硬猜名字");
|
||||
});
|
||||
|
||||
it("get_example 撞 401 → 同樣是讀不到,不是「example 不存在」", async () => {
|
||||
const { server, tools } = fakeServer();
|
||||
registerGetExample(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
|
||||
const body = parse(await tools.get("arcrun_get_example")!({ slug: "rag-search-answer" }));
|
||||
expect(body.error_code).toBe("kbdb_unauthorized");
|
||||
expect(body.human_message).not.toMatch(/example "[^"]*" 不存在/);
|
||||
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'example-rag-search-answer' })");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
# 卡在人類閘前的產物(`Arcrun#89` / `#90` / `#91`)
|
||||
|
||||
> **為什麼這個資料夾存在**:這三樣東西都做完並實測過了,但落地的最後一步是
|
||||
> **終端機裡等人親手打字的互動閘**,AI 打不進去。
|
||||
> 2026-08-11 它們原本只存在於某個 session 的暫存目錄——**那種目錄一關就沒了**。
|
||||
> 先搶進版控,等人有空時再落地。
|
||||
|
||||
---
|
||||
|
||||
## 一、兩份 recipe(`#89`/`#90`)
|
||||
|
||||
`recipes/gitea_put_file.yaml` — 把檔案寫回 Gitea repo。**出貨線有 7 站等它。**
|
||||
`recipes/cf_worker_deploy_simple.yaml` — 部署單檔 Worker(classic 格式)。
|
||||
|
||||
**落地指令**(一份跑一次):
|
||||
|
||||
```
|
||||
acr recipe push pending-human-gate/recipes/gitea_put_file.yaml
|
||||
```
|
||||
|
||||
跑的時候會停下來要你**親手輸入資源名確認**——那是「把資源變成可被外部呼叫」的暴露同意閘,
|
||||
不是卡住,是設計如此。
|
||||
|
||||
⚠️ **`cf_worker_deploy_simple.yaml` 先別急著推**:`#90` 查出一件結構性的事——
|
||||
recipe 引擎的 body 一律 JSON,而 Cloudflare 上傳 Worker 的 API 要的是原始 JS 或 multipart。
|
||||
⇒ **classic 版只適用於沒有 bindings 的簡單情形**。而實查安裝器那站有 9 把 KV + 一顆 D1,
|
||||
**classic 版幫不上它**。詳見 `Leo/Arcrun#90`。
|
||||
|
||||
### 金鑰(D36)
|
||||
|
||||
兩份 recipe 都只寫名字(`gitea_token`/`cf_api_token`),真身由 credential 中心在執行前回填。
|
||||
對應的 auth-recipe **已經註冊在 leo21c 上**,可以直接查證:
|
||||
|
||||
```
|
||||
curl -s https://arcrun-cypher-executor.leo21c.workers.dev/auth-recipes/gitea
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、`hash` 零件(`#91`)
|
||||
|
||||
`hash-component/` — sha256/sha1/md5,hex/base64。出貨線的版本號機制與成品指紋核對都要它。
|
||||
|
||||
**已實測**(tinygo 編出來、wasmtime 真跑,三種演算法都跟系統原生指令**逐位元一致**)。
|
||||
`.wasm` 是 1.3 MB 編譯產物,**沒有進版控**——要驗自己重編:
|
||||
|
||||
```
|
||||
cd pending-human-gate/hash-component && tinygo build -target=wasi -o /tmp/hash.wasm main.go
|
||||
echo '{"algorithm":"sha256","input":"hello"}' | wasmtime /tmp/hash.wasm
|
||||
printf 'hello' | shasum -a 256 # 兩者應該一致
|
||||
```
|
||||
|
||||
**落地要走零件投稿流程**(D27/D28):`docs/component-pr-review-standard.md` 的 checklist
|
||||
+ 人在終端機互動跑 `scripts/component-arm.sh`。
|
||||
🔴 `registry/components/` 底下有機械閘(`component-guard.sh`)擋著 AI 直接寫入——**那是刻意的**,
|
||||
所以這份放在 `pending-human-gate/`,不是放在它最終該去的位置。
|
||||
|
||||
---
|
||||
|
||||
## 落地之後
|
||||
|
||||
三樣都上去之後,`Arcrun#89`/`#91` 才能從 **◐ 半通** 變 **✅**——
|
||||
而判準是**貼一次真實的執行輸出**(recipe 對某個測試檔案回 2xx、零件在真端點上跑出正確雜湊),
|
||||
不是「推上去了」。
|
||||
@@ -0,0 +1,74 @@
|
||||
canonical_id: "hash"
|
||||
display_name: "計算雜湊"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [input]
|
||||
properties:
|
||||
algorithm:
|
||||
type: string
|
||||
enum: [sha256, sha1, md5]
|
||||
description: 雜湊演算法,預設 sha256
|
||||
input:
|
||||
type: string
|
||||
description: 要算雜湊的內容
|
||||
encoding:
|
||||
type: string
|
||||
enum: [hex, base64]
|
||||
description: 輸出編碼,預設 hex
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: string
|
||||
algorithm:
|
||||
type: string
|
||||
encoding:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "sha256 hex(預設)"
|
||||
given: '{"algorithm":"sha256","input":"hello"}'
|
||||
then_contains: '"result":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"'
|
||||
- scenario: "sha1"
|
||||
given: '{"algorithm":"sha1","input":"hello"}'
|
||||
then_contains: '"result":"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"'
|
||||
- scenario: "md5"
|
||||
given: '{"algorithm":"md5","input":"hello"}'
|
||||
then_contains: '"result":"5d41402abc4b2a76b9719d911017c592"'
|
||||
- scenario: "base64 編碼"
|
||||
given: '{"algorithm":"sha256","input":"hello","encoding":"base64"}'
|
||||
then_contains: '"result":"LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="'
|
||||
- scenario: "預設 algorithm=sha256"
|
||||
given: '{"input":"hello"}'
|
||||
then_contains: '"algorithm":"sha256"'
|
||||
- scenario: "不支援的 algorithm"
|
||||
given: '{"algorithm":"crc32","input":"hello"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, logic, hash, checksum, versioning]
|
||||
description: >-
|
||||
計算內容雜湊(sha256/sha1/md5,輸出 hex 或 base64)。純計算,無網路/檔案 syscall。
|
||||
用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,是「改了東西版本沒動」在結構上
|
||||
不可能發生的機制來源;build 站核對官方成品指紋也用它。
|
||||
config_example: |
|
||||
compute_hash: # 節點名稱(可自訂)
|
||||
algorithm: "sha256" # 演算法(選填,預設 sha256),可選值:sha256/sha1/md5
|
||||
input: "{{ctx.bundle_content}}" # 要算雜湊的內容(必填)
|
||||
encoding: "hex" # 輸出編碼(選填,預設 hex),可選值:hex/base64
|
||||
@@ -0,0 +1,89 @@
|
||||
// hash — 計算內容雜湊(純計算,無網路/檔案 syscall)
|
||||
// 支援: sha256, sha1, md5;輸出編碼: hex(預設), base64
|
||||
// 用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,
|
||||
// 是「改了東西版本沒動」在結構上不可能發生的機制來源。
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Algorithm string `json:"algorithm"` // sha256(預設)| sha1 | md5
|
||||
Input string `json:"input"`
|
||||
Encoding string `json:"encoding"` // hex(預設)| base64
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var in Input
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
algorithm := in.Algorithm
|
||||
if algorithm == "" {
|
||||
algorithm = "sha256"
|
||||
}
|
||||
encoding := in.Encoding
|
||||
if encoding == "" {
|
||||
encoding = "hex"
|
||||
}
|
||||
|
||||
var sum []byte
|
||||
switch algorithm {
|
||||
case "sha256":
|
||||
h := sha256.Sum256([]byte(in.Input))
|
||||
sum = h[:]
|
||||
case "sha1":
|
||||
h := sha1.Sum([]byte(in.Input))
|
||||
sum = h[:]
|
||||
case "md5":
|
||||
h := md5.Sum([]byte(in.Input))
|
||||
sum = h[:]
|
||||
default:
|
||||
writeError("不支援的 algorithm: " + algorithm + "(支援 sha256/sha1/md5)")
|
||||
return
|
||||
}
|
||||
|
||||
var result string
|
||||
switch encoding {
|
||||
case "hex":
|
||||
result = hex.EncodeToString(sum)
|
||||
case "base64":
|
||||
result = base64.StdEncoding.EncodeToString(sum)
|
||||
default:
|
||||
writeError("不支援的 encoding: " + encoding + "(支援 hex/base64)")
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"result": result,
|
||||
"algorithm": algorithm,
|
||||
"encoding": encoding,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "arcrun-hash"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
workers_dev = true
|
||||
|
||||
[vars]
|
||||
COMPONENT_ID = "hash"
|
||||
|
||||
[[routes]]
|
||||
pattern = "hash.arcrun.dev/*"
|
||||
zone_name = "arcrun.dev"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user