Compare commits
37 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 | |||
| 525faaf5d0 | |||
| 37e13fc9bf | |||
| 674e1b4fa2 | |||
| 1d6dde4a01 |
@@ -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. 統一帳號來源(薄殼共用同一身份)
|
||||
|
||||
所有薄殼讀**同一份**身份設定:
|
||||
|
||||
@@ -52,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/
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __esm = (fn, res) => function __init() {
|
||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||
var __esm = (fn, res, err2) => function __init() {
|
||||
if (err2) throw err2[0];
|
||||
try {
|
||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||
} catch (e) {
|
||||
throw err2 = [e], e;
|
||||
}
|
||||
};
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
@@ -102,7 +107,7 @@ function applyBaseRuntimeOptions(runtime, options) {
|
||||
function applyModuleEvalRuntimeOptions(runtime, options) {
|
||||
options.moduleLoader && runtime.setModuleLoader(options.moduleLoader), options.shouldInterrupt && runtime.setInterruptHandler(options.shouldInterrupt), options.memoryLimitBytes !== void 0 && runtime.setMemoryLimit(options.memoryLimitBytes), options.maxStackSizeBytes !== void 0 && runtime.setMaxStackSize(options.maxStackSizeBytes);
|
||||
}
|
||||
var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, UnstableSymbol, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule;
|
||||
var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule;
|
||||
var init_chunk_JTKJZQYV = __esm({
|
||||
"registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() {
|
||||
init_dist();
|
||||
@@ -190,7 +195,7 @@ var init_chunk_JTKJZQYV = __esm({
|
||||
return this.dispose();
|
||||
}
|
||||
};
|
||||
SymbolDispose = Symbol.dispose ?? Symbol.for("Symbol.dispose");
|
||||
SymbolDispose = Symbol.dispose ?? /* @__PURE__ */ Symbol.for("Symbol.dispose");
|
||||
prototypeAsAny = UsingDisposable.prototype;
|
||||
prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() {
|
||||
return this.dispose();
|
||||
@@ -409,7 +414,6 @@ Lifetime used`) : new QuickJSUseAfterFree("Lifetime not alive");
|
||||
return this.module._free(ptr), str;
|
||||
}
|
||||
};
|
||||
UnstableSymbol = Symbol("Unstable");
|
||||
DefaultIntrinsics = Object.freeze({ BaseObjects: true, Date: true, Eval: true, StringNormalize: true, RegExp: true, JSON: true, Proxy: true, MapSet: true, TypedArrays: true, Promise: true });
|
||||
QuickJSIterator = class extends UsingDisposable {
|
||||
constructor(handle, context) {
|
||||
@@ -773,7 +777,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
[Symbol.for("nodejs.util.inspect.custom")]() {
|
||||
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
|
||||
return this.alive ? `${this.constructor.name} { ctx: ${this.ctx.value} rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
|
||||
}
|
||||
getFunction(fn_id) {
|
||||
@@ -909,7 +913,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
|
||||
debugLog(...msg) {
|
||||
this._debugMode && console.log("quickjs-emscripten:", ...msg);
|
||||
}
|
||||
[Symbol.for("nodejs.util.inspect.custom")]() {
|
||||
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
|
||||
return this.alive ? `${this.constructor.name} { rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
|
||||
}
|
||||
getSystemContext() {
|
||||
@@ -1339,10 +1343,10 @@ async function QuickJSRaw(moduleArg = {}) {
|
||||
x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m];
|
||||
}
|
||||
b = a(...g);
|
||||
return b = function(k) {
|
||||
return b = (function(k) {
|
||||
0 !== h && sa(h);
|
||||
return "string" === d ? R(k) : "boolean" === d ? !!k : k;
|
||||
}(b);
|
||||
})(b);
|
||||
};
|
||||
c.wasmMemory ? r = c.wasmMemory : r = new WebAssembly.Memory({ initial: (c.INITIAL_MEMORY || 16777216) / 65536, maximum: 32768 });
|
||||
K();
|
||||
@@ -1464,7 +1468,7 @@ async function QuickJSRaw(moduleArg = {}) {
|
||||
}, t: function(a, d) {
|
||||
c.callbacks.freeHostRef(void 0, a, d);
|
||||
} }, Z;
|
||||
Z = await async function() {
|
||||
Z = await (async function() {
|
||||
function a(b) {
|
||||
b = Z = b.exports;
|
||||
c._malloc = b.v;
|
||||
@@ -1551,7 +1555,7 @@ async function QuickJSRaw(moduleArg = {}) {
|
||||
});
|
||||
M ??= c.locateFile ? c.locateFile ? c.locateFile("emscripten-module.wasm", u) : u + "emscripten-module.wasm" : new URL("emscripten-module.wasm", import.meta.url).href;
|
||||
return a((await ea(d)).instance);
|
||||
}();
|
||||
})();
|
||||
(function() {
|
||||
function a() {
|
||||
c.calledRun = true;
|
||||
@@ -3034,7 +3038,7 @@ var Hono = class _Hono {
|
||||
var emptyParam = [];
|
||||
function match(method, path) {
|
||||
const matchers = this.buildAllMatchers();
|
||||
const match2 = (method2, path2) => {
|
||||
const match2 = ((method2, path2) => {
|
||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||
const staticMatch = matcher[2][path2];
|
||||
if (staticMatch) {
|
||||
@@ -3046,7 +3050,7 @@ function match(method, path) {
|
||||
}
|
||||
const index = match3.indexOf("", 1);
|
||||
return [matcher[1][index], match3];
|
||||
};
|
||||
});
|
||||
this.match = match2;
|
||||
return match2(method, path);
|
||||
}
|
||||
@@ -4008,7 +4012,7 @@ app.post("/", async (c) => {
|
||||
);
|
||||
}
|
||||
});
|
||||
var code_default = app;
|
||||
var index_default = app;
|
||||
export {
|
||||
code_default as default
|
||||
index_default as default
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __esm = (fn, res) => function __init() {
|
||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||
var __esm = (fn, res, err) => function __init() {
|
||||
if (err) throw err[0];
|
||||
try {
|
||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||
} catch (e) {
|
||||
throw err = [e], e;
|
||||
}
|
||||
};
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
@@ -1501,7 +1506,7 @@ var init_hono_base = __esm({
|
||||
// cypher-executor/node_modules/.pnpm/hono@4.12.10/node_modules/hono/dist/router/reg-exp-router/matcher.js
|
||||
function match(method, path) {
|
||||
const matchers = this.buildAllMatchers();
|
||||
const match2 = (method2, path2) => {
|
||||
const match2 = ((method2, path2) => {
|
||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||
const staticMatch = matcher[2][path2];
|
||||
if (staticMatch) {
|
||||
@@ -1513,7 +1518,7 @@ function match(method, path) {
|
||||
}
|
||||
const index = match3.indexOf("", 1);
|
||||
return [matcher[1][index], match3];
|
||||
};
|
||||
});
|
||||
this.match = match2;
|
||||
return match2(method, path);
|
||||
}
|
||||
@@ -2645,7 +2650,7 @@ var init_recipes = __esm({
|
||||
});
|
||||
|
||||
// cypher-executor/src/lib/constants.ts
|
||||
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, BUILTIN_COMPONENTS;
|
||||
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, WAIT_MAX_MS, BUILTIN_COMPONENTS;
|
||||
var init_constants3 = __esm({
|
||||
"cypher-executor/src/lib/constants.ts"() {
|
||||
"use strict";
|
||||
@@ -2693,6 +2698,7 @@ var init_constants3 = __esm({
|
||||
"CLICK": "ON_CLICK",
|
||||
"SUBFLOW": "CALLS_SUBFLOW"
|
||||
};
|
||||
WAIT_MAX_MS = 3e4;
|
||||
BUILTIN_COMPONENTS = /* @__PURE__ */ new Map([
|
||||
["comp_passthrough", (ctx) => ctx],
|
||||
["comp_uppercase", (ctx) => {
|
||||
@@ -2702,6 +2708,54 @@ var init_constants3 = __esm({
|
||||
["comp_counter", (ctx) => {
|
||||
const c = ctx;
|
||||
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 : {};
|
||||
const requested = typeof c.ms === "number" ? c.ms : Number(c.ms);
|
||||
if (!Number.isFinite(requested) || requested <= 0) {
|
||||
return { success: false, error: "ms \u5FC5\u9808\u5927\u65BC 0" };
|
||||
}
|
||||
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const passthrough = c.context && typeof c.context === "object" && !Array.isArray(c.context) ? c.context : {};
|
||||
return { success: true, data: { ...passthrough, waited_ms: ms } };
|
||||
}]
|
||||
]);
|
||||
}
|
||||
@@ -3022,7 +3076,7 @@ async function readBodyOnce(res) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
var WASM_HTTP_RUNNER_IDS, LOGIC_BINDING_MAP;
|
||||
var WASM_HTTP_RUNNER_IDS, LOGIC_BINDING_MAP, RUNTIME_NATIVE_COMPONENT_IDS;
|
||||
var init_component_loader = __esm({
|
||||
"cypher-executor/src/lib/component-loader.ts"() {
|
||||
"use strict";
|
||||
@@ -3055,7 +3109,12 @@ var init_component_loader = __esm({
|
||||
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",
|
||||
@@ -3065,6 +3124,12 @@ var init_component_loader = __esm({
|
||||
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
|
||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
||||
};
|
||||
RUNTIME_NATIVE_COMPONENT_IDS = /* @__PURE__ */ new Set([
|
||||
"trigger_workflow",
|
||||
...BUILTIN_COMPONENTS.keys(),
|
||||
...Object.keys(LOGIC_BINDING_MAP),
|
||||
...WASM_HTTP_RUNNER_IDS
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3134,6 +3199,11 @@ function graphBase(env) {
|
||||
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, "");
|
||||
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
|
||||
}
|
||||
function graphHeaders(env) {
|
||||
const headers = {};
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return headers;
|
||||
}
|
||||
var kbdbProxyRouter, NEED_KEY;
|
||||
var init_kbdb_proxy = __esm({
|
||||
"cypher-executor/src/routes/kbdb-proxy.ts"() {
|
||||
@@ -3201,6 +3271,20 @@ var init_kbdb_proxy = __esm({
|
||||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param("recordId"))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
});
|
||||
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 \u5FC5\u586B\uFF08{slot\u540D: \u5167\u5BB9}\uFF09" }, 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" } });
|
||||
});
|
||||
kbdbProxyRouter.get("/kbdb/search", async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
@@ -3253,8 +3337,7 @@ var init_kbdb_proxy = __esm({
|
||||
kbdbProxyRouter.get("/kbdb/graph/neighbors/:name", async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const base = graphBase(c.env);
|
||||
const headers = {};
|
||||
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" } });
|
||||
@@ -7355,7 +7438,7 @@ var init_lib = __esm({
|
||||
...processCreateParams(params)
|
||||
});
|
||||
};
|
||||
BRAND = Symbol("zod_brand");
|
||||
BRAND = /* @__PURE__ */ Symbol("zod_brand");
|
||||
ZodBranded = class extends ZodType {
|
||||
_parse(input) {
|
||||
const { ctx } = this._processInputParams(input);
|
||||
@@ -7529,14 +7612,14 @@ var init_lib = __esm({
|
||||
onumber = () => numberType().optional();
|
||||
oboolean = () => booleanType().optional();
|
||||
coerce = {
|
||||
string: (arg) => ZodString.create({ ...arg, coerce: true }),
|
||||
number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
|
||||
boolean: (arg) => ZodBoolean.create({
|
||||
string: ((arg) => ZodString.create({ ...arg, coerce: true })),
|
||||
number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
|
||||
boolean: ((arg) => ZodBoolean.create({
|
||||
...arg,
|
||||
coerce: true
|
||||
}),
|
||||
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
|
||||
date: (arg) => ZodDate.create({ ...arg, coerce: true })
|
||||
})),
|
||||
bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
|
||||
date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
|
||||
};
|
||||
NEVER = INVALID;
|
||||
z = /* @__PURE__ */ Object.freeze({
|
||||
@@ -7757,6 +7840,7 @@ var init_recipe_loader = __esm({
|
||||
super(message);
|
||||
this.recipe = recipe;
|
||||
}
|
||||
recipe;
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -8763,7 +8847,7 @@ function recordRecipeStats(env, recipeKeys, ok, at, ctx) {
|
||||
)
|
||||
).then(() => void 0);
|
||||
if (ctx?.waitUntil) ctx.waitUntil(promise);
|
||||
else ;
|
||||
else void promise;
|
||||
}
|
||||
function generateToken() {
|
||||
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
@@ -8805,7 +8889,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
|
||||
result.trace
|
||||
);
|
||||
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
||||
else ;
|
||||
else void statsPromise;
|
||||
}
|
||||
return { success: true, data: result.data, duration_ms };
|
||||
} catch (err) {
|
||||
@@ -8829,7 +8913,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
|
||||
err.trace
|
||||
);
|
||||
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
||||
else ;
|
||||
else void statsPromise;
|
||||
}
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map((s) => ({
|
||||
@@ -9257,9 +9341,11 @@ function authStoreStatus(env) {
|
||||
var healthRouter = new Hono2();
|
||||
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 內容
|
||||
@@ -9294,6 +9380,7 @@ function extractTarget(input) {
|
||||
return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2);
|
||||
}
|
||||
async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) {
|
||||
void nodes;
|
||||
try {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
await fetch(`${base}/execution-log/record`, {
|
||||
@@ -9485,6 +9572,16 @@ async function searchNodes(parsed, config, env, mode = "discover", target) {
|
||||
nodeResults[nodeName] = { status: "found", componentId, type: role };
|
||||
continue;
|
||||
}
|
||||
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
|
||||
nodeResults[nodeName] = {
|
||||
status: "found",
|
||||
componentId,
|
||||
type: role,
|
||||
source: "builtin",
|
||||
branch_hint: branchHintFor(componentId)
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (catalog.status === "unreachable") {
|
||||
nodeResults[nodeName] = { status: "unknown", componentId, type: role };
|
||||
continue;
|
||||
@@ -12460,6 +12557,45 @@ init_kbdb_proxy();
|
||||
|
||||
// cypher-executor/src/routes/console-auth.ts
|
||||
init_dist();
|
||||
|
||||
// cypher-executor/src/lib/tenant.ts
|
||||
var TenantUnresolvedError = class extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "TenantUnresolvedError";
|
||||
}
|
||||
};
|
||||
function knowledgeOwner(env) {
|
||||
const injected = (env.ARCRUN_NAMESPACE ?? "").trim();
|
||||
if (injected) return injected;
|
||||
const legacy = (env.CONSOLE_TENANT ?? "").trim();
|
||||
if (legacy) return legacy;
|
||||
throw new TenantUnresolvedError(
|
||||
"\u9019\u500B\u90E8\u7F72\u6C92\u6709\u77E5\u8B58\u547D\u540D\u7A7A\u9593\uFF08ARCRUN_NAMESPACE / CONSOLE_TENANT \u90FD\u6C92\u8A2D\uFF09\u2014\u2014\u4E0D\u77E5\u9053\u8981\u53BB\u54EA\u4E00\u683C\u627E\u8CC7\u6599\u3002\u8ACB\u8DD1 `acr update` \u8B93\u5B83\u5F9E\u4F60\u7684 ~/.arcrun/config.yaml \u6CE8\u5165\u3002"
|
||||
);
|
||||
}
|
||||
function tenantFromApiKey(apiKey) {
|
||||
const key = (apiKey ?? "").trim();
|
||||
if (!key) throw new TenantUnresolvedError("\u7F3A\u5C11 X-Arcrun-API-Key\uFF0C\u7121\u6CD5\u6C7A\u5B9A\u67E5\u8A62\u7BC4\u570D");
|
||||
return key;
|
||||
}
|
||||
function accountTenant(env) {
|
||||
return env.CONSOLE_TENANT || "leo";
|
||||
}
|
||||
function ownerQuery(tenant2) {
|
||||
return `owner_id=${encodeURIComponent(tenant2)}`;
|
||||
}
|
||||
function ownerField(tenant2) {
|
||||
return tenant2;
|
||||
}
|
||||
function censusQueryAllTenants() {
|
||||
return "owner_id=";
|
||||
}
|
||||
function isOwnedBy(value, tenant2) {
|
||||
return typeof value === "string" && value === tenant2;
|
||||
}
|
||||
|
||||
// cypher-executor/src/routes/console-auth.ts
|
||||
var consoleAuthRouter = new Hono2();
|
||||
var CREDS_KEY = "console:credentials";
|
||||
var SESSION_PREFIX = "console_sess:";
|
||||
@@ -12486,7 +12622,7 @@ async function hashPassword(password, salt) {
|
||||
return h;
|
||||
}
|
||||
function tenantOf(c) {
|
||||
return c.env.CONSOLE_TENANT || "leo";
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
async function loadCredentials(env) {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
@@ -12757,10 +12893,10 @@ var DEFAULT_SESSION_TTL = 604800;
|
||||
var USER_TEMPLATE = "portal_user";
|
||||
var LIBRARY_TEMPLATE = "portal_library";
|
||||
function portalTenant(env) {
|
||||
return env.CONSOLE_TENANT || "leo";
|
||||
return accountTenant(env);
|
||||
}
|
||||
function portalNamespace(env) {
|
||||
return `${portalTenant(env)}::portal`;
|
||||
return `${accountTenant(env)}::portal`;
|
||||
}
|
||||
function sessionTtl(env) {
|
||||
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10);
|
||||
@@ -12789,6 +12925,9 @@ async function run(c, fn) {
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `\u8A8D\u8B49\u5132\u5B58\u5BEB\u5165\u5931\u6557\uFF1A${e.message}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
if (e instanceof TenantUnresolvedError) {
|
||||
return c.json({ error: e.message, code: "tenant_unresolved" }, 500);
|
||||
}
|
||||
if (e instanceof KbdbError) return c.json({ error: `KBDB \u4E0D\u53EF\u9054\u6216\u56DE\u932F\uFF1A${e.message}` }, 502);
|
||||
throw e;
|
||||
}
|
||||
@@ -13190,7 +13329,12 @@ portalRouter.post(
|
||||
session_token: token,
|
||||
display_name: rec.values.display_name ?? "",
|
||||
role: rec.values.role ?? "user",
|
||||
libraries: parseLibraries(rec.values.libraries)
|
||||
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/*)
|
||||
});
|
||||
})
|
||||
@@ -13666,10 +13810,9 @@ portalRouter.post(
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const daemonCfg = {
|
||||
cypher_url: new URL(c.req.url).origin,
|
||||
namespace: tenant2,
|
||||
namespace: knowledgeOwner(c.env),
|
||||
library: "kb",
|
||||
email,
|
||||
instance_name: String(rec.values.display_name ?? "")
|
||||
@@ -13685,7 +13828,7 @@ portalRouter.post(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const key = String(body?.key ?? "").trim();
|
||||
if (!key) return c.json({ error: "\u8ACB\u8CBC\u4E0A\u4F60\u7684 Google AI \u91D1\u9470" }, 400);
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const kvKey2 = `${tenant2}:wf:rag_chat`;
|
||||
const raw2 = await c.env.WEBHOOKS.get(kvKey2, "text");
|
||||
if (!raw2) return c.json({ error: "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u5B89\u88DD AI \u554F\u7B54\u5DE5\u4F5C\u6D41" }, 404);
|
||||
@@ -13737,8 +13880,8 @@ portalRouter.get(
|
||||
});
|
||||
const known = new Set(out.map((l) => l.name));
|
||||
try {
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const ownerParam = `owner_id=${encodeURIComponent(tenant2)}`;
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const ownerParam = ownerQuery(tenant2);
|
||||
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
||||
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
||||
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
||||
@@ -13887,8 +14030,8 @@ portalRouter.get(
|
||||
(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 \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
||||
@@ -13904,10 +14047,10 @@ portalRouter.put(
|
||||
if (days !== null && days !== void 0 && (typeof days !== "number" || !Number.isFinite(days) || days <= 0)) {
|
||||
return c.json({ error: "retention_days \u5FC5\u9808\u662F\u6B63\u6574\u6578\uFF0C\u6216 null\uFF08\u4EE3\u8868\u4E0D\u522A\u9664\uFF09" }, 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 === void 0 ? null : days })
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === void 0 ? null : days })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -13924,10 +14067,10 @@ portalRouter.delete(
|
||||
const confirm = String(body?.confirm ?? "").trim();
|
||||
if (!confirm) return c.json({ error: 'body \u9808\u5E36 { confirm: "<\u5EAB\u540D>" } \u624D\u57F7\u884C\uFF08\u79FB\u9664\u6703\u5F71\u97FF\u8CC7\u6599\u53EF\u641C\u6027\uFF09' }, 400);
|
||||
if (confirm !== name) return c.json({ error: `confirm \u503C\u300C${confirm}\u300D\u8207\u5EAB\u540D\u300C${name}\u300D\u4E0D\u7B26` }, 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 \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -13983,8 +14126,8 @@ async function buildDiagnostics(env, tenant2) {
|
||||
let embedding = { checked: false };
|
||||
try {
|
||||
const [statusRes, selftestRes] = await Promise.all([
|
||||
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant2 }).toString()}`),
|
||||
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant2 }).toString()}`)
|
||||
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant2)}`),
|
||||
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant2)}`)
|
||||
]);
|
||||
const statusBody = await statusRes.json().catch(() => null);
|
||||
const selftestBody = await selftestRes.json().catch(() => null);
|
||||
@@ -14006,7 +14149,7 @@ async function buildDiagnostics(env, tenant2) {
|
||||
}
|
||||
let library_count = 0;
|
||||
let triplet_count = 0;
|
||||
const ownerParam = new URLSearchParams({ owner_id: tenant2 }).toString();
|
||||
const ownerParam = ownerQuery(tenant2);
|
||||
try {
|
||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||
@@ -14030,7 +14173,7 @@ async function buildDiagnostics(env, tenant2) {
|
||||
let library_scope_check = { ran: false };
|
||||
if (library_count === 0 && triplet_count === 0) {
|
||||
try {
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant2, limit: "1" }).toString()}`);
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant2), limit: "1" }).toString()}`);
|
||||
const probeBody = await probeRes.json().catch(() => null);
|
||||
const total = probeBody?.total ?? 0;
|
||||
library_scope_check = {
|
||||
@@ -14053,7 +14196,7 @@ portalRouter.get(
|
||||
(c) => run(c, async () => {
|
||||
const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim();
|
||||
if (!apiKey) return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401);
|
||||
const core = await buildDiagnostics(c.env, apiKey);
|
||||
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
|
||||
return c.json({
|
||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
@@ -14500,6 +14643,20 @@ async function fetchJson(url, headers) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function fetchTripletTotal(env, tenant2) {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const data = await fetchJson(
|
||||
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant2)}`,
|
||||
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;
|
||||
}
|
||||
async function fetchEntryTotal(env, filters) {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const params = new URLSearchParams({ ...filters, limit: "1" });
|
||||
@@ -14594,7 +14751,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri
|
||||
return { ...fresh, cache: "miss" };
|
||||
}
|
||||
consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const now2 = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
@@ -14607,6 +14764,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
tripletTotal,
|
||||
entriesTotal,
|
||||
wikiCardTotal,
|
||||
workflowTotal
|
||||
@@ -14618,7 +14776,13 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)),
|
||||
fetchJson(`${kbdbUrl}/health`, kbdbHeaders),
|
||||
fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||
fetchJson(`${graphUrl}/triplets/stats`),
|
||||
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
|
||||
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
|
||||
fetchJson(
|
||||
`${graphUrl}/triplets/stats`,
|
||||
graphHeaders(c.env)
|
||||
),
|
||||
fetchTripletTotal(c.env, tenant2),
|
||||
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||
fetchEntryTotal(c.env, { owner_id: tenant2 }),
|
||||
fetchEntryTotal(c.env, { entry_type: "wiki_card", owner_id: tenant2 }),
|
||||
@@ -14726,36 +14890,37 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
system: {
|
||||
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
|
||||
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(now2).toISOString()
|
||||
});
|
||||
});
|
||||
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
const now2 = 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(
|
||||
`${base}/entries?${new URLSearchParams({ owner_id: tenant2, entry_type: "wiki_card", limit: "1" }).toString()}`,
|
||||
headers
|
||||
),
|
||||
fetchJson(`${graphUrl}/triplets/stats`),
|
||||
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
|
||||
fetchTripletTotal(c.env, tenant2),
|
||||
fetchJson(`${base}/embed/backfill/status`, headers)
|
||||
]);
|
||||
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||
return c.json({
|
||||
wiki_card_total: typeof wikiCards?.total === "number" ? wikiCards.total : null,
|
||||
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now2, 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(now2).toISOString()
|
||||
@@ -14773,7 +14938,7 @@ consoleDashboardRouter.get("/console/settings-data", (c) => {
|
||||
consoleDashboardRouter.get("/console/triage-data", async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header("authorization"));
|
||||
if (!ok) return c.json({ error: "\u9700\u8981\u767B\u5165\uFF08console session\uFF09" }, 401);
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const [todoEntries, inboxEntries] = await Promise.all([
|
||||
fetchEntries(c.env, tenant2, "todo", 500),
|
||||
fetchEntries(c.env, tenant2, "inbox", 200)
|
||||
@@ -14788,7 +14953,7 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => {
|
||||
const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : "";
|
||||
if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400);
|
||||
const action = body?.action === "restore" ? "restore" : "check";
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const got = await fetchJson(
|
||||
`${base}/entries/${encodeURIComponent(entryId)}`,
|
||||
@@ -14816,7 +14981,7 @@ init_kbdb_proxy();
|
||||
init_webhook_handlers();
|
||||
var portalDataRouter = new Hono2();
|
||||
async function getTenantWorkflowGraph(env, name) {
|
||||
const raw2 = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, "text");
|
||||
const raw2 = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, "text");
|
||||
if (!raw2) return null;
|
||||
try {
|
||||
const rec = JSON.parse(raw2);
|
||||
@@ -14907,9 +15072,30 @@ function findBestNodeMatch(searchTerm, nodeNames) {
|
||||
if (hits.length === 0) return null;
|
||||
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||||
}
|
||||
async function tripletCount(env, owner) {
|
||||
try {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
async function tripletCensus(env, tenant2) {
|
||||
const owned = await tripletCount(env, tenant2);
|
||||
if (owned !== 0) return { owned, any: null };
|
||||
return { owned, any: await tripletCount(env, null) };
|
||||
}
|
||||
async function fuzzyFindNode(env, tenant2, searchTerm) {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant2)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
@@ -14937,7 +15123,7 @@ portalDataRouter.get(
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ success: true, entries: [], count: 0, mode: "keyword", note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" });
|
||||
}
|
||||
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(","));
|
||||
if (c.req.query("mode") === "semantic") {
|
||||
params.set("mode", "semantic");
|
||||
@@ -14973,7 +15159,7 @@ portalDataRouter.get(
|
||||
const body = await res.json();
|
||||
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 });
|
||||
})
|
||||
@@ -14988,7 +15174,7 @@ portalDataRouter.get(
|
||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||
}
|
||||
const nodeName = normalizeCjkQuery(c.req.param("name"));
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors");
|
||||
if (wfGraph) {
|
||||
const depthRaw = c.req.query("depth") ?? "";
|
||||
@@ -15008,8 +15194,7 @@ portalDataRouter.get(
|
||||
return c.json(mapGraphWorkflowOutput(result.data));
|
||||
}
|
||||
const base = graphBase(c.env);
|
||||
const headers = {};
|
||||
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) {
|
||||
@@ -15043,13 +15228,20 @@ portalDataRouter.get(
|
||||
if (!await hasGraphAccess(c.env, libraries)) {
|
||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||
}
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant2)}&limit=500`),
|
||||
tripletCensus(c.env, tenant2)
|
||||
]);
|
||||
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);
|
||||
const records = body && Array.isArray(body.records) ? body.records : [];
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: "\u4E09\u5143\u7D44\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
|
||||
}
|
||||
const records = body.records;
|
||||
const EDGE_CAP = 500;
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
const edges = [];
|
||||
@@ -15075,7 +15267,24 @@ portalDataRouter.get(
|
||||
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 });
|
||||
let emptyReason = 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
|
||||
});
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
@@ -15092,7 +15301,7 @@ portalDataRouter.get(
|
||||
wfGraph,
|
||||
{ question },
|
||||
"rag_chat",
|
||||
portalTenant(c.env),
|
||||
knowledgeOwner(c.env),
|
||||
c.executionCtx
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -15168,7 +15377,7 @@ portalDataRouter.get(
|
||||
if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) {
|
||||
return c.json({ error: "\u9700\u8981 admin \u6B0A\u9650" }, 403);
|
||||
}
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const prefix = `${tenant2}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const workflows = await Promise.all(
|
||||
@@ -15190,7 +15399,7 @@ portalDataRouter.get(
|
||||
let last_execution = null;
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant2 }).toString()}`
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant2) }).toString()}`
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null);
|
||||
if (execRes.ok && execBody?.success && execBody.execution) {
|
||||
@@ -15202,12 +15411,207 @@ portalDataRouter.get(
|
||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||
})
|
||||
);
|
||||
function recordLibrary(values) {
|
||||
const lib = values?.library;
|
||||
return typeof lib === "string" && lib.trim() ? lib.trim() : null;
|
||||
}
|
||||
function canReadRecord(rec, tenant2, libraries) {
|
||||
if (!isOwnedBy(rec.owner_id, tenant2)) return false;
|
||||
const lib = recordLibrary(rec.values);
|
||||
return lib === null || canReadLibrary(libraries, lib);
|
||||
}
|
||||
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: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002"
|
||||
});
|
||||
}
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant2)}`);
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.libraries)) {
|
||||
return c.json({ error: "\u85CF\u66F8\u5730\u5716\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 libraries \u6E05\u55AE" }, 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: "\u9019\u500B\u5E33\u865F\u76EE\u524D\u6C92\u6709\u4EFB\u4F55\u77E5\u8B58\u5EAB\u7684\u6AA2\u8996\u6B0A\u9650\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u958B\u901A\u3002"
|
||||
});
|
||||
}
|
||||
const census = await tripletCensus(c.env, tenant2);
|
||||
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: "\u8B80\u4E0D\u5230\u77E5\u8B58\u5EAB\u7684\u7D71\u8A08\uFF0C\u7121\u6CD5\u78BA\u8A8D\u5EAB\u88E1\u6709\u6C92\u6709\u6771\u897F\u2014\u2014\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u662F\u9019\u6B21\u8B80\u53D6\u5931\u6557\u3002\u8ACB\u7A0D\u5F8C\u91CD\u6574\u6216\u901A\u77E5\u7BA1\u7406\u54E1\u3002"
|
||||
});
|
||||
}
|
||||
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: `\u8B80\u4E0D\u5230\u4F60\u9019\u500B\u5E33\u865F\u7BC4\u570D\u5167\u7684\u85CF\u66F8\u2014\u2014\u4F46\u9019\u53F0\u5BE6\u4F8B\u88E1\u6709 ${census.any} \u689D\u77E5\u8B58\u95DC\u806F\u3002\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u4E0D\u7528\u53BB\u91CD\u65B0\u4E0A\u50B3\uFF1B\u6BD4\u8F03\u50CF\u77E5\u8B58\u7684\u6B78\u5C6C\u547D\u540D\u7A7A\u9593\u5C0D\u4E0D\u4E0A\u3002\u8ACB\u901A\u77E5\u7BA1\u7406\u54E1\u8DD1\u4E00\u6B21 \`acr update\`\uFF08\u6703\u628A\u4F60\u5B89\u88DD\u6642\u7684\u547D\u540D\u7A7A\u9593\u540C\u6B65\u7D66\u96F2\u7AEF\uFF09\uFF0C\u6216\u6AA2\u67E5 ARCRUN_NAMESPACE \u8A2D\u5B9A\u3002`
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "confirmed_empty",
|
||||
note: "\u77E5\u8B58\u5EAB\u9084\u6C92\u6709\u4EFB\u4F55\u5167\u5BB9\u2014\u2014\u4E0A\u50B3\u6587\u4EF6\u5F8C\u5C31\u6703\u51FA\u73FE\u5728\u9019\u88E1\u3002"
|
||||
});
|
||||
})
|
||||
);
|
||||
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 \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
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 \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
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);
|
||||
if (!body || typeof body.name !== "string" || !body.name.trim() || !Array.isArray(body.slots)) {
|
||||
return c.json({ error: "name \u8207 slots[] \u5FC5\u586B" }, 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 : void 0,
|
||||
created_by: knowledgeOwner(c.env)
|
||||
})
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
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 tenant2 = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?${ownerQuery(tenant2)}`
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: "record \u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
|
||||
}
|
||||
const records = body.records.filter((r) => canReadRecord(r, tenant2, libraries));
|
||||
return c.json({ success: true, records, count: records.length });
|
||||
})
|
||||
);
|
||||
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 \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
const body = await res.json().catch(() => 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 });
|
||||
})
|
||||
);
|
||||
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: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u7121\u6CD5\u5BEB\u5165" }, 403);
|
||||
}
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.template !== "string" || !body.template.trim() || !body.values || typeof body.values !== "object") {
|
||||
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
|
||||
}
|
||||
const values = body.values;
|
||||
const targetLib = recordLibrary(values);
|
||||
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
|
||||
return c.json({ error: `\u7121\u300C${targetLib}\u300D\u5EAB\u7684\u6B0A\u9650\uFF0C\u4E0D\u80FD\u5BEB\u5165\u8A72\u5EAB` }, 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" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/diagnostics",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const core = await buildDiagnostics(c.env, tenant2);
|
||||
return c.json({
|
||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
@@ -15257,10 +15661,10 @@ app.route("/", consoleAuthRouter);
|
||||
app.route("/", consoleDashboardRouter);
|
||||
app.route("/", portalRouter);
|
||||
app.route("/", portalDataRouter);
|
||||
var src_default = {
|
||||
var index_default = {
|
||||
fetch: app.fetch,
|
||||
scheduled: handleScheduled
|
||||
};
|
||||
export {
|
||||
src_default as default
|
||||
index_default as default
|
||||
};
|
||||
|
||||
@@ -1427,7 +1427,7 @@ var Hono = class _Hono {
|
||||
var emptyParam = [];
|
||||
function match(method, path) {
|
||||
const matchers = this.buildAllMatchers();
|
||||
const match2 = (method2, path2) => {
|
||||
const match2 = ((method2, path2) => {
|
||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||
const staticMatch = matcher[2][path2];
|
||||
if (staticMatch) {
|
||||
@@ -1439,7 +1439,7 @@ function match(method, path) {
|
||||
}
|
||||
const index = match3.indexOf("", 1);
|
||||
return [matcher[1][index], match3];
|
||||
};
|
||||
});
|
||||
this.match = match2;
|
||||
return match2(method, path);
|
||||
}
|
||||
@@ -2522,7 +2522,7 @@ app.post("/", async (c) => {
|
||||
);
|
||||
}
|
||||
});
|
||||
var src_default = app;
|
||||
var index_default = app;
|
||||
async function runWasm(input) {
|
||||
const hostFunctions = {
|
||||
http_request: async (url, method, headersJson, body) => {
|
||||
@@ -2568,5 +2568,5 @@ async function runWasm(input) {
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
export {
|
||||
src_default as default
|
||||
index_default as default
|
||||
};
|
||||
|
||||
@@ -1444,7 +1444,7 @@ var Hono = class _Hono {
|
||||
var emptyParam = [];
|
||||
function match(method, path) {
|
||||
const matchers = this.buildAllMatchers();
|
||||
const match2 = (method2, path2) => {
|
||||
const match2 = ((method2, path2) => {
|
||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||
const staticMatch = matcher[2][path2];
|
||||
if (staticMatch) {
|
||||
@@ -1456,7 +1456,7 @@ function match(method, path) {
|
||||
}
|
||||
const index = match3.indexOf("", 1);
|
||||
return [matcher[1][index], match3];
|
||||
};
|
||||
});
|
||||
this.match = match2;
|
||||
return match2(method, path);
|
||||
}
|
||||
@@ -2189,11 +2189,18 @@ async function deprecateEntriesByLibrary(db, ownerId, library) {
|
||||
var MAX_LIKE_Q_BYTES = 48;
|
||||
var MAX_LIKE_TERMS = 6;
|
||||
var utf8Len = (s) => new TextEncoder().encode(s).length;
|
||||
var LIKE_ESCAPE = "\\";
|
||||
var CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
|
||||
function escapeLikeLiteral(s) {
|
||||
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
|
||||
}
|
||||
var likeBytes = (s) => utf8Len(escapeLikeLiteral(s));
|
||||
var likePattern = (s) => `%${escapeLikeLiteral(s)}%`;
|
||||
function chunkByBytes(s, maxBytes) {
|
||||
const out = [];
|
||||
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 {
|
||||
@@ -2204,8 +2211,8 @@ function chunkByBytes(s, maxBytes) {
|
||||
return out;
|
||||
}
|
||||
function buildContentLike(q) {
|
||||
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 = [];
|
||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||
@@ -2217,8 +2224,8 @@ function buildContentLike(q) {
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -2335,7 +2342,7 @@ function buildSearchScore(q) {
|
||||
if (terms.length === 0) {
|
||||
const m = buildContentLike(trimmed);
|
||||
return {
|
||||
scoreExpr: m.conds.map(() => "CASE WHEN content LIKE ? THEN 1 ELSE 0 END").join(" + "),
|
||||
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(" + "),
|
||||
scoreParams: m.params,
|
||||
terms: [],
|
||||
legacyShape: true
|
||||
@@ -2344,14 +2351,14 @@ function buildSearchScore(q) {
|
||||
const parts = [];
|
||||
const params = [];
|
||||
for (const { term, weight } of terms) {
|
||||
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
||||
params.push(`%${term}%`);
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
|
||||
params.push(likePattern(term));
|
||||
}
|
||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
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(`%${trimmed}%`);
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
|
||||
params.push(likePattern(trimmed));
|
||||
}
|
||||
return { scoreExpr: parts.join(" + "), scoreParams: params, terms, legacyShape: single };
|
||||
}
|
||||
@@ -2408,6 +2415,57 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s
|
||||
return applyRelativeCut(res.results ?? []);
|
||||
}
|
||||
|
||||
// kbdb/src/actions/maintenance-quota.ts
|
||||
var DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 2e4;
|
||||
function maintenanceDailyLimit(env) {
|
||||
const raw2 = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
}
|
||||
function utcDay() {
|
||||
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||
}
|
||||
function maintenanceUsageId() {
|
||||
return `kbdb-maintenance-usage:${utcDay()}`;
|
||||
}
|
||||
async function getMaintenanceUsageToday(db) {
|
||||
const row = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(maintenanceUsageId()).first();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? JSON.parse(row.metadata_json) : {};
|
||||
return Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
async function addMaintenanceUsage(db, by) {
|
||||
if (by <= 0) return;
|
||||
const id = maintenanceUsageId();
|
||||
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? JSON.parse(existing.metadata_json) : {};
|
||||
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();
|
||||
}
|
||||
}
|
||||
async function maintenanceBudgetToday(env, db) {
|
||||
const limit = maintenanceDailyLimit(env);
|
||||
let used = 0;
|
||||
try {
|
||||
used = await getMaintenanceUsageToday(db);
|
||||
} catch {
|
||||
used = 0;
|
||||
}
|
||||
return { limit, used, remaining: Math.max(0, limit - used) };
|
||||
}
|
||||
|
||||
// kbdb/src/embed.ts
|
||||
var DEFAULT_EMBED_MODEL = "@cf/baai/bge-m3";
|
||||
var MIN_SCORE_ABS_FLOOR = 0.45;
|
||||
@@ -2449,7 +2507,7 @@ async function embedOnWrite(env, entry) {
|
||||
}
|
||||
}
|
||||
]);
|
||||
await env.DB.prepare("UPDATE entries SET is_embedded = 1 WHERE id = ?").bind(entry.id).run();
|
||||
await env.DB.prepare("UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?").bind(embedModel(env), entry.id).run();
|
||||
return true;
|
||||
}
|
||||
function isEmbeddable(entry) {
|
||||
@@ -2476,12 +2534,47 @@ function parseMeta(json) {
|
||||
}
|
||||
}
|
||||
var BACKFILL_PREDICATE = "is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
|
||||
async function backfillEmbeddings(env, opts = {}) {
|
||||
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
|
||||
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
||||
const offset = Math.max(opts.offset ?? 0, 0);
|
||||
const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE;
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
|
||||
var DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
|
||||
function backfillDailyLimit(env) {
|
||||
const raw2 = env.EMBED_BACKFILL_DAILY_LIMIT;
|
||||
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
|
||||
}
|
||||
function utcDay2() {
|
||||
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||
}
|
||||
function backfillUsageId() {
|
||||
return `embed-backfill-usage:${utcDay2()}`;
|
||||
}
|
||||
async function getBackfillUsageToday(db) {
|
||||
const row = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(backfillUsageId()).first();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? JSON.parse(row.metadata_json) : {};
|
||||
return Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
async function addBackfillUsage(db, by) {
|
||||
if (by <= 0) return;
|
||||
const id = backfillUsageId();
|
||||
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? JSON.parse(existing.metadata_json) : {};
|
||||
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: utcDay2(), 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: utcDay2(), embedded: by })).run();
|
||||
}
|
||||
}
|
||||
function selectionCriteriaPredicate(opts) {
|
||||
const conds = [];
|
||||
const params = [];
|
||||
if (opts.owner_id) {
|
||||
conds.push("owner_id = ?");
|
||||
@@ -2491,12 +2584,55 @@ async function backfillEmbeddings(env, opts = {}) {
|
||||
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 };
|
||||
}
|
||||
async function backfillEmbeddings(env, opts = {}) {
|
||||
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);
|
||||
const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE;
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
|
||||
const params = [...sel.params];
|
||||
const where = conds.join(" AND ");
|
||||
const res = await env.DB.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
|
||||
const res = await env.DB.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
|
||||
const rows = res.results ?? [];
|
||||
const scanned = rows.length;
|
||||
const dailyCap = backfillDailyLimit(env);
|
||||
let usedToday = 0;
|
||||
try {
|
||||
usedToday = await getBackfillUsageToday(env.DB);
|
||||
} catch {
|
||||
usedToday = 0;
|
||||
}
|
||||
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);
|
||||
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 });
|
||||
@@ -2516,14 +2652,27 @@ async function backfillEmbeddings(env, opts = {}) {
|
||||
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();
|
||||
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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
const remRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
||||
const totalMatching = remRow?.c ?? 0;
|
||||
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
|
||||
};
|
||||
}
|
||||
async function backfillStatus(env, opts = {}) {
|
||||
const conds = [];
|
||||
@@ -2541,6 +2690,74 @@ async function backfillStatus(env, opts = {}) {
|
||||
const embeddedRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE is_embedded = 1 AND json_extract(metadata_json, '$.embed') = 1${extra}`).bind(...params).first();
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
async function reconcileEmbedGeneration(env, opts = {}) {
|
||||
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 = [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();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
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();
|
||||
const written = confirmed_current + reset_to_pending;
|
||||
try {
|
||||
await addMaintenanceUsage(env.DB, written);
|
||||
} catch {
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
async function embedSelfTest(env, opts = {}) {
|
||||
if (!embedEnabled(env)) {
|
||||
return { enabled: false, tested: false, passed: null, note: "embed \u6A21\u7D44\u672A\u958B\uFF08\u7F3A Vectorize/AI binding\uFF09\uFF0C\u8A9E\u7FA9\u641C\u5C0B\u9019\u689D\u8DEF\u76EE\u524D\u4E0D\u5B58\u5728" };
|
||||
@@ -2647,6 +2864,90 @@ async function migrateLegacyCredentialsForOwner(db, ownerId) {
|
||||
return (after?.n ?? 0) - (before?.n ?? 0);
|
||||
}
|
||||
|
||||
// kbdb/src/actions/library-backfill.ts
|
||||
var MAX_PAGE_NAMES = 300;
|
||||
var HARD_LIMIT_CAP = 500;
|
||||
function criteriaPredicate(c) {
|
||||
const conds = [
|
||||
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')"
|
||||
];
|
||||
const params = [];
|
||||
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 };
|
||||
}
|
||||
async function backfillEntryLibraryTags(db, env, opts) {
|
||||
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\uFF08\u6A19\u5EAB\u662F\u8DE8\u5927\u91CF\u65E2\u6709\u8CC7\u6599\u7684\u6279\u6B21\u5BEB\u5165\uFF0C\u4E0D\u51C6\u7121\u79DF\u6236\u7BC4\u570D\u5730\u6383\u5168\u5EAB\u2014\u20142026-08-11 leo \u76F4\u4EE4\uFF09");
|
||||
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();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
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 {
|
||||
}
|
||||
const remRow = await db.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
||||
return {
|
||||
library,
|
||||
scanned,
|
||||
tagged,
|
||||
remaining: remRow?.c ?? 0,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + tagged,
|
||||
quota_exceeded: quotaExceeded
|
||||
};
|
||||
}
|
||||
async function libraryBackfillStatus(db, opts = {}) {
|
||||
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();
|
||||
return { pending: row?.c ?? 0 };
|
||||
}
|
||||
|
||||
// kbdb/src/routes/entries.ts
|
||||
var entryRoutes = new Hono2();
|
||||
function fireAndForget(c, p) {
|
||||
@@ -2873,6 +3174,39 @@ entryRoutes.patch("/deprecate-by-library", async (c) => {
|
||||
}
|
||||
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
||||
});
|
||||
entryRoutes.post("/backfill-library", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const library = String(body.library ?? "").trim();
|
||||
const ownerId = String(body.owner_id ?? "").trim();
|
||||
if (!library || !ownerId) return c.json({ success: false, error: "library \u8207 owner_id \u5FC5\u586B" }, 400);
|
||||
try {
|
||||
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
|
||||
library,
|
||||
owner_id: ownerId,
|
||||
entry_type: body.entry_type || void 0,
|
||||
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : void 0,
|
||||
source_prefix: body.source_prefix || void 0,
|
||||
page_name_prefix: body.page_name_prefix || void 0,
|
||||
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||
until: body.until !== void 0 ? Number(body.until) : void 0,
|
||||
limit: body.limit !== void 0 ? Number(body.limit) : void 0
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
entryRoutes.get("/backfill-library/status", async (c) => {
|
||||
const status = await libraryBackfillStatus(c.env.DB, {
|
||||
owner_id: c.req.query("owner_id") || void 0,
|
||||
entry_type: c.req.query("entry_type") || void 0,
|
||||
source_prefix: c.req.query("source_prefix") || void 0,
|
||||
page_name_prefix: c.req.query("page_name_prefix") || void 0,
|
||||
since: c.req.query("since") ? Number(c.req.query("since")) : void 0,
|
||||
until: c.req.query("until") ? Number(c.req.query("until")) : void 0
|
||||
});
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
entryRoutes.patch("/:id", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const entry = await updateEntry(c.env.DB, c.req.param("id"), body);
|
||||
@@ -2947,7 +3281,7 @@ async function createRecord(db, input) {
|
||||
});
|
||||
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("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 };
|
||||
}
|
||||
async function updateRecord(db, recordId, values) {
|
||||
const evRes = await db.prepare(
|
||||
@@ -2978,7 +3312,7 @@ async function updateRecord(db, recordId, values) {
|
||||
}
|
||||
async function getRecord(db, recordId) {
|
||||
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();
|
||||
@@ -2986,7 +3320,8 @@ async function getRecord(db, recordId) {
|
||||
if (rows.length === 0) return null;
|
||||
const values = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
||||
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 };
|
||||
}
|
||||
async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||
const tpl = await getTemplate(db, template);
|
||||
@@ -3005,17 +3340,18 @@ async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
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();
|
||||
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);
|
||||
@@ -3192,6 +3528,9 @@ embedRoutes.post("/backfill", async (c) => {
|
||||
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
|
||||
owner_id: body.owner_id || void 0,
|
||||
source: body.source || void 0,
|
||||
library: body.library || void 0,
|
||||
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||
until: body.until !== void 0 ? Number(body.until) : void 0,
|
||||
// reindex(Arcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
|
||||
reindex: body.reindex === true,
|
||||
offset: body.offset !== void 0 ? Number(body.offset) : void 0
|
||||
@@ -3205,6 +3544,23 @@ embedRoutes.get("/backfill/status", async (c) => {
|
||||
});
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
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(() => ({}));
|
||||
const result = await reconcileEmbedGeneration(c.env, {
|
||||
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
|
||||
owner_id: body.owner_id || void 0,
|
||||
library: body.library || void 0,
|
||||
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||
until: body.until !== void 0 ? Number(body.until) : void 0
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
embedRoutes.get("/selftest", async (c) => {
|
||||
const result = await embedSelfTest(c.env, { owner_id: c.req.query("owner_id") || void 0 });
|
||||
return c.json({ success: true, ...result });
|
||||
@@ -3402,14 +3758,18 @@ async function recomputeLibraryMap(db, input) {
|
||||
async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
|
||||
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||
const res = await db.prepare(
|
||||
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||||
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||
FROM (
|
||||
SELECT 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).all();
|
||||
const m = /* @__PURE__ */ new Map();
|
||||
@@ -3558,7 +3918,7 @@ function dailyLimit(env) {
|
||||
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
|
||||
}
|
||||
function utcDay() {
|
||||
function utcDay3() {
|
||||
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||
}
|
||||
function truncate(s, max) {
|
||||
@@ -3566,7 +3926,7 @@ function truncate(s, max) {
|
||||
return s.slice(0, Math.max(0, max - 1)) + "\u2026";
|
||||
}
|
||||
async function checkUsage(db, limit) {
|
||||
const id = `exlog-usage:${utcDay()}`;
|
||||
const id = `exlog-usage:${utcDay3()}`;
|
||||
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||
let count;
|
||||
if (existing) {
|
||||
@@ -3578,10 +3938,10 @@ async function checkUsage(db, limit) {
|
||||
prevWrites = 0;
|
||||
}
|
||||
count = prevWrites + 1;
|
||||
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay(), writes: count }), id).run();
|
||||
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay3(), writes: count }), id).run();
|
||||
} else {
|
||||
count = 1;
|
||||
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`).bind(id, JSON.stringify({ day: utcDay(), writes: count })).run();
|
||||
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`).bind(id, JSON.stringify({ day: utcDay3(), writes: count })).run();
|
||||
}
|
||||
if (count > limit) return "skip";
|
||||
if (count > limit * DEGRADE_RATIO) return "log_failure_only";
|
||||
@@ -3797,7 +4157,7 @@ app.route("/recipe-stats", recipeStatRoutes);
|
||||
app.route("/execution-log", executionLogRoutes);
|
||||
app.route("/embed", embedRoutes);
|
||||
app.route("/map", mapRoutes);
|
||||
var src_default = app;
|
||||
var index_default = app;
|
||||
export {
|
||||
src_default as default
|
||||
index_default as default
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-11T05:33:37.988Z",
|
||||
"repo_head": "d8bbf2241bd6b117d76fb27d9e386ecfb0ffe8f7",
|
||||
"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": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
|
||||
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 568855,
|
||||
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
|
||||
"js_bytes": 588587,
|
||||
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -58,11 +58,11 @@
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "a7e23badf2a771be779a861e69e7efa6e8141dfe",
|
||||
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 135910,
|
||||
"content_sha256": "5e5a7a030f4fd1f5549ace6791c3827b6497b0bfdd9add46af041af47c472905",
|
||||
"js_bytes": 149797,
|
||||
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -90,8 +90,8 @@
|
||||
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-http-request/worker.mjs",
|
||||
"js_bytes": 80073,
|
||||
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
|
||||
"js_bytes": 80079,
|
||||
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
@@ -122,8 +122,8 @@
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153671,
|
||||
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
|
||||
"js_bytes": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
@@ -148,11 +148,11 @@
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
||||
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1165130,
|
||||
"content_sha256": "be15033f32e605f03f69bd10cd87782dafa34dbafeee2ce367bd7361a062a291",
|
||||
"js_bytes": 1179487,
|
||||
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
+549
-126
@@ -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,40 +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/library 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
|
||||
const created = await ensureVectorizeMetadataIndexes(ctx);
|
||||
console.log(chalk.green(' ✓'));
|
||||
// 新建的 metadata index **只收「建立之後 upsert」的向量** ⇒ 既有向量不重推就永遠 filter 不到。
|
||||
// 這一步不能靜默:leo21c 全盲事件裡,人看到「✓」就以為好了,實際上舊向量一筆都查不到。
|
||||
if (created.length > 0) {
|
||||
console.log(chalk.yellow(
|
||||
` ⚠ 新建了 metadata index(${created.join('/')})。Vectorize 只索引「建立之後寫入」的向量,\n` +
|
||||
' 既有向量必須重推才查得到 → 部署完成後打:\n' +
|
||||
' POST <kbdb>/embed/backfill {"reindex":true} (重複呼叫直到 remaining=0)',
|
||||
));
|
||||
}
|
||||
plan = await planResources(api, requirements, mode);
|
||||
} catch (e) {
|
||||
console.log(chalk.red(' ✗'));
|
||||
failures.push(
|
||||
`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}` +
|
||||
' ⇒ 語意搜尋會「看起來有開、實際全盲」(帶歸屬條件的查詢一律 0 命中),請先修好這項再驗收語意搜尋。',
|
||||
);
|
||||
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 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 絕對路徑)。
|
||||
@@ -308,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) {
|
||||
@@ -446,64 +775,28 @@ 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)。
|
||||
*
|
||||
* 🔴 這份清單必須與 `kbdb/src/embed.ts` 的 upsert metadata 欄位**逐欄對齊**:少一欄,
|
||||
* 帶那一欄過濾的語意查詢就永遠回 0 命中(Vectorize 只認「已建 metadata index」的欄位),
|
||||
* **而且不會報錯**——與 bge-m3 換代那次同款的靜默漂移(wiki/mistakes.md「改 A 要連動 B」)。
|
||||
* `library` 是 2026-08-11 補的:portal-auth P1 的「庫」filter 早就拿它在查,清單卻一直停在
|
||||
* 三欄(`kbdb/wrangler.toml` 自己記著「library 待補進該清單」,那張欠條在這裡還掉)。
|
||||
*/
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source', 'library'] as const;
|
||||
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed index 上的 metadata index(owner_id/entry_type/source)存在(Arcrun#11 根因修復)。
|
||||
* 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<string[]> {
|
||||
const base = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}`;
|
||||
const auth = { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' };
|
||||
const created: string[] = [];
|
||||
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(`${base}/metadata_index/create`, {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: auth,
|
||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ propertyName, indexType: 'string' }),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (res.ok) { created.push(propertyName); continue; }
|
||||
if (res.ok) continue;
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
|
||||
| null;
|
||||
@@ -511,33 +804,6 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<strin
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue;
|
||||
throw new Error(`metadata_index ${propertyName}: ${msg}`);
|
||||
}
|
||||
|
||||
// 🔴 建完一定要複驗(2026-08-11 立,Arcrun#85 D70 事故的直接教訓)。
|
||||
// leo21c 的現役 index 上**一個 metadata index 都沒有**,於是每一條帶 owner_id 的
|
||||
// 語意查詢(=所有真實使用者路徑,租戶隔離一律帶)都回 0 命中,語意搜尋全盲三天。
|
||||
// 真兇是 arcrun-rag 安裝器把端點寫成 `metadata-index/create`(連字號,CF 回 404,
|
||||
// 正解是底線 `metadata_index/create`),而那支把失敗降級成一行 ⚠ 就宣告安裝成功。
|
||||
// ⇒ **「我發過 create 請求」不等於「index 真的在」**。這一段就是那個等號。
|
||||
// 複驗失敗一律 throw:呼叫端會把它收進 failures 讓部署誠實標紅,而不是
|
||||
// 「語意搜尋開起來了、但全盲」這種最貴的假綠(mindset §7 禁假綠)。
|
||||
const listRes = await fetch(`${base}/metadata_index/list`, { headers: auth, signal: AbortSignal.timeout(60_000) });
|
||||
if (!listRes.ok) {
|
||||
throw new Error(`metadata_index 複驗失敗:list HTTP ${listRes.status}(無法確認 index 是否真的建起來,不當作成功)`);
|
||||
}
|
||||
const listJson = (await listRes.json().catch(() => null)) as
|
||||
| { result?: { metadataIndexes?: Array<{ propertyName?: string }> } }
|
||||
| null;
|
||||
const present = new Set(
|
||||
(listJson?.result?.metadataIndexes ?? []).map(m => String(m.propertyName ?? '')),
|
||||
);
|
||||
const missing = KBDB_VECTORIZE_META_FIELDS.filter(f => !present.has(f));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`metadata_index 複驗不通過:${missing.join('/')} 不在 ${KBDB_VECTORIZE_INDEX} 上。` +
|
||||
'沒有這些 index,帶 owner_id/library 等條件的語意查詢會一律回 0 命中(不會報錯,只是全盲)。',
|
||||
);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** 下載 Gitea archive tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
|
||||
@@ -548,11 +814,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<strin
|
||||
* 解法: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 假綠根因)。
|
||||
@@ -645,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(
|
||||
@@ -678,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] 的不動。
|
||||
@@ -717,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>';
|
||||
|
||||
@@ -1108,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 {}; }
|
||||
}
|
||||
@@ -1123,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;
|
||||
@@ -1134,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
|
||||
@@ -1156,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(); }); // 只藏本次,下次進站再提醒
|
||||
}
|
||||
@@ -1486,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>'
|
||||
@@ -1495,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;
|
||||
@@ -2018,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);
|
||||
@@ -2034,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; })
|
||||
@@ -2049,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,11 +15,19 @@ 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 內容
|
||||
|
||||
@@ -223,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(),
|
||||
|
||||
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-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/*)
|
||||
});
|
||||
}),
|
||||
@@ -1404,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 設定…」自己設。
|
||||
//
|
||||
@@ -1417,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 ?? ''),
|
||||
@@ -1451,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);
|
||||
@@ -1513,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),
|
||||
@@ -1707,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 });
|
||||
@@ -1727,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 };
|
||||
@@ -1751,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 };
|
||||
@@ -1845,22 +1873,25 @@ 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 }
|
||||
| null;
|
||||
const selftestBody = (await selftestRes.json().catch(() => null)) as
|
||||
| { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; filter_blind?: boolean | null; note?: string }
|
||||
| { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; note?: string }
|
||||
| null;
|
||||
embedding = {
|
||||
checked: true,
|
||||
@@ -1871,13 +1902,6 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
||||
ran: selftestBody?.tested ?? false,
|
||||
// 三態:true=能搜到自己 / false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開
|
||||
found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null,
|
||||
// 🔴 2026-08-11(Arcrun#85 D70):found_itself:false 有**兩種處方相反**的成因,
|
||||
// 光看布林分不出來,而 leo21c 就是照著錯的處方(只 reindex)永遠修不好。
|
||||
// true =不帶條件搜得到、一帶歸屬條件就搜不到 ⇒ Vectorize metadata 過濾是死的
|
||||
// (該 index 上沒建 metadata index)⇒ **先建 index 再 reindex**
|
||||
// false=怎麼查都搜不到 ⇒ 向量不在現役 index ⇒ reindex 才對
|
||||
// 讓機器能直接分支,不必去解析 note 的文字。
|
||||
filter_blind: selftestBody?.tested ? (selftestBody?.filter_blind ?? null) : null,
|
||||
note: selftestBody?.note ?? '',
|
||||
},
|
||||
};
|
||||
@@ -1897,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(() => []),
|
||||
@@ -1931,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 = {
|
||||
@@ -1978,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 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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: 表退場施工步驟③讓舊表退場,非資料存取違規,理由見檔頭
|
||||
@@ -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,8 +302,8 @@ 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,
|
||||
};
|
||||
}
|
||||
@@ -428,7 +479,7 @@ export function buildSearchScore(q: string): SearchScorePlan {
|
||||
if (terms.length === 0) {
|
||||
const m = buildContentLike(trimmed);
|
||||
return {
|
||||
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
|
||||
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '),
|
||||
scoreParams: m.params,
|
||||
terms: [],
|
||||
legacyShape: true,
|
||||
@@ -438,16 +489,16 @@ export function buildSearchScore(q: string): SearchScorePlan {
|
||||
const parts: string[] = [];
|
||||
const params: string[] = [];
|
||||
for (const { term, weight } of terms) {
|
||||
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
||||
params.push(`%${term}%`);
|
||||
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 && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
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(`%${trimmed}%`);
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
|
||||
params.push(likePattern(trimmed));
|
||||
}
|
||||
|
||||
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||
@@ -512,7 +563,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||
// 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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+317
-80
@@ -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,20 +453,133 @@ export async function backfillStatus(
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
|
||||
export interface ReconcileResult {
|
||||
enabled: boolean;
|
||||
checked: number; // 本批「真的核對+寫回」的筆數(受下方 D1 額度截斷後的量)。
|
||||
confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。
|
||||
reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。
|
||||
remaining: number; // 本次之後仍待核對的筆數(不受額度影響,可重複呼叫直到 0)。
|
||||
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
|
||||
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與標庫 backfill 共用,見 maintenance-quota.ts)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有候選但今天不再寫 D1,等明天/調高上限)。
|
||||
}
|
||||
|
||||
/**
|
||||
* 世代核對(Generation reconciliation,D68 配套修復,2026-08-11)。
|
||||
*
|
||||
* 背景:`is_embedded=1` 只代表「曾經對某個 Vectorize index 嵌過」,不保證是**現行**的
|
||||
* index/模型(見檔頭 2026-08-03 換代註解:換模型必須換 index,舊向量收不進新 index、也刪不掉)。
|
||||
* 從備份整批灌回的資料尤其會帶著對**已退役索引**(例:768 維 `arcrun-kbdb-embed`)的
|
||||
* `is_embedded=1`——現行 backfill 的預設路徑(只補 `is_embedded=0`)永遠不會碰它們,
|
||||
* 語意搜尋對現行(1024 維 `arcrun-kbdb-embed-m3`)索引而言永遠搜不到那批東西,畫面不會說壞掉。
|
||||
*
|
||||
* 做法:不猜(`is_embedded` 本身此刻不可信),直接問現行 Vectorize index「這些 id 真的在你這嗎」
|
||||
* (`env.VECTORIZE.getByIds`,ground truth,而非比對 content_hash 字串本身——後者在這次修復
|
||||
* 之前從未被寫過,所有既有 is_embedded=1 的列 content_hash 皆為 NULL,無法只憑字串判斷「哪些是
|
||||
* 這次修復前的正常資料、哪些是真正的舊世代殘留」,必須問 Vectorize 本身):
|
||||
* - 真的在現行 index → 只是這次修復之前的正常資料,沒補寫過 content_hash。補標記,不重打 AI
|
||||
* (不浪費額度在已經正確的資料上)。
|
||||
* - 不在現行 index → 對現行 index 而言等於沒嵌過,重置 is_embedded=0、清空 content_hash,
|
||||
* 交回正常 backfill 佇列(下一輪照樣受「新到舊」排序+每日額度上限保護,不特別優待)。
|
||||
*
|
||||
* 不消耗 Workers AI 額度:零 AI.run,只有一次 D1 掃描 + 一次 Vectorize.getByIds + D1 寫回。
|
||||
*
|
||||
* D69(Arcrun#85,2026-08-11 leo 逐行複核找到的破口):**這一步雖不打 AI,但逐筆寫 D1**——
|
||||
* 每個候選最多消耗一次 row write(補標 content_hash 或重置 is_embedded,兩條路互斥、恰好一次),
|
||||
* 47 萬筆候選 ≈ 4.7 倍 D1 100,000 rows written/日免費額度。與標庫 backfill(同樣是多筆 D1
|
||||
* write、不打 AI)共用 `actions/maintenance-quota.ts` 的同一顆每日計數器——不共用的話,
|
||||
* 補標庫時會把這裡的閘繞過去(反之亦然)。額度用完 → 誠實截斷候選,不再寫 D1,等明天。
|
||||
*
|
||||
* 「挑哪一批」:owner_id/library/since/until 走 SelectionCriteria(同 backfillEmbeddings/
|
||||
* backfillEntryLibraryTags 共用的篩選形狀),讓時間分層/庫分層能從外面指定。
|
||||
*/
|
||||
export async function reconcileEmbedGeneration(
|
||||
env: Bindings,
|
||||
opts: Pick<SelectionCriteria, 'owner_id' | 'library' | 'since' | 'until'> & { limit?: number } = {},
|
||||
): Promise<ReconcileResult> {
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0,
|
||||
scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
const currentModel = embedModel(env);
|
||||
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [
|
||||
'is_embedded = 1',
|
||||
'(content_hash IS NULL OR content_hash != ?)',
|
||||
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
|
||||
...sel.conds,
|
||||
];
|
||||
const params: unknown[] = [currentModel, ...sel.params];
|
||||
const where = conds.join(' AND ');
|
||||
|
||||
const res = await env.DB
|
||||
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write,直接照剩餘額度砍候選清單長度。
|
||||
const budget = await maintenanceBudgetToday(env, env.DB);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
const checked = ids.length;
|
||||
|
||||
let confirmed_current = 0;
|
||||
let reset_to_pending = 0;
|
||||
if (ids.length > 0 && env.VECTORIZE) {
|
||||
const found = await env.VECTORIZE.getByIds(ids);
|
||||
const foundIds = new Set(found.map((v) => v.id));
|
||||
const presentIds = ids.filter((id) => foundIds.has(id));
|
||||
const missingIds = ids.filter((id) => !foundIds.has(id));
|
||||
|
||||
if (presentIds.length > 0) {
|
||||
const ph = presentIds.map(() => '?').join(',');
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`)
|
||||
.bind(currentModel, ...presentIds)
|
||||
.run();
|
||||
confirmed_current = presentIds.length;
|
||||
}
|
||||
if (missingIds.length > 0) {
|
||||
const ph = missingIds.map(() => '?').join(',');
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`)
|
||||
.bind(...missingIds)
|
||||
.run();
|
||||
reset_to_pending = missingIds.length;
|
||||
}
|
||||
}
|
||||
|
||||
const remRow = await env.DB
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
const written = confirmed_current + reset_to_pending;
|
||||
try {
|
||||
await addMaintenanceUsage(env.DB, written);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的核對寫入(精神同 backfillEmbeddings 的
|
||||
// addBackfillUsage 失敗處理——寧可下次呼叫少算一點用量,也不讓計數故障吞掉已做的工)。
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0,
|
||||
scanned, quota_limit: budget.limit, quota_used_today: budget.used + written, quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfTestResult {
|
||||
enabled: boolean; // embed 模組是否開(binding 都在)
|
||||
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
|
||||
passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測)
|
||||
note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用
|
||||
// 🔴 2026-08-11 新增(Arcrun#85 D70 leo21c 全盲事件):分辨**兩種處方相反**的故障。
|
||||
// null=沒測到這一層(模組未開/沒帶 owner_id/或帶 filter 就通過了,不必再探)
|
||||
// true =不帶 filter 搜得到,帶 filter 搜不到 ⇒ **Vectorize metadata filter 失效**
|
||||
// false=連不帶 filter 都搜不到 ⇒ 向量根本不在現役 index 裡
|
||||
//
|
||||
// 為什麼非分不可:舊版兩種病都只回一句「需要重新 reindex」。但 metadata index
|
||||
// **不存在**時,Vectorize 不會索引該欄位,reindex 重推幾萬筆也不會生效
|
||||
// ——leo21c 就是照著這個處方修不好。錯的處方比沒有處方更貴。
|
||||
filter_blind: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,7 +599,7 @@ export async function embedSelfTest(
|
||||
opts: { owner_id?: string } = {},
|
||||
): Promise<SelfTestResult> {
|
||||
if (!embedEnabled(env)) {
|
||||
return { enabled: false, tested: false, passed: null, filter_blind: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
|
||||
}
|
||||
const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
|
||||
const params: unknown[] = [];
|
||||
@@ -327,80 +610,34 @@ export async function embedSelfTest(
|
||||
.bind(...params)
|
||||
.first<Entry>();
|
||||
if (!row) {
|
||||
return { enabled: true, tested: false, passed: null, filter_blind: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
|
||||
return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
|
||||
}
|
||||
const sample = (row.content ?? '').trim().slice(0, 200);
|
||||
if (!sample) {
|
||||
return { enabled: true, tested: false, passed: null, filter_blind: null, note: '取樣卡片內容為空,跳過自我檢查' };
|
||||
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' };
|
||||
}
|
||||
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
|
||||
// 第一段=**使用者真正走的那條路**(帶 owner_id filter),先測它;通了就不必多花第二次查詢。
|
||||
const probe = async (o: { owner_id?: string }) =>
|
||||
semanticSearch(env, sample, { ...o, topK: 10, min_score: 0 });
|
||||
let hits: SemanticHit[] | null;
|
||||
try {
|
||||
hits = await probe({ owner_id: opts.owner_id });
|
||||
hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 });
|
||||
} catch (e) {
|
||||
if (e instanceof EmbedQueryFailedError) {
|
||||
// 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。
|
||||
return { enabled: true, tested: false, passed: null, filter_blind: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` };
|
||||
return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (hits === null) {
|
||||
return { enabled: false, tested: false, passed: null, filter_blind: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
|
||||
}
|
||||
const passed = hits.some((h) => h.id === row.id);
|
||||
if (passed) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: true, filter_blind: null,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的',
|
||||
};
|
||||
}
|
||||
|
||||
// ── 沒搜到自己:第二段,判斷是「向量不在 index」還是「filter 失效」───────────────
|
||||
// 🔴 2026-08-11(Arcrun#85 D70,leo21c 實撞):這兩種病的處方**相反**,不能都叫人 reindex。
|
||||
// 自己查自己相似度接近 1.0,所以「搜不到自己」絕不是分數問題(MIN_SCORE_ABS_FLOOR 也被
|
||||
// min_score:0 關掉了)。剩下兩種可能,用「拿掉 filter 再查一次」一刀切開:
|
||||
// 拿掉 filter 就找得到 → 向量在 index 裡,是 **metadata filter 死的**
|
||||
// (metadata index 沒建,或向量早於該 index 建立時間)
|
||||
// ⇒ 修法是**先建 metadata index,再 reindex**;只 reindex 沒用
|
||||
// 拿掉 filter 還是找不到 → 向量真的不在現役 index(常見:換 index 世代後沒重嵌)
|
||||
// ⇒ 修法才是 reindex
|
||||
// 只有在「有帶 owner_id」時第二段才有意義(沒帶 filter 的查詢,兩段是同一件事)。
|
||||
if (!opts.owner_id) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: false,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0',
|
||||
};
|
||||
}
|
||||
let unfiltered: SemanticHit[] | null = null;
|
||||
try {
|
||||
unfiltered = await probe({});
|
||||
} catch (e) {
|
||||
if (!(e instanceof EmbedQueryFailedError)) throw e;
|
||||
// 第二段查詢自己壞了 → 不硬猜,誠實回「分不出是哪一種」。
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: null,
|
||||
note: `拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己;追查用的第二次查詢也失敗(${e.message}),無法判斷是索引沒收錄還是過濾條件失效`,
|
||||
};
|
||||
}
|
||||
const foundWithoutFilter = (unfiltered ?? []).some((h) => h.id === row.id);
|
||||
if (foundWithoutFilter) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: true,
|
||||
note:
|
||||
'拿一張已標記「已嵌入」的卡片自我查詢:**不帶歸屬條件搜得到、一帶上去就搜不到** ⇒ ' +
|
||||
'向量在索引裡,壞的是 Vectorize 的 metadata 過濾(該欄位的 metadata index 沒建,' +
|
||||
'或這些向量是在該 index 建立之前寫進去的)。所有真實查詢都會帶歸屬條件做租戶隔離,' +
|
||||
'所以語意搜尋等於全盲。修法有先後:**先**建 metadata index' +
|
||||
'(owner_id/entry_type/source/library),**再** POST /embed/backfill {"reindex":true}' +
|
||||
'——順序反了或只做 reindex 都不會生效。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: false,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,不論帶不帶歸屬條件都搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0',
|
||||
enabled: true,
|
||||
tested: true,
|
||||
passed,
|
||||
note: passed
|
||||
? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的'
|
||||
: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 撞過的真實案例),
|
||||
|
||||
+60
-56
@@ -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 }>();
|
||||
|
||||
@@ -35,41 +36,6 @@ function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknow
|
||||
else void p.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 「這次零命中,是不是因為 Vectorize 的 metadata 過濾整個是死的?」
|
||||
*
|
||||
* 🔴 2026-08-11 立(Arcrun#85 D70,leo21c 實撞):那台實例的現役 index
|
||||
* `arcrun-kbdb-embed-m3` 上 **一個 metadata index 都沒有**(換代時漏建),於是
|
||||
* Vectorize 對 owner_id/source/entry_type/library 下任何 filter 都回 0 筆。
|
||||
* 而**每一條真實使用者路徑都會帶 owner_id 做租戶隔離**(portal、MCP、workflow 搜尋皆然)
|
||||
* ⇒ 語意搜尋 100% 全盲,但系統只會回「沒有找到符合的內容,換個說法再試試看」。
|
||||
*
|
||||
* 判法不靠猜、也不查 Cloudflare 設定(KBDB 這面牆內打不到那支 API):
|
||||
* **同一句查詢,把 metadata filter 全部拿掉再打一次**。
|
||||
* 有命中 → 向量在 index 裡,死的是 filter(回 true)
|
||||
* 仍零命中 → 就是這次查詢真的沒撞到東西(回 false,維持 no_match)
|
||||
*
|
||||
* 成本紀律:只在「已有嵌入資料卻零命中」這個**本來就已經降級**的分支才會被呼叫,
|
||||
* 正常有結果的查詢一次都不會多花。多的是一次 AI.run + 一次 Vectorize query。
|
||||
* 沒帶任何 filter 的查詢直接回 false(沒有 filter 可以怪,也不必多打一次)。
|
||||
* 探針自己出錯一律回 false——診斷絕不能把查詢本身弄壞(誠實限制,mindset §7)。
|
||||
*/
|
||||
async function filterIsBlind(
|
||||
env: Bindings,
|
||||
q: string,
|
||||
f: { owner_id?: string; source?: string; entry_type?: string; library?: string[] },
|
||||
): Promise<boolean> {
|
||||
const hasFilter = !!(f.owner_id || f.source || f.entry_type || (f.library && f.library.length > 0));
|
||||
if (!hasFilter) return false;
|
||||
try {
|
||||
// min_score:0 + 小 topK:只問「拿掉 filter 到底有沒有東西」,不問品質。
|
||||
const probe = await semanticSearch(env, q, { topK: 5, min_score: 0 });
|
||||
return (probe ?? []).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// library 多值參數(逗號分隔,portal-auth P1,design §3.3)。空值/全空白 → undefined(=不過濾,
|
||||
// 行為與未帶參數一字不變——向後相容硬驗收)。
|
||||
function parseLibraryParam(raw: string | undefined): string[] | undefined {
|
||||
@@ -324,7 +290,7 @@ entryRoutes.get('/search', async (c) => {
|
||||
// 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。
|
||||
// 正常有結果(entries.length>0)完全不受影響,回應形狀不變。
|
||||
if (entries.length === 0) {
|
||||
let empty_reason: 'no_index' | 'no_match' | 'stale_index' | 'filter_blind';
|
||||
let empty_reason: 'no_index' | 'no_match' | 'stale_index';
|
||||
let capability_hint: string;
|
||||
let admin_hint: string;
|
||||
if (hits.length === 0) {
|
||||
@@ -343,26 +309,6 @@ entryRoutes.get('/search', async (c) => {
|
||||
capability_hint =
|
||||
'這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。';
|
||||
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`;
|
||||
} else if (await filterIsBlind(c.env, q, { owner_id, source, entry_type, library })) {
|
||||
// 🔴 2026-08-11(Arcrun#85 D70,leo21c 實撞,三小時才挖出來的那個病):
|
||||
// 「有 N 筆嵌入資料卻零命中」在這裡曾一律被歸成 no_match,回給使用者
|
||||
// 「換個說法再試試看」——但那台實例的真相是 **Vectorize 的 metadata index
|
||||
// 一個都沒建**(換 index 世代時漏了),所以**每一次**帶 owner_id 的語意查詢
|
||||
// 都回 0,換幾種說法都一樣。把系統故障說成使用者的問題,正是 leo 08-09
|
||||
// 直令禁止的那件事;而且它是靜默的——沒人會因為「搜不到」去查 Vectorize 設定。
|
||||
// 判法不靠猜:**同一句查詢拿掉 metadata filter 再打一次**,有命中就證明
|
||||
// 向量在索引裡、死的是 filter(見 filterIsBlind)。
|
||||
empty_reason = 'filter_blind';
|
||||
capability_hint =
|
||||
'語意搜尋目前故障——你的資料都在,是我們的索引設定壞了,所以每一次語意搜尋都會空手而回。' +
|
||||
'這不是你打的字有問題,換個說法也不會有用。請先用關鍵字搜尋,我們會修好它。';
|
||||
admin_hint =
|
||||
`owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料;帶 metadata filter 零命中,` +
|
||||
'但同一句查詢拿掉 filter 後有命中 ⇒ 向量在 index 裡,死的是 Vectorize metadata 過濾。' +
|
||||
'成因:該 index 上沒有對應的 metadata index(換 index 世代/改名時最常漏),' +
|
||||
'或既有向量早於 metadata index 的建立時間。修法有先後:**先**建 metadata index' +
|
||||
'(owner_id/entry_type/source/library,acr 的 ensureVectorizeMetadataIndexes 會冪等建),' +
|
||||
'**再** POST /embed/backfill {"reindex":true} 重推到 remaining=0。只做 reindex 不會生效。';
|
||||
} else {
|
||||
empty_reason = 'no_match';
|
||||
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
|
||||
@@ -422,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); // 在時間窗外,沒被動到
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,70 +113,6 @@ describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的
|
||||
expect(r.passed).toBeNull();
|
||||
});
|
||||
|
||||
// ── Arcrun#85 D70(2026-08-11 leo21c 全盲事故)──────────────────────────────
|
||||
// 兩種故障的**處方相反**,舊版都只回一句「需要重新 reindex」:
|
||||
// ① metadata filter 死掉(metadata index 沒建)→ 先建 index 再 reindex;只 reindex 無效
|
||||
// ② 向量不在現役 index(換世代沒重嵌) → reindex 才是對的
|
||||
// 判法=拿掉 filter 再查一次。下面的假 VECTORIZE 依「有沒有帶 filter」回不同結果,
|
||||
// 精確重現 leo21c 的現場(不帶 filter score 0.8957 命中、帶 owner_id 0 命中)。
|
||||
function makeFilterAwareEnv(
|
||||
store: Entry[],
|
||||
opts: { unfilteredMatches: { id: string; score: number }[]; filteredMatches: { id: string; score: number }[] },
|
||||
): Bindings {
|
||||
return {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: {
|
||||
async query(_v: number[], o?: { filter?: Record<string, unknown> }) {
|
||||
const filtered = !!(o?.filter && Object.keys(o.filter).length > 0);
|
||||
return { matches: filtered ? opts.filteredMatches : opts.unfilteredMatches };
|
||||
},
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
it('不帶 filter 搜得到、帶 owner_id 搜不到 → filter_blind:true,處方是「先建 metadata index 再 reindex」', async () => {
|
||||
const store = [mkEntry('e1', '淡水河口的黑面琵鷺在退潮時會集體覓食', 'bfezv28v')];
|
||||
const env = makeFilterAwareEnv(store, {
|
||||
unfilteredMatches: [{ id: 'e1', score: 0.8957 }], // leo21c 實測分數
|
||||
filteredMatches: [],
|
||||
});
|
||||
const r = await embedSelfTest(env, { owner_id: 'bfezv28v' });
|
||||
expect(r.tested).toBe(true);
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.filter_blind).toBe(true);
|
||||
expect(r.note).toContain('metadata');
|
||||
// 🔴 處方順序必須寫出來——只叫人 reindex 正是 leo21c 修不好的原因
|
||||
expect(r.note).toContain('reindex');
|
||||
expect(r.note).toMatch(/先.*建.*再/s);
|
||||
});
|
||||
|
||||
it('帶不帶 filter 都搜不到 → filter_blind:false,處方才是 reindex', async () => {
|
||||
const store = [mkEntry('e1', 'content', 'o1')];
|
||||
const env = makeFilterAwareEnv(store, { unfilteredMatches: [], filteredMatches: [] });
|
||||
const r = await embedSelfTest(env, { owner_id: 'o1' });
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.filter_blind).toBe(false);
|
||||
expect(r.note).toContain('reindex');
|
||||
expect(r.note).not.toContain('metadata index 沒建');
|
||||
});
|
||||
|
||||
it('帶 filter 就搜得到 → passed:true、filter_blind:null,且不多花第二次查詢', async () => {
|
||||
const store = [mkEntry('e1', 'content', 'o1')];
|
||||
let queries = 0;
|
||||
const env = {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { queries++; return { matches: [{ id: 'e1', score: 0.9 }] }; } },
|
||||
} as unknown as Bindings;
|
||||
const r = await embedSelfTest(env, { owner_id: 'o1' });
|
||||
expect(r.passed).toBe(true);
|
||||
expect(r.filter_blind).toBeNull();
|
||||
expect(queries).toBe(1); // 健康的情況不該多打一次(成本紀律)
|
||||
});
|
||||
|
||||
it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => {
|
||||
const store = [mkEntry('e1', 'this is the secret card body, must never leak')];
|
||||
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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); // 詞數上限
|
||||
});
|
||||
|
||||
@@ -107,80 +107,3 @@ describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼
|
||||
expect(body.capability_hint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 第四態 filter_blind(Arcrun#85 D70,2026-08-11 leo21c 實撞)─────────────────
|
||||
//
|
||||
// 現場:現役 Vectorize index `arcrun-kbdb-embed-m3` 上**一個 metadata index 都沒有**
|
||||
// (真兇=arcrun-rag 安裝器把端點寫成 `metadata-index/create`,連字號版 CF 回 404,
|
||||
// 底線 `metadata_index/create` 才是對的;而該安裝器把失敗降級成一行 ⚠ 就宣告成功)。
|
||||
// ⇒ Vectorize 對 owner_id 下 filter 一律回 0 筆,而**每一條真實使用者路徑都帶 owner_id**
|
||||
// 做租戶隔離 ⇒ 語意搜尋 100% 全盲。
|
||||
// 實測(leo21c,同一句查詢):不帶 filter → 1 命中 score 0.8957;帶 owner_id → 0 命中。
|
||||
//
|
||||
// 舊行為把這個歸成 no_match,回「換個說法或更具體的關鍵字再試試看」
|
||||
// =**把系統故障說成使用者的問題**,正是 leo 2026-08-09 直令禁止的那件事,
|
||||
// 而且沒有人會因為「搜不到」去翻 Cloudflare 的 Vectorize 設定。
|
||||
function makeFilterAwareEnv(
|
||||
dbOpts: Parameters<typeof makeFakeDB>[0],
|
||||
opts: { unfiltered: { id: string; score: number }[]; filtered: { id: string; score: number }[] },
|
||||
): Bindings {
|
||||
return {
|
||||
DB: makeFakeDB(dbOpts),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: {
|
||||
async query(_v: number[], o?: { filter?: Record<string, unknown> }) {
|
||||
const filtered = !!(o?.filter && Object.keys(o.filter).length > 0);
|
||||
return { matches: filtered ? opts.filtered : opts.unfiltered };
|
||||
},
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
describe('empty_reason=filter_blind — Vectorize metadata 過濾整個是死的', () => {
|
||||
it('帶 owner_id 零命中、拿掉 filter 有命中 → filter_blind,且照實說是我們的故障', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeFilterAwareEnv(
|
||||
{ embeddedCount: 805, hydrateEntry: mkEntry('e1') },
|
||||
{ unfiltered: [{ id: 'e1', score: 0.8957 }], filtered: [] },
|
||||
);
|
||||
const res = await app.request('/entries/search?q=黑面琵鷺&mode=semantic&owner_id=bfezv28v', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.count).toBe(0);
|
||||
expect(body.empty_reason).toBe('filter_blind');
|
||||
const hint = body.capability_hint as string;
|
||||
// 🔴 誠實鐵律:是故障、不是使用者的錯,且**明說換個說法沒有用**
|
||||
expect(hint).toContain('故障');
|
||||
expect(hint).toContain('不是你');
|
||||
expect(/換個說法也不會有用/.test(hint)).toBe(true);
|
||||
// 🔴 人話紅線:不准把 Vectorize/owner_id 這類內部詞漏給使用者
|
||||
expect(/vectorize|owner_id|metadata|index/i.test(hint)).toBe(false);
|
||||
// 技術細節與**處方順序**留給維運者
|
||||
const admin = body.admin_hint as string;
|
||||
expect(admin).toContain('metadata index');
|
||||
expect(admin).toContain('reindex');
|
||||
});
|
||||
|
||||
it('沒帶任何 filter 的查詢不做探針,維持 no_match(不多花一次查詢)', async () => {
|
||||
const app = makeApp();
|
||||
let queries = 0;
|
||||
const env = {
|
||||
DB: makeFakeDB({ embeddedCount: 42 }),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { queries++; return { matches: [] }; } },
|
||||
} as unknown as Bindings;
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.empty_reason).toBe('no_match');
|
||||
expect(queries).toBe(1);
|
||||
});
|
||||
|
||||
it('帶 filter 但拿掉 filter 也零命中 → 仍是 no_match(別把正常的找不到誣賴成故障)', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeFilterAwareEnv({ embeddedCount: 42 }, { unfiltered: [], filtered: [] });
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.empty_reason).toBe('no_match');
|
||||
});
|
||||
});
|
||||
|
||||
+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' })");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,23 @@
|
||||
// wait — 等待指定毫秒數後繼續(最多 30 秒)
|
||||
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
|
||||
//
|
||||
// ⚠️ 已由引擎接手,這份 WASM 在 Cloudflare Workers 上跑不動(Arcrun#101,2026-08-12)。
|
||||
// 現行實作在 cypher-executor/src/lib/constants.ts 的 BUILTIN_COMPONENTS['wait'],
|
||||
// component-loader step 1 先命中,這顆 wasm 不會再被工作流呼叫到。
|
||||
//
|
||||
// 為什麼跑不動(不是「比較慢」,是「永遠不會結束」):
|
||||
// 下面的 time.Sleep 在 TinyGo 走 WASI poll_oneoff,而 component worker 的 WASI shim
|
||||
// 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get 自旋;
|
||||
// Workers 的時鐘在無 I/O 的同步執行期間是凍結的 ⇒ 結束條件永遠不成立 ⇒ 一路燒到
|
||||
// CPU 上限被砍(error 1102)。leo 實測 ms=3000/20000/30000 全在 ~35 秒後 503,
|
||||
// 死法與 ms 無關 —— 這正是「迴圈沒結束」而非「等待很貴」的證據。
|
||||
//
|
||||
// 原本的舊註解寫「改用 busy-wait 模擬」是錯的:這個檔從來沒有 busy-wait,
|
||||
// 一直是 time.Sleep。那句話誤導了後來每一個讀這個檔的人。
|
||||
//
|
||||
// 本次刻意不改行為、只改註解:手邊沒有 TinyGo 工具鏈,改了 main.go 卻沒重編,
|
||||
// 會讓 repo 內已 commit 的 .component-builds/wait/component.wasm 與原始碼漂移
|
||||
// (rule 05「WASM 來源」:那份 wasm 是 self-host 用戶的部署來源)。
|
||||
// 要退役這顆零件(刪目錄/下架 wait.arcrun.dev)是另一個決定,需人拍板。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -42,6 +42,8 @@ import { join, resolve, basename, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
// Arcrun#108 出貨閘(見 main() 內註解)。規則本體與掃描器住在 cypher-executor/scripts/。
|
||||
import { scanProject as scanTenantSources } from '../cypher-executor/scripts/check-tenant-source.mjs';
|
||||
|
||||
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
|
||||
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
|
||||
@@ -214,6 +216,29 @@ async function main() {
|
||||
console.log('✔ node_modules 檢查通過:');
|
||||
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
|
||||
|
||||
// ── 出貨閘:靜態租戶字串不得用於資料面過濾(Arcrun#108,#105 同族)─────────────
|
||||
//
|
||||
// 為什麼擋在**這裡**:這條路徑是成品的產地(.worker-builds/ → 使用者的機器)。
|
||||
// 擋在這裡=違規的碼**編不出成品、出不了貨**,而不是「有人記得跑檢查才會發現」。
|
||||
// leo 2026-08-12:「做一個平台要減少 hotfix。」規則存在但沒機制驗證,就是會再犯第三次。
|
||||
//
|
||||
// 規則本體是純函式(cypher-executor/scripts/tenant-source-rules.mjs),
|
||||
// 由 cypher-executor/tests/tenant-gate.test.ts 逐條驗「壞例子會擋、合法寫法零誤攔」
|
||||
// ——這道閘自己可測,也擋不到自己(掃描範圍只有 cypher-executor/src/)。
|
||||
const tenantViolations = scanTenantSources(join(REPO, 'cypher-executor'));
|
||||
if (tenantViolations.length) {
|
||||
console.error('\n❌ 建置中止:cypher-executor 有「靜態租戶字串用於資料面過濾」的寫法(Arcrun#108 的閘):\n');
|
||||
for (const v of tenantViolations) {
|
||||
console.error(` [${v.rule}] ${v.file}:${v.line} ${v.text}`);
|
||||
console.error(` → ${v.message}`);
|
||||
}
|
||||
console.error('\n知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()');
|
||||
console.error('(cypher-executor/src/lib/tenant.ts 是租戶字串的唯一產地)。');
|
||||
console.error('本機自查:cd cypher-executor && npm run check:tenant\n');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts');
|
||||
|
||||
if (CHECK_ONLY) {
|
||||
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sync-resource-rule.mjs — 把「該用哪些資源」這條規則的**唯一原稿**同步給需要打包的呼叫端。
|
||||
*
|
||||
* 【為什麼需要這支】
|
||||
* 規則的原稿在 `shared/resource-rule/rule.mjs`(理由見該檔開頭)。
|
||||
* 兩條路取用它的方式不同:
|
||||
*
|
||||
* · **安裝器 / 任何 Worker**:本來就會下載這個 repo 的 archive 當部署來源,
|
||||
* 直接 import `shared/resource-rule/rule.mjs`。**不需要副本,本支不管它。**
|
||||
*
|
||||
* · **`acr` CLI**:`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案
|
||||
* ⇒ 套件裡必須有一份。這支就是產生那一份的地方。
|
||||
*
|
||||
* 【這算不算「第二份實作」】
|
||||
* 不算,而且是機械保證的:產生物是**逐位元組副本**,`--check` 一有差就 exit 1,
|
||||
* 而 `npm run build` 與 `npm test` 都會先跑 `--check`。
|
||||
* 也就是說「有人手改了 CLI 那一份」= build 紅、publish 擋下。
|
||||
* ——同 `cli/harness/`(產生物進 repo + `check:harness` 世代閘)的既有慣例,
|
||||
* 不是為本票新發明的做法。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/sync-resource-rule.mjs 產生/更新副本
|
||||
* node scripts/sync-resource-rule.mjs --check 只檢查,有漂移就 exit 1(不寫檔)
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// REPO 一律由本檔位置推導,不吃 cwd(比照 scripts/build-worker-artifacts.mjs)。
|
||||
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
|
||||
|
||||
/**
|
||||
* 需要「套件內自帶一份」的呼叫端。**整個目錄原樣鏡射**(不是挑檔案)——
|
||||
* 檔名與相對位置保持一致,`cf-resource-api.mjs` 裡的 `./rule.mjs` 才不用改寫。
|
||||
* 安裝器不在此列:它直接讀 repo archive 裡的原稿,連副本都不需要。
|
||||
*/
|
||||
const MIRRORS = ['cli/src/lib/resource-rule'];
|
||||
|
||||
const CHECK_ONLY = process.argv.includes('--check');
|
||||
|
||||
/** @param {string|Buffer} b */
|
||||
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
||||
|
||||
if (!existsSync(SOURCE_DIR)) {
|
||||
console.error(`❌ 找不到規則原稿目錄:${SOURCE_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** 原稿目錄裡所有 .mjs(README / 測試不進副本)。 */
|
||||
const FILES = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
|
||||
if (FILES.length === 0) {
|
||||
console.error(`❌ ${SOURCE_DIR} 裡沒有任何 .mjs 原稿`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let drifted = 0;
|
||||
for (const mirror of MIRRORS) {
|
||||
for (const file of FILES) {
|
||||
const src = readFileSync(join(SOURCE_DIR, file));
|
||||
const srcHash = sha256(src);
|
||||
const rel = `${mirror}/${file}`;
|
||||
const abs = join(REPO, mirror, file);
|
||||
const had = existsSync(abs) ? readFileSync(abs) : null;
|
||||
|
||||
if (had !== null && sha256(had) === srcHash) {
|
||||
console.log(`✔ ${rel} = 原稿(sha256 ${srcHash.slice(0, 12)})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CHECK_ONLY) {
|
||||
drifted++;
|
||||
console.error(
|
||||
had === null
|
||||
? `✗ ${rel} 不存在——跑 \`node scripts/sync-resource-rule.mjs\` 產生。`
|
||||
: `✗ ${rel} 與原稿不一致(副本 ${sha256(had).slice(0, 12)} ≠ 原稿 ${srcHash.slice(0, 12)})。\n` +
|
||||
` 這一份是**產生物**,不要手改:規則要改就改 shared/resource-rule/${file},` +
|
||||
`然後跑 \`node scripts/sync-resource-rule.mjs\`。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirSync(join(REPO, mirror), { recursive: true });
|
||||
writeFileSync(abs, src);
|
||||
console.log(`↻ ${rel} ← shared/resource-rule/${file}(sha256 ${srcHash.slice(0, 12)})`);
|
||||
}
|
||||
|
||||
// 副本目錄裡多出來的 .mjs = 有人在產生物旁邊自己加了一支(第二份實作的常見長法)。
|
||||
const mirrorAbs = join(REPO, mirror);
|
||||
const extra = existsSync(mirrorAbs)
|
||||
? readdirSync(mirrorAbs).filter((f) => f.endsWith('.mjs') && !FILES.includes(f))
|
||||
: [];
|
||||
for (const f of extra) {
|
||||
drifted++;
|
||||
console.error(`✗ ${mirror}/${f} 在原稿目錄裡不存在——副本目錄不是放自己東西的地方。`);
|
||||
}
|
||||
}
|
||||
|
||||
if (drifted > 0) {
|
||||
console.error(
|
||||
`\n❌ ${drifted} 項與規則原稿脫節。` +
|
||||
`\n「該用哪些資源」只能有一份實作(.claude/rules/07-thin-shell.md)——` +
|
||||
`副本漂移就是第二份實作偷偷長出來的樣子。`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
# `shared/resource-rule` — 「這個實例該用哪些資源」的唯一一份規則
|
||||
|
||||
> leo 2026-08-12:
|
||||
> ①「如果你沒有裝,就是新的;**如果你已經有,原來叫什麼名字就繼續用下去**。」
|
||||
> ②「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
|
||||
|
||||
① 是規則本身,② 是它該住哪裡。這個目錄就是 ②。
|
||||
|
||||
---
|
||||
|
||||
## 1. 規則(三句話)
|
||||
|
||||
判準是「**這顆 worker 現在綁著誰**」,**不是**「有沒有叫這個名字的資源」。
|
||||
|
||||
1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
|
||||
2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
|
||||
3. **只要有一點說不準就整趟停手**——讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
|
||||
該更新的 worker 一顆都不在 ⇒ **什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
|
||||
|
||||
`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段,
|
||||
所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。
|
||||
|
||||
---
|
||||
|
||||
## 2. 為什麼在這裡,不在 cypher-executor 的 API
|
||||
|
||||
`.claude/rules/07-thin-shell.md` 的標準答案是「能力放 API」。這一條**不走那條路**,理由是自舉:
|
||||
|
||||
| 問題 | 說明 |
|
||||
|---|---|
|
||||
| **cypher 可能還不存在** | 這條規則要在「決定怎麼裝」的當下就用得到,而安裝器的工作正是把 cypher 生出來。把規則放進 cypher = 要先有雞才能有蛋。 |
|
||||
| **輸入是使用者自己的帳號狀態** | 判斷的依據是使用者 Cloudflare 帳號上的綁定。送去平台託管的 worker 換一個答案 ⇒ ①「能不能安裝」綁在平台是否活著,②使用者的帳號拓撲交給第三方。 |
|
||||
| **它根本不需要是服務** | 這是**純函式**:唯一的 IO 由呼叫端注入(`ResourceApi`)。薄殼原則要求「能力只實作一次」,不是「能力一定要是 HTTP」。 |
|
||||
|
||||
所以形態是**一份零依賴的 ESM**——Node 18+ 與 Cloudflare Workers runtime 都能直接 import,
|
||||
不必編譯、不必連網、不必先有任何 arcrun 元件活著。
|
||||
|
||||
其他評估過的形態:**共用 npm 套件** → 要多發一個 package + token,且安裝器得先 `npm i` 才能判斷,
|
||||
自舉問題只是換個位置;**做成一顆零件** → 得用 TinyGo/AssemblyScript 重寫一次,那正是「第二份實作」。
|
||||
|
||||
---
|
||||
|
||||
## 3. 檔案
|
||||
|
||||
| 檔案 | 內容 |
|
||||
|---|---|
|
||||
| `rule.mjs` | 規則本體:`planResources` / `applyResourcePlan` / `parseWranglerRequirements` + 把 CF 回應讀成事實的 `normalizeLiveBindings` / `normalizeLiveVars` |
|
||||
| `cf-resource-api.mjs` | `ResourceApi` 的 CF REST 實作(只用 global `fetch`)。**眼睛也要共用**——見下 §5 |
|
||||
| `installer-entry.mjs` | 安裝器唯一該碰的入口:`resolveInstanceResources()` |
|
||||
| `tests/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+三種情境 |
|
||||
| `tests/demo.mjs` | `node shared/resource-rule/tests/demo.mjs`——零依賴、零建置就能跑的示範 |
|
||||
|
||||
🔴 **零依賴是硬規則**:只准 import 同目錄的兄弟檔,不准碰 `node:*`。
|
||||
有外部依賴就會有某條路吃不到它。`cli/tests/single-implementation.test.ts` ③ 會擋。
|
||||
|
||||
---
|
||||
|
||||
## 4. 兩條路怎麼取用
|
||||
|
||||
### 安裝器 / 任何 Worker(不需要副本)
|
||||
|
||||
安裝器本來就會下載本 repo 的 archive 當部署來源(`.claude/rules/05-deploy-convention.md`
|
||||
「WASM 來源」),`shared/resource-rule/` 就在那份 archive 裡:
|
||||
|
||||
```js
|
||||
import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
|
||||
|
||||
const r = await resolveInstanceResources({
|
||||
accountId, apiToken,
|
||||
wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // toml 的「內容」,不是路徑
|
||||
mode: isUpdate ? 'update' : 'init',
|
||||
});
|
||||
|
||||
if (r.blocked) {
|
||||
// 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
|
||||
return showAndStop(r.blockers);
|
||||
}
|
||||
// r.bindings : { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
|
||||
// r.origin : { 'kv_namespace:WEBHOOKS': 'adopted' | 'created', … }
|
||||
// r.liveVars : { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } } ← #106
|
||||
```
|
||||
|
||||
**安裝器不准自己判斷要不要建資源**,也不准自己解讀 CF 的 binding 回應。只呼叫這一支。
|
||||
|
||||
### `acr` CLI(需要一份鏡射)
|
||||
|
||||
`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒ 套件裡必須自帶一份。
|
||||
`cli/src/lib/resource-rule/` 就是本目錄的**逐位元組鏡射**,由
|
||||
`node scripts/sync-resource-rule.mjs` 產生。
|
||||
|
||||
**要改規則就改這個目錄,然後重跑 sync。** 手改鏡射會被擋下:
|
||||
`npm run build` 與 `npm test` 都先跑 `sync-resource-rule.mjs --check`,
|
||||
差一個位元組就 exit 1(同 `cli/harness/` 的產生物+世代閘慣例)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 為什麼連 CF client 也共用
|
||||
|
||||
判斷一致還不夠,**看到的東西**也要一致。
|
||||
|
||||
「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的。
|
||||
兩條路各自寫一份 client,只要有一邊把 404 當錯誤、漏了 `per_page`、少認一種欄位名
|
||||
(`namespace_id` vs `id`),那一邊就會「看不到既有綁定」——
|
||||
而看不到既有綁定的下一步,依規則就是**新建**。
|
||||
|
||||
**Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。**
|
||||
所以 `cli/src/lib/cf-api.ts` 的 `CfAccountClient` 把 `ResourceApi` 那七個方法**全部委派**
|
||||
給 `cf-resource-api.mjs`,自己不留實作。
|
||||
|
||||
---
|
||||
|
||||
## 6. 驗收
|
||||
|
||||
```bash
|
||||
cd cli && npm test # 58 項,含下列三組
|
||||
node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑
|
||||
```
|
||||
|
||||
| 測試 | 證的事 |
|
||||
|---|---|
|
||||
| `cli/tests/two-paths-agree.test.ts` | 同一個帳號狀態餵給 `acr` 那條與安裝器那條,**選出的 resource id 相同**、建的東西相同、停手的理由相同 |
|
||||
| `cli/tests/single-implementation.test.ts` | ①規則的 7 支函式全 repo 只有這裡有實作 ②鏡射逐位元組相同 ③共用層零依賴 |
|
||||
| `cli/tests/resource-adoption.test.ts` | #97 本身的迴歸(沿用/不多建/四種停手情境),改共用層後照樣全過 |
|
||||
|
||||
三種情境(`tests/fixture-account.mjs` 的 `SCENARIOS`):
|
||||
|
||||
- `fresh` — 沒裝過 → **正常建新的**(不能為了沿用而變成永遠不建)
|
||||
- `installed` — 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在
|
||||
- `renamed` — **資源在但名字與預期完全不同** → 仍然沿用(#97 的病根,專門驗)
|
||||
|
||||
---
|
||||
|
||||
## 7. 相關
|
||||
|
||||
- `Arcrun#97` — 「我按了更新,工作流和登入全不見了」:CLI 那條已修,本目錄是把同一條規則交給所有路徑
|
||||
- `Arcrun#106` — 重部署把 `plain_text` var(含版本標籤)洗掉:`liveVars` 就是那些標籤
|
||||
- `Arcrun#80` / `arcrun-rag#39` — 同一個「重複做 Arcrun 的工作」家族;Arcrun 是唯一編譯點的既有慣例
|
||||
- `.claude/rules/07-thin-shell.md` — 本目錄存在的依據
|
||||
@@ -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,60 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* demo.mjs — 安裝器那條路的**可獨立執行**證明。
|
||||
*
|
||||
* node shared/resource-rule/tests/demo.mjs
|
||||
*
|
||||
* 這支只 import `shared/resource-rule/`,**沒有 node_modules、沒有建置步驟**——
|
||||
* 跑得起來本身就是「安裝器把 repo archive 拉下來就能直接用」這句話的證據。
|
||||
* (對照組:`acr` 那條要先 npm ci + TS 轉譯才跑得動。兩條路差在外殼,判斷是同一份。)
|
||||
*
|
||||
* 三種情境各跑一次,印出每個 binding 選到哪顆資源、以及這一趟建了幾顆。
|
||||
*/
|
||||
|
||||
import { resolveInstanceResources } from '../installer-entry.mjs';
|
||||
import { makeAccount, SCENARIOS, WORKER_NEEDS } from './fixture-account.mjs';
|
||||
|
||||
/** 用 fixture 的需求組出各 worker 的 wrangler.toml 內容。 */
|
||||
function tomls() {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
const order = /** @type {const} */ (['fresh', 'installed', 'renamed']);
|
||||
|
||||
console.log('安裝器那條路(只 import shared/resource-rule/,零依賴、零建置)\n');
|
||||
|
||||
for (const scenario of order) {
|
||||
const mode = scenario === 'fresh' ? 'init' : 'update';
|
||||
const account = makeAccount(scenario);
|
||||
const r = await resolveInstanceResources({
|
||||
accountId: 'acct-demo',
|
||||
apiToken: 'tok-demo',
|
||||
wranglerTomls: tomls(),
|
||||
mode,
|
||||
fetch: account.fetch,
|
||||
});
|
||||
|
||||
console.log(`── ${scenario}(mode=${mode}):${SCENARIOS[scenario].label}`);
|
||||
if (r.blocked) {
|
||||
console.log(' ⛔ 停手,一顆資源都沒建:');
|
||||
for (const b of r.blockers) console.log(` • ${b}`);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(r.bindings).sort()) {
|
||||
console.log(` ${key.padEnd(30)} → ${r.bindings[key].padEnd(26)} ${r.origin[key]}`);
|
||||
}
|
||||
console.log(
|
||||
` 本趟新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆、` +
|
||||
`Vectorize ${account.created.vectorize.length} 顆` +
|
||||
`|沿用既有版本標籤 ARCRUN_BUNDLE_VERSION=` +
|
||||
`${r.liveVars['arcrun-cypher-executor']?.ARCRUN_BUNDLE_VERSION ?? '(無,全新安裝)'}\n`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* fixture-account.mjs — 一個假的 Cloudflare 帳號,做成 **`fetch` 替身**。
|
||||
*
|
||||
* 【為什麼是 fetch 替身,不是假的 ResourceApi 物件】
|
||||
* 本票要證的是「`acr` 那條與安裝器那條,跑出來的決定必須一致」。
|
||||
* 如果兩條路各自餵一個假的 `ResourceApi`,那就只測到了 `rule.mjs` 的判斷,
|
||||
* **完全跳過了「怎麼把 CF 回應讀成事實」**——而 Arcrun#97 的重演只需要眼睛不一樣就夠了
|
||||
* (一邊把 404 當錯誤、一邊漏認 `namespace_id`…)。
|
||||
* 從 `fetch` 這一層假起,兩條路就是真的走完整條鏈:HTTP → 解析 → 判斷。
|
||||
*
|
||||
* 零依賴、純 ESM,Node 與 Workers 都能跑。
|
||||
*/
|
||||
|
||||
/** arcrun 各 worker 在 wrangler.toml 裡宣告的 KV binding 名(= 需求,不是資源名)。 */
|
||||
export const KV_BINDINGS = [
|
||||
'WEBHOOKS', 'CREDENTIALS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV',
|
||||
'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', 'OAUTH_KV',
|
||||
];
|
||||
|
||||
/** 這台實例上有資源綁定的四顆 worker,以及各自需要的綁定。 */
|
||||
export const WORKER_NEEDS = {
|
||||
'arcrun-cypher-executor': {
|
||||
kv: ['EXEC_CONTEXT', 'WEBHOOKS', 'CREDENTIALS_KV', 'ANALYTICS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV'],
|
||||
d1: [{ binding: 'CREDENTIALS_DB', database_name: 'arcrun-kbdb' }],
|
||||
},
|
||||
'arcrun-registry': { kv: ['SUBMISSIONS_KV', 'ANALYTICS_KV'], d1: [] },
|
||||
'arcrun-mcp': { kv: ['OAUTH_KV'], d1: [] },
|
||||
'arcrun-kbdb': { kv: [], d1: [{ binding: 'DB', database_name: 'arcrun-kbdb' }] },
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 WORKER_NEEDS 攤成 `BindingRequirement[]`——兩條路都用**同一份需求**進去,
|
||||
* 才能證明差異(如果有)來自實作而不是輸入。
|
||||
* @returns {Array<{kind: 'kv_namespace'|'d1', binding: string, worker: string, createName: string}>}
|
||||
*/
|
||||
export function requirements() {
|
||||
const out = [];
|
||||
for (const [worker, need] of Object.entries(WORKER_NEEDS)) {
|
||||
for (const b of need.kv) out.push({ kind: 'kv_namespace', binding: b, worker, createName: b });
|
||||
for (const d of need.d1) {
|
||||
out.push({ kind: 'd1', binding: d.binding, worker, createName: d.database_name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
|
||||
* 這正是 #97 的病根所在:規則**不准**拿名字當識別。
|
||||
*
|
||||
* @typedef {'fresh' | 'installed' | 'renamed'} Scenario
|
||||
*/
|
||||
|
||||
/** @type {Record<Scenario, {label: string, deployed: boolean, titleFor: (binding: string) => string}>} */
|
||||
export const SCENARIOS = {
|
||||
fresh: {
|
||||
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
|
||||
deployed: false,
|
||||
titleFor: (b) => b,
|
||||
},
|
||||
installed: {
|
||||
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>)',
|
||||
deployed: true,
|
||||
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
|
||||
},
|
||||
renamed: {
|
||||
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
|
||||
deployed: true,
|
||||
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
|
||||
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 建一個假帳號 + 對應的 `fetch` 替身。
|
||||
*
|
||||
* @param {Scenario} scenario
|
||||
* @returns {{
|
||||
* fetch: typeof globalThis.fetch,
|
||||
* created: {kv: string[], d1: string[], vectorize: string[]},
|
||||
* userData: {workflows: string[], sessions: string[], libraries: string[]},
|
||||
* kvIdFor: (binding: string) => string | undefined,
|
||||
* d1Id: string,
|
||||
* requestLog: string[],
|
||||
* }}
|
||||
*/
|
||||
export function makeAccount(scenario) {
|
||||
const spec = SCENARIOS[scenario];
|
||||
/** title → id */
|
||||
const kv = new Map();
|
||||
/** name → uuid */
|
||||
const d1 = new Map();
|
||||
/** @type {string[]} */
|
||||
const vectorize = [];
|
||||
/** script → CF `/settings` 回應裡的 bindings[] 原始形狀 */
|
||||
const scripts = new Map();
|
||||
|
||||
const created = { kv: [], d1: [], vectorize: [] };
|
||||
const requestLog = [];
|
||||
|
||||
// 使用者的東西——驗「更新完還在不在」用。掛在資源 id 上,不是掛在名字上。
|
||||
const userData = {
|
||||
workflows: ['webhook:leo:daily-digest', 'webhook:leo:inbox-sync', 'webhook:leo:rag-ingest'],
|
||||
sessions: ['session:leo-abc123'],
|
||||
libraries: ['general', '課程', '客戶', '研究'],
|
||||
};
|
||||
|
||||
const kvIdByBinding = new Map();
|
||||
const D1_ID = 'd1id-kbdb-REAL';
|
||||
|
||||
if (spec.deployed) {
|
||||
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
|
||||
for (const b of KV_BINDINGS) {
|
||||
const id = `kvid-${b.toLowerCase()}-REAL`;
|
||||
kv.set(spec.titleFor(b), id);
|
||||
kvIdByBinding.set(b, id);
|
||||
}
|
||||
d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID);
|
||||
|
||||
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
|
||||
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
|
||||
const bindings = [];
|
||||
for (const b of need.kv) {
|
||||
bindings.push({ type: 'kv_namespace', name: b, namespace_id: kvIdByBinding.get(b) });
|
||||
}
|
||||
for (const d of need.d1) bindings.push({ type: 'd1', name: d.binding, id: D1_ID });
|
||||
// #106:plain_text var 也在同一份回應裡
|
||||
bindings.push({ type: 'plain_text', name: 'ARCRUN_BUNDLE_VERSION', text: '1.4.33' });
|
||||
scripts.set(script, bindings);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {unknown} result @param {number} [status] */
|
||||
const ok = (result, status = 200) =>
|
||||
new Response(JSON.stringify({ success: true, result, errors: [] }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
/** @param {string} message @param {number} status */
|
||||
const fail = (message, status) =>
|
||||
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
/** @type {typeof globalThis.fetch} */
|
||||
// @ts-expect-error — 測試替身只實作用得到的那幾條路徑
|
||||
const fakeFetch = async (input, init) => {
|
||||
const url = new URL(typeof input === 'string' ? input : String(input));
|
||||
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
requestLog.push(`${method} ${path}${url.search}`);
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
|
||||
// 已部署 worker 的綁定
|
||||
const m = path.match(/^\/workers\/scripts\/([^/]+)\/settings$/);
|
||||
if (m && method === 'GET') {
|
||||
const script = decodeURIComponent(m[1]);
|
||||
if (!scripts.has(script)) return fail('workers.api.error.script_not_found', 404);
|
||||
return ok({ bindings: scripts.get(script) });
|
||||
}
|
||||
|
||||
if (path === '/storage/kv/namespaces' && method === 'GET') {
|
||||
return ok([...kv].map(([title, id]) => ({ id, title })));
|
||||
}
|
||||
if (path === '/storage/kv/namespaces' && method === 'POST') {
|
||||
const id = `kvid-NEW-${created.kv.length + 1}`;
|
||||
kv.set(body.title, id);
|
||||
created.kv.push(body.title);
|
||||
return ok({ id, title: body.title });
|
||||
}
|
||||
if (path === '/d1/database' && method === 'GET') {
|
||||
return ok([...d1].map(([name, uuid]) => ({ uuid, name })));
|
||||
}
|
||||
if (path === '/d1/database' && method === 'POST') {
|
||||
const uuid = `d1id-NEW-${created.d1.length + 1}`;
|
||||
d1.set(body.name, uuid);
|
||||
created.d1.push(body.name);
|
||||
return ok({ uuid, name: body.name });
|
||||
}
|
||||
if (path === '/vectorize/v2/indexes' && method === 'GET') {
|
||||
return ok(vectorize.map((name) => ({ name })));
|
||||
}
|
||||
if (path === '/vectorize/v2/indexes' && method === 'POST') {
|
||||
vectorize.push(body.name);
|
||||
created.vectorize.push(body.name);
|
||||
return ok({ name: body.name });
|
||||
}
|
||||
|
||||
return fail(`fixture 沒有實作這條路徑:${method} ${path}`, 501);
|
||||
};
|
||||
|
||||
return {
|
||||
fetch: fakeFetch,
|
||||
created,
|
||||
userData,
|
||||
kvIdFor: (binding) => kvIdByBinding.get(binding),
|
||||
d1Id: D1_ID,
|
||||
requestLog,
|
||||
};
|
||||
}
|
||||
@@ -376,6 +376,23 @@
|
||||
真人用滑鼠點擊複製鈕(受限於自動化環境,上述已用既有鈕做過同構對照)。
|
||||
執行範圍:`console-ui/public/portal/index.html`(新增面板 + JS)。未動後端、未部署。
|
||||
|
||||
- [x] **Arcrun#108(任務層修正,2026-08-13):資料面租戶字串收斂到唯一產地**
|
||||
P3 的 `/portal/data/*` 一律用 `portalTenant(env) = env.CONSOLE_TENANT || 'leo'` 注 `owner_id`。
|
||||
那個字串是**部署環境變數**,而知識是 CLI/同步小幫手/MCP 用**實例 namespace**
|
||||
(`~/.arcrun/config.yaml` 的 `api_key`)寫進去的——兩個來源會漂。leo 實撞:
|
||||
藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組(他的在 `owner_id=bfezv28v`)。
|
||||
與 `Arcrun#105`(`env.MCP_OWNER_NAMESPACE || "leo"`)同形,低一層。
|
||||
**修法**:新增 `cypher-executor/src/lib/tenant.ts` 當唯一產地——
|
||||
`knowledgeOwner(env)` 回 branded `TenantId`(`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
|
||||
誠實丟錯,**無字面預設值**),資料面過濾一律經 `ownerQuery()/ownerField()`;
|
||||
帳號子 namespace(design D-2 的 `{tenant}::portal`)改用 `accountTenant(env)`(回 `string`,
|
||||
型別上不可能流進資料面),**帳號落點一字不動**(動了舊實例登不進去)。
|
||||
`acr update` 先驗(`GET /kbdb/map?owner_id=<api_key>` 查得到庫)才注入 `ARCRUN_NAMESPACE`。
|
||||
空地圖改回四態(`no_library_grant`/`filtered_out`/`scope_mismatch`/`confirmed_empty`),
|
||||
沿 Arcrun#100「讀不到就說讀不到」。庫權限過濾一字未動(回歸測試釘住)。
|
||||
防複發:`scripts/build-worker-artifacts.mjs` 出貨前掃描,違規編不出成品。
|
||||
規範寫入 `.claude/rules/02-forbidden.md` 第六類、`system-dev/wiki/mistakes.md` #26。
|
||||
|
||||
## 第二波(不在本 SDD 動工範圍,掛號)
|
||||
|
||||
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
||||
|
||||
@@ -547,6 +547,43 @@ repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。
|
||||
|
||||
---
|
||||
|
||||
## 26. 「身分來自環境變數」——同一句話寫錯兩次,因為規則有、機制沒有(2026-08-12,Arcrun#105/#108)
|
||||
|
||||
**症狀**:leo 打開藏書地圖回 **0 個庫**,同一分鐘 KBDB 裡有 **1854 條三元組**;
|
||||
`arcrun_whoami` 顯示 admin/全部知識庫,`kbdb_search` 也查得到東西——**只有地圖那格是空的**。
|
||||
|
||||
**根因**(不是資料掉了,是讀寫兩端各拿一個來源):
|
||||
|
||||
| | 寫入端用什麼當 owner_id | 讀取端用什麼過濾 |
|
||||
|---|---|---|
|
||||
| 之前 | `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,leo = `bfezv28v`) | `env.CONSOLE_TENANT \|\| "leo"`(repo toml 帶的**官方 prod 值**) |
|
||||
|
||||
`acr` 從來不注入 `CONSOLE_TENANT`,所以那個 `"leo"` 不是理論邊角,**是每台 self-hosted 實例的實際行為**。
|
||||
|
||||
**這是第二次**。`#105` 前一天才修掉 `env.MCP_OWNER_NAMESPACE || "leo"`——同一句話,換一個檔案。
|
||||
|
||||
**判準(下次照用)**:
|
||||
1. **「這個字串是用來決定誰的資料嗎?」** 是 → 它是身分,不是部署設定。
|
||||
身分要嘛來自請求(登入 session/`X-Arcrun-API-Key`),要嘛來自「寫入端用的那個值」,
|
||||
**不可以是一個各自抄一份的環境變數預設值**。
|
||||
2. **`|| '預設值'` 出現在身分解析路徑上=把「這台機器沒設定」偽裝成「你沒有資料」**。
|
||||
解析不到就誠實丟錯(#100 同一條:讀不到就說讀不到)。
|
||||
3. **「規則存在但沒有機制驗證」=它會再犯**。所以本次除了修 bug,還留下三道會擋的:
|
||||
- 型別閘:`TenantId` 只能由 `cypher-executor/src/lib/tenant.ts` 產出,
|
||||
資料面過濾只吃 `ownerQuery()/ownerField()` → 拿隨手一個 string 去過濾,`tsc` 當場不給過。
|
||||
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編成品前先跑租戶來源檢查
|
||||
→ **違規的碼編不出成品、出不了貨**(不是「有人記得跑才會發現」)。
|
||||
- 這道閘自己可測:規則是純函式(`cypher-executor/scripts/tenant-source-rules.mjs`),
|
||||
`tests/tenant-gate.test.ts` 逐條驗「壞例子會擋、11 種合法寫法零誤攔」。
|
||||
**誤攔比漏攔更容易殺死一道閘**——被擋煩了就有人把它關掉。
|
||||
4. **修法不能比 bug 更危險**:`acr update` 注入 `ARCRUN_NAMESPACE` 前**先驗**
|
||||
(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫)。無條件覆蓋會把「知識本來就在
|
||||
`CONSOLE_TENANT` 底下」的一鍵安裝實例指向空的那一格——那是 #97/#106 那類
|
||||
「更新一次把人家的東西弄不見」。
|
||||
|
||||
**順手挖出的同族**(同一道閘一次抓到):`console-dashboard.ts` 有 **4 處**、
|
||||
`console-auth.ts` 有 1 處相同寫法——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。
|
||||
|
||||
## 快速檢查清單(做新功能前)
|
||||
|
||||
- [ ] 這是工作流還是零件?問「有必要嗎?」
|
||||
@@ -568,3 +605,6 @@ repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。
|
||||
- [ ] 本地/Gitea 改完 code 想 `acr update` 部署?先確認:它抓的是 GitHub codeload tarball,不是你剛改的目錄(#23)
|
||||
- [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25)
|
||||
- [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25)
|
||||
- [ ] 寫下 `env.X || '預設值'`?先問「這個字串是用來決定誰的資料嗎?」是 → 它是身分不是設定,不准有字面預設值(#26)
|
||||
- [ ] 要用某個字串過濾 owner_id?確認它與**寫入端**用的是同一個來源,不是另一份手抄的環境變數(#26)
|
||||
- [ ] 留了一道新的閘?它自己有測試嗎、誤攔案例驗過嗎、擋不擋得到自己?(#26)
|
||||
|
||||
Reference in New Issue
Block a user