Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c497ec418e | |||
| 6985bf4850 | |||
| 8e10f1d83e | |||
| 3eb8b31f2b | |||
| 8cee9c9f76 | |||
| 7e3ca4c1a1 | |||
| af3edff856 | |||
| c5d696556e | |||
| 5919c6b90f | |||
| 525faaf5d0 | |||
| 37e13fc9bf | |||
| 674e1b4fa2 | |||
| b6ef0f07dc | |||
| 1d6dde4a01 | |||
| 507620e313 | |||
| 1e94f8451e | |||
| dcb6ad693b | |||
| 3b0238bb28 | |||
| 788295ed71 | |||
| 797e7f751c | |||
| d1c44a5878 | |||
| a7e23badf2 | |||
| eebb691426 | |||
| c76e10d314 | |||
| 9a29eb5af6 | |||
| 8d49d883c0 | |||
| 417d69ceb3 | |||
| 035e8b255b | |||
| 1c630ecfd4 | |||
| a99d3e5a3e | |||
| 894408181f | |||
| d022ca067b |
@@ -6,6 +6,10 @@ dist/
|
||||
# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署
|
||||
# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6)
|
||||
!.component-builds/**/component.wasm
|
||||
# 例外:Arcrun#80 tier2 worker 官方編譯成品(cypher-executor/kbdb/http_request/code/mcp 的
|
||||
# esbuild bundle + 隨附 wasm part)——commit 進 repo 同一套理由:固定位置、any clone 都拿得到,
|
||||
# 不必自己再編一次(見 scripts/build-worker-artifacts.mjs)。
|
||||
!.worker-builds/**/*.wasm
|
||||
# 例外:code 零件(自足 Worker)的 vendored quickjs.wasm 同屬部署物 —— acr init/update 從
|
||||
# repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件
|
||||
# @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-12T01:50:15.065Z",
|
||||
"repo_head": "3eb8b31f2bfa029e15a8119e229082fbafb8d2b1",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 570290,
|
||||
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"EXEC_CONTEXT",
|
||||
"WEBHOOKS",
|
||||
"CREDENTIALS_KV",
|
||||
"ANALYTICS_KV",
|
||||
"RECIPES",
|
||||
"USERS_KV",
|
||||
"SESSIONS_KV"
|
||||
],
|
||||
"d1": [
|
||||
{
|
||||
"binding": "CREDENTIALS_DB",
|
||||
"database_name": "arcrun-kbdb"
|
||||
}
|
||||
],
|
||||
"vectorize": 0,
|
||||
"ai": true,
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production",
|
||||
"CF_ACCOUNT_ID": "",
|
||||
"WORKER_SUBDOMAIN": "uncle6-me",
|
||||
"KBDB_BASE_URL": "https://arcrun-kbdb.uncle6-me.workers.dev",
|
||||
"CONSOLE_TENANT": "leo",
|
||||
"PORTAL_SESSION_TTL": "604800",
|
||||
"PORTAL_SHOW_WORKFLOWS": "admin",
|
||||
"GITEA_BASE_URL": "https://git.uncle6.me",
|
||||
"GITEA_SPRINT_REPO": "Leo/InkStoneCo",
|
||||
"GITEA_SPRINT_DIR": "system-dev/docs/3-specs/autonomy-dispatch"
|
||||
}
|
||||
},
|
||||
"stripped": {
|
||||
"services": 13
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "3eb8b31f2bfa029e15a8119e229082fbafb8d2b1",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 149234,
|
||||
"content_sha256": "7bc666568f453a2fc5fc9339fc997c12d3927fb8a293439f2c80695be8da9767",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "arcrun-kbdb"
|
||||
}
|
||||
],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-http-request",
|
||||
"source_dir": ".component-builds/http_request",
|
||||
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-http-request/worker.mjs",
|
||||
"js_bytes": 80079,
|
||||
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-http-request/component.wasm",
|
||||
"sha256": "cc15cc785703e7bbb8dbff2d38dc84a4ac24e2f44316182730abae0f170ef133"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "http_request"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1165388,
|
||||
"content_sha256": "c5ff10f9b9d5a77217be343af12d2be3ee8f9792d3e1e091e48e5e6c8d24ca9d",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"notes": []
|
||||
}
|
||||
@@ -122,6 +122,12 @@
|
||||
.modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
|
||||
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
|
||||
.modebtn.hide { display: none; }
|
||||
/* 🔴 2026-08-10 實測撞到:`.hide` 一直**只有上面那三條有 scope 的規則**(#tabbar .tab / #sidenav .nav / .modebtn),
|
||||
所以任何其他元素掛上 class="hide" 都**完全沒有被藏起來**——看起來有藏、其實沒藏。
|
||||
D62 的「忘記密碼」欄位與「現有密碼」欄位就是這樣在瀏覽器上原形畢露的。
|
||||
補一條通用規則;帶 !important 是因為要蓋過 inline 的 display(#forgot-box 就有)。
|
||||
既有那三種用法的意圖與本規則一致(都是「藏起來」),加這條不改變它們的行為。 */
|
||||
.hide { display: none !important; }
|
||||
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
|
||||
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
|
||||
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
|
||||
@@ -219,12 +225,42 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
|
||||
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
|
||||
<div style="font-size:13px;color:rgba(var(--ink-rgb),.4);line-height:1.7">你自己就是管理員?<a href="/console/" style="color:var(--amber)">用管理主控台密碼救援自己</a></div>
|
||||
<!-- 🔴 D50/D62:這裡原本寫「忘記密碼請聯絡管理員重設」+一條「用管理主控台密碼救援自己」
|
||||
的 console 連結。**兩條都是已否決的做法**,不准寫回來:
|
||||
① leo:「我要是記得密碼,還來找忘記密碼幹嘛?」——console 登入頁一樣要密碼
|
||||
② console 與 portal 是安裝時**同一組帳密**寫進兩個地方(D50 補刀)
|
||||
⇒ console 不是第二道閘,是同一把鎖的第二個鑰匙孔,往它補洞不會多出任何一條路
|
||||
③ 唯一管理員忘記密碼時「聯絡管理員」=叫他聯絡他自己
|
||||
leo 2026-08-10:「**是對 portal 不是對 console**,這樣 youlin 雖然忘記,
|
||||
我還是可以去 portal 忘記密碼。」⇒ 出口就在這裡。 -->
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">
|
||||
忘記密碼了?<a href="#" id="login-forgot" style="color:var(--amber)">寄一條「修改密碼」連結給我</a>
|
||||
</div>
|
||||
<div id="forgot-box" class="hide" style="display:flex;flex-direction:column;gap:10px;margin-top:4px">
|
||||
<input type="email" id="forgot-email" class="txt" placeholder="你註冊時用的 Email" autocomplete="username">
|
||||
<button class="btn" id="forgot-submit">寄出連結</button>
|
||||
<div id="forgot-status" style="font-size:13.5px;min-height:1.2em;line-height:1.6"></div>
|
||||
</div>
|
||||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密碼(D62):忘記密碼點信裡的連結進來的落點。
|
||||
🔴 這個殼**刻意沒有自己的密碼欄位**——真正的表單是設定頁那一份 #pw-form,
|
||||
進到這個模式時被 JS 原封不動搬進 #reset-slot。D62:「同一個畫面、同一條寫入路徑,
|
||||
差別只有一格:現有密碼是要填、還是被連結豁免。」複製一份長得像的就會漂移。 -->
|
||||
<div class="authwrap view" id="v-reset">
|
||||
<div class="authbox">
|
||||
<div>
|
||||
<div style="font-size:22px;font-weight:600;margin-bottom:6px">設定新密碼</div>
|
||||
<div id="reset-who" style="font-size:14px;color:rgba(var(--ink-rgb),.55);line-height:1.7;margin-bottom:14px"></div>
|
||||
<div id="reset-slot"></div>
|
||||
<div id="reset-error" class="err" style="font-size:14px;line-height:1.7"></div>
|
||||
<div style="margin-top:16px;font-size:13.5px"><a href="#/" id="reset-back" style="color:var(--amber)">回登入頁</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首次設定(t49,leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
|
||||
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
|
||||
<div class="authwrap view" id="v-firstsetup">
|
||||
@@ -375,10 +411,14 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">更改密碼</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<!-- D62(leo 2026-08-10):「改密碼與忘記密碼**這兩個機制其實是一個機制,可以簡化**。」
|
||||
⇒ 這個表單是**整個站唯一一份**改密碼的表單。忘記密碼點連結進來時,
|
||||
它會被原封不動搬到 #v-reset 那個殼裡(不是複製一份長得像的)——
|
||||
差別只有「現有密碼」那一格顯不顯示。同一個畫面、同一條寫入路徑。 -->
|
||||
<div class="panel" id="st-pw-panel">
|
||||
<div style="font-size:17px;font-weight:600" id="pw-title">更改密碼</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)" id="pw-sub">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||
<div id="pw-form" style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
|
||||
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
|
||||
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
|
||||
@@ -746,6 +786,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
|
||||
function route() {
|
||||
// 🔴 D62:`#/reset?token=…` **不是站內的一個 view**,是「還沒登入的人拿著連結進來」。
|
||||
// 少了這一段,下面那行正規化會把不認得的 `reset` 換成 HOME ⇒ **連 token 一起丟掉**,
|
||||
// 使用者點信裡的連結會落在搜尋頁、而且再也回不去(連結一次有效,已經耗掉了)。
|
||||
// 實測(2026-08-10 stage,瀏覽器):網址被改寫成 `#/search`、修改密碼畫面根本沒出現。
|
||||
var rt = resetTokenFromUrl();
|
||||
if (rt) { showReset(rt); return; }
|
||||
var r = currentRoute();
|
||||
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
|
||||
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
|
||||
@@ -817,11 +863,28 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
} catch (e) { /* 登入殼還沒渲染就算了 */ }
|
||||
}
|
||||
function boot() {
|
||||
// D62:信裡的「修改密碼」連結最優先——這個人**就是進不去才收到這封信的**,
|
||||
// 不可以因為瀏覽器裡剛好還留著一個舊 token 就把他丟去別的畫面。
|
||||
var rt = (typeof resetTokenFromUrl === 'function') ? resetTokenFromUrl() : '';
|
||||
if (rt) { showReset(rt); return; }
|
||||
if (!S.token) { showAuth(); return; }
|
||||
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { dropSession(); return; }
|
||||
// 🔴 arcrun-rag#66:**只有 401 才算「你被登出了」**。
|
||||
// 舊版是 `if (!x.ok) dropSession()` = 任何非 2xx 都清掉 token——
|
||||
// 包含改完密碼後那幾十秒的 503(認證 secret 正在鋪開)與 502(KBDB 暫時不可達)。
|
||||
// 那正是 leo 08-10「改完密碼、重新整理就回不去」的最後一哩:
|
||||
// 後端就算不刪 KV,前端自己把鑰匙丟了,結果一樣。
|
||||
if (x.status === 401) { dropSession(); return; }
|
||||
if (!x.ok) {
|
||||
// 暫時性故障:留著 session,告訴他這是暫時的、下一步做什麼。
|
||||
showAuth();
|
||||
$('login-status').textContent = (x.d && x.d.code === 'auth_store_propagating')
|
||||
? '認證資料正在更新中(通常幾十秒),請稍候重新整理——你並沒有被登出。'
|
||||
: '服務暫時不可用,請稍後重新整理(你的登入沒有失效)。';
|
||||
return;
|
||||
}
|
||||
S.profile = x.d;
|
||||
showApp();
|
||||
})
|
||||
@@ -1185,6 +1248,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
|
||||
// 任何 data 請求收到 401 → session 失效 → 回登入殼
|
||||
//
|
||||
// 🔴 arcrun-rag#66 的前端那一半:後端在「認證資料正在鋪開」時改回 503
|
||||
// `auth_store_propagating`(不再回 401、也不再刪 KV 那筆 session)。
|
||||
// 這裡**必須跟著只認 401**——若前端把任何錯誤都當登出,後端不刪也沒用:
|
||||
// 使用者手上的 token 會被自己的瀏覽器丟掉,症狀跟被踢出去一模一樣。
|
||||
// (503 由各呼叫點自己顯示錯誤訊息,session 原封不動。)
|
||||
function guard401(status) {
|
||||
if (status === 401) { dropSession(); return true; }
|
||||
return false;
|
||||
@@ -2022,33 +2091,147 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
|
||||
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
|
||||
}
|
||||
$('st-pw-save').addEventListener('click', function () {
|
||||
// ══════════ D62:改密碼與忘記密碼=同一個機制 ══════════
|
||||
//
|
||||
// leo 2026-08-10:「這兩個機制其實是一個機制,可以簡化。」
|
||||
// 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋
|
||||
// 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
|
||||
// ⇒ 底下只有**一個表單元素**(#pw-form)與**一支送出函式**,差別只有「現有密碼」那一格
|
||||
// 顯不顯示、以及帶 current 還是帶 reset_token。兩份長得像的東西必然漂移
|
||||
// (arcrun-rag#40「同一個事實兩份」)。
|
||||
// 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。
|
||||
|
||||
var S_RESET_TOKEN = ''; // 非空=現在是「忘記密碼」模式
|
||||
|
||||
/** 把那一份唯一的表單搬到指定容器,並決定「現有密碼」那一格出不出現。 */
|
||||
function mountPasswordForm(host, needCurrent) {
|
||||
var form = $('pw-form');
|
||||
if (form && form.parentNode !== host) host.appendChild(form);
|
||||
$('st-pw-old').classList.toggle('hide', !needCurrent);
|
||||
$('st-pw-save').textContent = needCurrent ? '更新密碼' : '設定新密碼';
|
||||
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||||
$('st-pw-status').textContent = '';
|
||||
}
|
||||
|
||||
/** 送出:兩種模式共用。帶 reset_token 就走連結那一格,否則走現有密碼那一格。 */
|
||||
function submitPasswordChange() {
|
||||
var needCurrent = !S_RESET_TOKEN;
|
||||
var oldPw = $('st-pw-old').value;
|
||||
var newPw = $('st-pw-new').value;
|
||||
var newPw2 = $('st-pw-new2').value;
|
||||
var st = $('st-pw-status');
|
||||
st.className = 'err';
|
||||
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
|
||||
if (needCurrent && !oldPw) { st.textContent = '請填現在的密碼'; return; }
|
||||
if (!newPw) { st.textContent = '請填新密碼'; return; }
|
||||
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
|
||||
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
|
||||
var body = { 'new': newPw };
|
||||
if (S_RESET_TOKEN) body.reset_token = S_RESET_TOKEN; else body.current = oldPw;
|
||||
$('st-pw-save').disabled = true;
|
||||
st.textContent = '';
|
||||
fetch(API_BASE + '/portal/me/password', {
|
||||
st.textContent = '處理中…';
|
||||
fetch(API_BASE + '/portal/password/change', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify({ current: oldPw, 'new': newPw })
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, needCurrent ? authHeaders() : {}),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$('st-pw-save').disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
|
||||
// 忘記密碼模式沒有 session,401 不代表「你被登出」——別誤踢
|
||||
if (needCurrent && guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = (x.d && x.d.error) || '更新失敗'; return; }
|
||||
st.className = 'ok';
|
||||
st.textContent = '密碼已更新';
|
||||
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||||
toast('密碼已更新');
|
||||
if (S_RESET_TOKEN) {
|
||||
st.textContent = '密碼已設定好了,正在帶你回登入頁…';
|
||||
var who = (x.d && x.d.email) || '';
|
||||
S_RESET_TOKEN = '';
|
||||
setTimeout(function () {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
location.hash = '';
|
||||
$('v-reset').classList.remove('on');
|
||||
mountPasswordForm($('st-pw-panel'), true);
|
||||
showAuth();
|
||||
if (who) $('login-email').value = who;
|
||||
$('login-status').className = 'ok';
|
||||
$('login-status').textContent = '新密碼已生效,請用它登入。';
|
||||
}, 1200);
|
||||
} else {
|
||||
st.textContent = '密碼已更新(你不會被登出,可以繼續操作)';
|
||||
toast('密碼已更新');
|
||||
}
|
||||
})
|
||||
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
|
||||
}
|
||||
$('st-pw-save').addEventListener('click', submitPasswordChange);
|
||||
|
||||
// ── 登入頁的「忘記密碼」入口(**在 portal,不是 console**,leo 2026-08-10)──
|
||||
$('login-forgot').addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
$('forgot-box').classList.remove('hide');
|
||||
$('forgot-email').value = $('login-email').value.trim();
|
||||
$('forgot-email').focus();
|
||||
});
|
||||
$('forgot-submit').addEventListener('click', function () {
|
||||
var email = $('forgot-email').value.trim();
|
||||
var st = $('forgot-status');
|
||||
st.className = 'err';
|
||||
if (!email) { st.textContent = '請填 Email'; return; }
|
||||
$('forgot-submit').disabled = true;
|
||||
st.textContent = '寄送中…';
|
||||
fetch(API_BASE + '/portal/password/forgot', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$('forgot-submit').disabled = false;
|
||||
if (!x.ok) { st.textContent = (x.d && x.d.error) || '寄送失敗'; return; }
|
||||
st.className = 'ok';
|
||||
st.textContent = (x.d && x.d.message) || '如果這個 email 有帳號,連結已經寄出了。';
|
||||
})
|
||||
.catch(function (e) { $('forgot-submit').disabled = false; st.textContent = friendlyErr(e); });
|
||||
});
|
||||
|
||||
/** 點信裡的連結進來:`…/portal/#/reset?token=…`(cypher 的 /portal/password/reset-link 導過來的)。 */
|
||||
function resetTokenFromUrl() {
|
||||
var m = String(location.hash || '').match(/[?&]token=([^&]+)/);
|
||||
if (m) return decodeURIComponent(m[1]);
|
||||
m = String(location.search || '').match(/[?&]token=([^&]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
function showReset(token) {
|
||||
$('v-login').classList.remove('on');
|
||||
$('v-firstsetup').classList.remove('on');
|
||||
$('shell').classList.remove('on');
|
||||
$('tabbar').classList.remove('on');
|
||||
$('v-reset').classList.add('on');
|
||||
$('reset-error').textContent = '';
|
||||
// 先問一次「這張票還有效嗎」——連結是一次有效、30 分鐘過期的,
|
||||
// 過期就要當場說清楚,不要讓人填完密碼才被打回票(#49:別把系統狀態講成用戶的錯)。
|
||||
fetch(API_BASE + '/portal/password/reset?token=' + encodeURIComponent(token))
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok || !x.d || x.d.valid !== true) {
|
||||
$('reset-who').textContent = '';
|
||||
$('reset-error').textContent = (x.d && x.d.error) || '這條連結已經失效了。請回登入頁重新按一次「忘記密碼」。';
|
||||
return;
|
||||
}
|
||||
S_RESET_TOKEN = token;
|
||||
$('reset-who').innerHTML = '你正在為 <strong>' + esc(x.d.email) + '</strong> 設定新密碼。<br>這條連結只能用一次,<strong>不需要輸入現在的密碼</strong>。';
|
||||
mountPasswordForm($('reset-slot'), false); // ← 差別只有這一格
|
||||
})
|
||||
.catch(function (e) { $('reset-error').textContent = friendlyErr(e); });
|
||||
}
|
||||
$('reset-back').addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
S_RESET_TOKEN = '';
|
||||
history.replaceState(null, '', location.pathname);
|
||||
location.hash = '';
|
||||
$('v-reset').classList.remove('on');
|
||||
mountPasswordForm($('st-pw-panel'), true);
|
||||
showAuth();
|
||||
});
|
||||
|
||||
boot();
|
||||
|
||||
@@ -1,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)→ 退回逐顆查(相容路徑)。
|
||||
|
||||
@@ -88,6 +88,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> => {
|
||||
|
||||
|
||||
@@ -143,7 +143,18 @@ export function authStoreWritable(env: Bindings): boolean {
|
||||
*/
|
||||
export function readAuthStore(env: Bindings): AuthStoreData {
|
||||
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
||||
return readAuthStoreFromEnv(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只讀 `env` 那一版(**跳過 overlay**)。
|
||||
*
|
||||
* 為什麼要分出這一支(#66 修補的一半):read-modify-write 時,overlay 與 env 兩份都可能
|
||||
* 各自「有對方沒有的帳號」——overlay 可能來自加速器(別台 isolate 剛寫的),
|
||||
* env 可能是**比加速器更新**的一版(加速器過期、或這顆 isolate 已經吃到新版本)。
|
||||
* 只採信其中一份就會把另一份獨有的帳號寫掉,而 secret 是唯一真相源 ⇒ **永久消失**。
|
||||
*/
|
||||
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
|
||||
const bag = env as unknown as Record<string, unknown>;
|
||||
const out = emptyStore();
|
||||
for (const name of shardNames(env)) {
|
||||
@@ -268,13 +279,50 @@ export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */
|
||||
/**
|
||||
* 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
|
||||
*
|
||||
* 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事:
|
||||
* - 「查不到這個帳號」= 帳號真的被刪了 → 該擋(401)
|
||||
* - 「查不到這個帳號」= secret 新版本還沒鋪到這顆 isolate → **不該擋,更不該刪 session**
|
||||
* 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`,它存在就代表「最近有人動過認證儲存」。
|
||||
* 讀不到(KV 掛了/沒設)⇒ 回 false,退回舊行為,不會比現在更糟。
|
||||
*/
|
||||
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
|
||||
try {
|
||||
return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 兩份 store 取聯集:同一個 id 以 `updated_at` 新者為準;只在一邊出現的一律保留。 */
|
||||
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
|
||||
const byId = new Map<string, AuthUserRecord>();
|
||||
for (const u of [...a.users, ...b.users]) {
|
||||
const prev = byId.get(u.id);
|
||||
if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
|
||||
}
|
||||
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。
|
||||
*
|
||||
* 🔴 #66:**改之前先把手上這份補齊**。舊版直接 `readAuthStore(env)` 當底稿,而 `writeAuthStore`
|
||||
* 會把整份重切分片並刪掉多出來的舊分片 ⇒ 若底稿是「某個帳號被建立之前」的版本,
|
||||
* 那個帳號會在這次寫入中**被抹掉,且再也回不來**(secret 是唯一真相源,沒有第二份可還原)。
|
||||
* 這正是「改一次密碼=有人被鎖在門外」的另一半病因。
|
||||
*
|
||||
* 補法:先問一次加速器,再把 env 版與 overlay 版**取聯集**當底稿——
|
||||
* 兩邊獨有的帳號都留下來;刪除仍然有效,因為 `fn()` 是在聯集**之後**才跑。
|
||||
*/
|
||||
export async function mutateAuthStore(
|
||||
env: Bindings,
|
||||
fn: (data: AuthStoreData) => void | Promise<void>,
|
||||
): Promise<AuthStoreData> {
|
||||
const data = readAuthStore(env);
|
||||
const next: AuthStoreData = { version: 1, console: data.console, users: [...data.users] };
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
return next;
|
||||
|
||||
@@ -98,6 +98,20 @@ export function randomHex(bytes: number): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV key(D62)。
|
||||
*
|
||||
* 為什麼不直接拿 token 當 key:連結裡的 token 是**能改密碼的憑據**,直接當 key 等於
|
||||
* 把它明碼存在 KV 裡;改存雜湊後,看得到 KV 的人也拿不到可用的連結。
|
||||
* (這裡只做 digest——不是 rule 2.2 禁的 `crypto.subtle.decrypt` / RSASSA 簽章。)
|
||||
*/
|
||||
export async function sha256Hex(input: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
|
||||
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
||||
|
||||
@@ -21,6 +21,12 @@ healthRouter.get('/health', (c) => {
|
||||
ok: true,
|
||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||
auth_store: authStoreStatus(c.env),
|
||||
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||
// 版本無關(同一個 cypher 版本,有的實例有這個 var、有的沒有)。純比版本號的話,
|
||||
// 已經在最新版的實例(如 leo 自己那台)永遠不會因為「按更新」而重推,這個 var
|
||||
// 就永遠補不進去。只回布林(有沒有設,不回值本身)——不洩漏郵差網址。
|
||||
mail_relay_configured: Boolean(String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -119,6 +119,27 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// PATCH /kbdb/records/:recordId — 翻某筆 record 的 slot 值({ values:{slot:content} })。
|
||||
// 補上基本盤既有能力(kbdb/src/routes/records.ts 的 PATCH /records/:recordId,mira-dissolve T2.1)
|
||||
// 缺的對外通道——2026-08-11 leo 三元組 library 補標核實:base 早有這個端點,但這條 proxy
|
||||
// 之前只轉發 GET/POST,插件/工作流打不到,補標三元組只能繞去改表(違 D38)。單純轉發,無業務邏輯。
|
||||
// by-id 沿用既有慣例(require-key,不額外做 owner 比對——與本檔 GET .../:recordId、
|
||||
// PATCH /kbdb/entries/:id 同款)。
|
||||
kbdbProxyRouter.patch('/kbdb/records/:recordId', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.values !== 'object' || body.values === null) {
|
||||
return c.json({ error: 'values 必填({slot名: 內容})' }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ values: body.values }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
|
||||
|
||||
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
|
||||
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
@@ -33,6 +33,7 @@ import { storeCredential, hasCredential } from './credentials';
|
||||
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreRecentlyWritten,
|
||||
authStoreStatus,
|
||||
findAuthUserByEmail,
|
||||
findAuthUserById,
|
||||
@@ -413,6 +414,23 @@ export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Resp
|
||||
/**
|
||||
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
|
||||
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。
|
||||
*
|
||||
* 🔴 `Leo/arcrun-rag#66`(2026-08-10,leo 本人被鎖在 stage 外面的那條):
|
||||
* **「這一瞬間讀不到」不可以觸發不可逆的動作。**
|
||||
* 認證的家是 CF Workers Secret,改它(改密碼/建帳號/停用)會產生 worker 新版本,
|
||||
* **既有 isolate 讀到的還是舊 env**(`#55` 實測 ≥15 秒)。舊版在那個空窗裡:
|
||||
* ① `getRecordById` 讀不到 → ② **直接把 session 從 KV 刪掉** → ③ 回 401
|
||||
* ⇒ 帶著一個**完全有效的 token**,登入狀態被當場銷毀,等 secret 鋪開也回不來。
|
||||
* `#55` 補的「讀不到就再問一次加速器」只加在登入路徑(`findAndVerifyUser`),這道門沒有。
|
||||
*
|
||||
* 三段修法(缺一不可):
|
||||
* 1. **先問一次加速器再判定**——與登入路徑同一招,同一支 `hydrateFromAccelerator`。
|
||||
* 2. **永不因「讀不到」刪 session**。刪是 best-effort 清潔工,而它清掉的是使用者唯一的
|
||||
* 憑據;KV 的 TTL 本來就會回收,這件事沒有非做不可的理由。
|
||||
* 3. 仍然讀不到且**正在傳播空窗**(加速器 key 還在)→ 回 **503 `auth_store_propagating`**,
|
||||
* 不是 401。理由在前端:portal 的 `guard401()` 一看到 401 就清 localStorage 踢回登入頁
|
||||
* ⇒ 就算 KV 那筆還在,使用者手上的 token 也被自己的瀏覽器丟掉了。
|
||||
* **後端不刪、前端不丟,這件事才算真的修好。**
|
||||
*/
|
||||
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
|
||||
const token = bearerToken(c);
|
||||
@@ -426,15 +444,33 @@ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Pro
|
||||
/* fallthrough */
|
||||
}
|
||||
if (!recordId) {
|
||||
// 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
||||
}
|
||||
const rec = await getRecordById(c.env, recordId);
|
||||
let rec = await getRecordById(c.env, recordId);
|
||||
if (!rec && (await hydrateFromAccelerator(c.env))) {
|
||||
rec = await getRecordById(c.env, recordId); // ①:與登入路徑同一招,再問一次加速器
|
||||
}
|
||||
if (!rec) {
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
// ③:分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。
|
||||
if (await authStoreRecentlyWritten(c.env)) {
|
||||
return {
|
||||
ok: false,
|
||||
res: c.json(
|
||||
{
|
||||
error: '認證資料正在更新中(Cloudflare 正在鋪開新版本),請稍候幾秒再試——你並沒有被登出。',
|
||||
code: 'auth_store_propagating',
|
||||
},
|
||||
503,
|
||||
),
|
||||
};
|
||||
}
|
||||
// ②:查無此帳號(可能真的被刪了)→ 擋下即可,**不刪 session**(KV TTL 自己會回收)。
|
||||
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
||||
}
|
||||
if ((rec.values.status ?? '') !== 'active') {
|
||||
// 讀得到 record = 確定的事實,停用要即時生效,刪 session 是對的。
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
|
||||
}
|
||||
@@ -679,29 +715,310 @@ portalRouter.get('/portal/session', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/me/password — body {current, new}。驗舊密改新密。
|
||||
portalRouter.post('/portal/me/password', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const current = String(body?.current ?? '');
|
||||
const next = String(body?.new ?? '');
|
||||
if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400);
|
||||
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
||||
// ═════════════════ D62:改密碼與忘記密碼是**同一個機制** ═══════════════════════
|
||||
//
|
||||
// leo 2026-08-10 拍板(頂層 decisions-summary D62):
|
||||
// 「這兩個機制其實是一個機制,可以簡化。」
|
||||
// - 修改密碼:到「修改密碼」→ 輸入**現有的** → 輸入新的 → 覆蓋現有的
|
||||
// - 忘記密碼:寄給你**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
|
||||
// ⇒ **同一個畫面、同一條寫入路徑,差別只有一格**:「現有密碼」是要填、還是被連結豁免。
|
||||
//
|
||||
// 🔴 **不做一次性密碼**(leo:「不要發一次性密碼太麻煩」)——這修正了 D50 的「一次性驗證碼」,
|
||||
// 形態改成連結;D50 其餘部分(console 退場、不准沿用註冊辨識碼)不變。
|
||||
// 🔴 連結的安全性(承 D50 的理由,不可退讓):
|
||||
// ① **一次有效**——用掉就從 KV 刪除(本檔 consumeResetToken)
|
||||
// ② **會過期**——KV expirationTtl 30 分鐘,過了就是不存在
|
||||
// ③ **與註冊辨識碼不同源**——現場 crypto 亂數產生、只活在這台實例的 SESSIONS_KV,
|
||||
// 跟 landing `SIGNUPS` 那組安裝辨識碼沒有任何關係。
|
||||
// D50 否決固定辨識碼的理由正是「綁定不會變 ⇒ 等於不會過期的鑰匙」。
|
||||
// 🔴 已否決、不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。
|
||||
// 🔴 **入口在 portal,不是 console**(leo 2026-08-10:「是對 portal 不是對 console,
|
||||
// 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」)——console 與 portal 是
|
||||
// 安裝時同一組帳密寫進兩個地方(D50 補刀),往 console 補洞不會多出任何一條路。
|
||||
|
||||
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
|
||||
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
|
||||
/** 「修改密碼」連結 token 的 KV key 前綴(存的是 token 的 sha256,不是 token 本身)。 */
|
||||
const PWRESET_PREFIX = 'portal_pwreset:';
|
||||
/** 連結有效期:30 分鐘(安全要求②「會過期」)。 */
|
||||
const PWRESET_TTL_SECONDS = 30 * 60;
|
||||
/** 同一個 email 的請求節流 key 前綴+冷卻秒數(避免被拿來灌信)。 */
|
||||
const PWRESET_THROTTLE_PREFIX = 'portal_pwreset_req:';
|
||||
const PWRESET_THROTTLE_SECONDS = 120;
|
||||
/** 代寄回呼票(讓郵差可以回頭問「這封真的是你要我寄的嗎」)的 key 前綴與存活秒數。 */
|
||||
const RELAY_TICKET_PREFIX = 'portal_relay_ticket:';
|
||||
const RELAY_TICKET_TTL_SECONDS = 120;
|
||||
|
||||
const newHash = await hashPassword(next);
|
||||
await patchRecordValues(c.env, auth.user.recordId, {
|
||||
password_hash: newHash,
|
||||
updated_at: new Date().toISOString(),
|
||||
interface ResetTokenPayload {
|
||||
record_id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* portal 前端(使用者會看到的那個網址)的 origin。
|
||||
* 與 index.ts 的 CORS 白名單**同一套推導**:UI_ORIGINS 優先,否則用 workers.dev 兄弟位址。
|
||||
* (2026-08-08 事故的教訓:能推導出來的東西就不要再多一個「必須被注入、漏了看不出來」的變數。)
|
||||
*/
|
||||
function portalUiOrigin(env: Bindings): string | null {
|
||||
const declared = String(env.UI_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (declared.length > 0) return declared[0];
|
||||
const sub = String(env.WORKER_SUBDOMAIN ?? '').trim();
|
||||
return sub ? `https://arcrun-rag-ui.${sub}.workers.dev` : null;
|
||||
}
|
||||
|
||||
/** 發一張「修改密碼」連結票,回傳要放進連結的 token(明碼只在這一刻存在)。 */
|
||||
async function issueResetToken(env: Bindings, recordId: string, email: string): Promise<string> {
|
||||
const token = randomHex(32);
|
||||
const payload: ResetTokenPayload = { record_id: recordId, email, created_at: new Date().toISOString() };
|
||||
await env.SESSIONS_KV.put(`${PWRESET_PREFIX}${await sha256Hex(token)}`, JSON.stringify(payload), {
|
||||
expirationTtl: PWRESET_TTL_SECONDS,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 看一眼票是否有效(**不消耗**)——給「點進連結先渲染畫面」用。 */
|
||||
async function peekResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
|
||||
if (!token || !/^[0-9a-f]{16,128}$/i.test(token)) return null;
|
||||
const raw = await env.SESSIONS_KV.get(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as ResetTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用掉一張票(安全要求①「一次有效」):**先刪再回傳**。
|
||||
* 順序是刻意的——先刪掉才回,兩個人同時點同一條連結時最多只有一個拿得到。
|
||||
*/
|
||||
async function consumeResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
|
||||
const payload = await peekResetToken(env, token);
|
||||
if (!payload) return null;
|
||||
await env.SESSIONS_KV.delete(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* **唯一的密碼寫入路徑**(D62「同一條寫入路徑」的落地點)。
|
||||
* 修改密碼與忘記密碼都只能從這裡覆蓋密碼——不再有第二支自己 hash 自己 patch 的路。
|
||||
*/
|
||||
async function writeNewPassword(env: Bindings, recordId: string, newPassword: string): Promise<void> {
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await patchRecordValues(env, recordId, {
|
||||
password_hash: newHash,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 請中央 landing 代寄「修改密碼」連結。
|
||||
*
|
||||
* 🔴 **為什麼要代寄**(leo 2026-08-10 給的職責切法):
|
||||
* 「其實這應該是 youlin 的實例告訴 arcrun.dev 說『我實例的重設密碼網址是 abc.recover,
|
||||
* 你幫我寄信給用戶讓他來修改密碼』,由 arcrun.dev 幫它寄出這封信。」
|
||||
* ⇒ **實例**產生連結、管一次有效/過期/作廢;**arcrun.dev 只是郵差**,不碰任何認證邏輯。
|
||||
* 必須這樣切的硬理由:用戶自己的實例**根本沒有寄信能力**——安裝器部署 cypher 的 binding
|
||||
* 只有 ai/d1/kv_namespace/plain_text/secret_text/service/vectorize,**沒有 send_email**。
|
||||
* ⚠️ 「由我們中央代寄」是依 leo「寄給你」推導的**假設**,他尚未正式表態(D62 明列為未裁前置)。
|
||||
*
|
||||
* 🔴 **絕不把整條 URL 交給郵差**(總管 2026-08-10 紅線):寄件網域 `arcrun.dev` 掛在 uncle6、
|
||||
* 帶 DKIM。郵差若肯收「任意 URL + 任意 email」就寄,任何人裝一台實例就能用 `arcrun.dev`
|
||||
* 的名義、**通過 DKIM 驗證**把任意連結寄給任意人 ⇒ 一台開放的釣魚中繼,
|
||||
* 燒的是整個網域的信譽、波及所有用戶、**不可逆**。
|
||||
*
|
||||
* 做法:我們只交出**這台實例自己的 origin + 一張回呼票**。連結由郵差自己組,而且郵差會
|
||||
* **回頭打這個 origin** 問「這張票是你發的嗎」(見 /portal/password/relay-verify)。
|
||||
* 冒用別人的網域會被那台實例自己否認 ⇒ **主機屬於呼叫方這件事由郵差親自確認,
|
||||
* 不是相信呼叫方的宣稱**。
|
||||
*
|
||||
* `apiOrigin` 取自**這次請求真正抵達的位址**(`new URL(c.req.url).origin`),不新增任何
|
||||
* 需要被注入的變數——2026-08-08 兩次事故的教訓:能推導的就不要再多一個會被漏掉的設定。
|
||||
*
|
||||
* **不假綠**(mindset §7):寄不出去就回 'failed' / 'not_configured'。
|
||||
*/
|
||||
async function relayResetLink(
|
||||
env: Bindings,
|
||||
apiOrigin: string,
|
||||
email: string,
|
||||
ticket: string,
|
||||
): Promise<'sent' | 'not_configured' | 'failed'> {
|
||||
const base = String(env.PORTAL_MAIL_RELAY_BASE ?? '').trim().replace(/\/$/, '');
|
||||
if (!base) return 'not_configured';
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.PORTAL_MAIL_RELAY_KEY) headers['X-Arcrun-Relay-Key'] = env.PORTAL_MAIL_RELAY_KEY;
|
||||
try {
|
||||
const res = await fetch(`${base}/api/send-password-reset`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ email, api_origin: apiOrigin, ticket }),
|
||||
});
|
||||
return c.json({ success: true });
|
||||
return res.ok ? 'sent' : 'failed';
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /portal/password/relay-verify — **郵差回頭確認用**(body `{ticket}`)。
|
||||
*
|
||||
* 這支存在的唯一理由是上面那條紅線:郵差不可以相信「呼叫方說這台是我的」。
|
||||
* 它會回頭打**連結裡的那個主機**問這張票在不在——
|
||||
* - 真的是這台發的 → 這裡答得出來 → 寄
|
||||
* - 有人冒用別人的網域 → 被冒用的那台**根本沒有這張票** → 答不出來 → 郵差拒寄
|
||||
* 回應**不含 email 明碼、不含 reset token**,只回收件人 email 的 sha256 讓郵差核對它手上那個,
|
||||
* 以及要放進信裡的完整連結(由本台自己組,主機必然是自己)。
|
||||
* 票 TTL 兩分鐘、看過即焚——它不是憑據,只是一次握手。
|
||||
*/
|
||||
portalRouter.post('/portal/password/relay-verify', (c) =>
|
||||
run(c, async () => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const ticket = String(body?.ticket ?? '').trim();
|
||||
if (!ticket || !/^[0-9a-f]{8,64}$/i.test(ticket)) return c.json({ ok: false }, 400);
|
||||
const key = `${RELAY_TICKET_PREFIX}${ticket}`;
|
||||
const raw = await c.env.SESSIONS_KV.get(key);
|
||||
if (!raw) return c.json({ ok: false }, 404);
|
||||
await c.env.SESSIONS_KV.delete(key); // 一次握手,看過即焚
|
||||
let parsed: { email: string; link: string };
|
||||
try {
|
||||
parsed = JSON.parse(raw) as { email: string; link: string };
|
||||
} catch {
|
||||
return c.json({ ok: false }, 404);
|
||||
}
|
||||
return c.json({ ok: true, email_sha256: await sha256Hex(parsed.email), link: parsed.link });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/password/reset-link?token=… — 信裡連結的落點。
|
||||
// **連結的主機刻意是 cypher 自己**(=郵差回呼確認的那個主機,兩者必須是同一個,
|
||||
// 否則「郵差確認過的主機」與「信裡的主機」就不是同一件事,紅線等於沒守)。
|
||||
// 這裡只做一件事:把人帶去 portal 前端的修改密碼畫面。
|
||||
portalRouter.get('/portal/password/reset-link', (c) => {
|
||||
const token = c.req.query('token') ?? '';
|
||||
const ui = portalUiOrigin(c.env);
|
||||
if (!ui) return c.text('這台實例沒有設定 portal 前端網址,無法導向修改密碼畫面。', 500);
|
||||
return c.redirect(`${ui}/portal/#/reset?token=${encodeURIComponent(token)}`, 302);
|
||||
});
|
||||
|
||||
// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。
|
||||
//
|
||||
// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。
|
||||
// 唯一會回錯的是「寄信功能根本沒接上」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
|
||||
// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。
|
||||
//
|
||||
// 🔴 arcrun-rag#38/#69/#25(2026-08-11 leo 親口點出兩個問題,改字前先讀):
|
||||
// ① 「實例」是行話——目標用戶是「只會叫 AI 幫忙的人」,不懂什麼叫一台實例。
|
||||
// ② 「請管理員直接幫你改密碼」對單人使用者是死路——他自己就是管理員,等於叫他聯絡自己
|
||||
// (`#25` 同一種病:「我忘記 portal 密碼,畫面叫我去找管理員,但管理員就是我」)。
|
||||
// ⇒ 訊息改成白話(不提「實例」),而且給一條他自己走得完的路:**重新跑一次安裝/更新**
|
||||
// 現在**真的有用**(不是安慰話)——arcrun-rag#38/#69/#25 同批修正讓安裝器學會「版本號一樣
|
||||
// 不代表這個功能已經接上,沒接上就當作舊的重推」,所以照原本收到的安裝網址走一次,
|
||||
// 選同一個 Cloudflare 帳號,這個功能就會自動接上,不需要自己改任何設定。
|
||||
portalRouter.post('/portal/password/forgot', (c) =>
|
||||
run(c, async () => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||||
if (!email || !isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
|
||||
|
||||
if (!String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'寄信功能還沒接上,所以「忘記密碼」的信寄不出去。' +
|
||||
'請照當初收到的安裝網址,重新執行一次安裝(選同一個 Cloudflare 帳號)——' +
|
||||
'完成後這個功能就會自動接上,不需要自己設定任何東西,也不用找任何人幫忙。',
|
||||
code: 'mail_relay_not_configured',
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const generic = {
|
||||
success: true,
|
||||
message: '如果這個 email 有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
|
||||
};
|
||||
|
||||
// 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
|
||||
const throttleKey = `${PWRESET_THROTTLE_PREFIX}${email}`;
|
||||
if (await c.env.SESSIONS_KV.get(throttleKey)) return c.json(generic);
|
||||
await c.env.SESSIONS_KV.put(throttleKey, '1', { expirationTtl: PWRESET_THROTTLE_SECONDS });
|
||||
|
||||
const recordId = await findUserRecordId(c.env, email).catch(() => null);
|
||||
if (!recordId) return c.json(generic); // 沒有這個帳號 → 一樣的回應,什麼都不寄
|
||||
|
||||
const token = await issueResetToken(c.env, recordId, email);
|
||||
// 連結的主機=這次請求真正抵達的位址(郵差待會兒就是回頭打這裡確認的)
|
||||
const apiOrigin = new URL(c.req.url).origin;
|
||||
const link = `${apiOrigin}/portal/password/reset-link?token=${encodeURIComponent(token)}`;
|
||||
const ticket = randomHex(16);
|
||||
await c.env.SESSIONS_KV.put(`${RELAY_TICKET_PREFIX}${ticket}`, JSON.stringify({ email, link }), {
|
||||
expirationTtl: RELAY_TICKET_TTL_SECONDS,
|
||||
});
|
||||
await relayResetLink(c.env, apiOrigin, email, ticket); // 寄不出去也回同一句(存在性不可由回應推得)
|
||||
return c.json(generic);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/password/reset?token=… — 點進連結時先問「這張票還有效嗎」(**不消耗**)。
|
||||
// 回 email 讓畫面顯示「你正在為 xxx@yyy 設定新密碼」——票本身就證明持有人控制那個信箱。
|
||||
portalRouter.get('/portal/password/reset', (c) =>
|
||||
run(c, async () => {
|
||||
const payload = await peekResetToken(c.env, c.req.query('token') ?? '');
|
||||
if (!payload) {
|
||||
return c.json(
|
||||
{ valid: false, error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
return c.json({ valid: true, email: payload.email });
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /portal/password/change — **D62 的那一支**:修改密碼與忘記密碼共用。
|
||||
*
|
||||
* body:`{ new, current? , reset_token? }`
|
||||
* - 帶 `reset_token`(從信裡的連結來)→ **忽略 current**,票就是憑據
|
||||
* - 沒帶 → 必須是登入狀態 + 提供正確的 `current`
|
||||
* 兩條路在這一行之後**完全相同**(writeNewPassword)——這就是「差別只有一格」的實體。
|
||||
*/
|
||||
async function handlePasswordChange(c: Context<{ Bindings: Bindings }>): Promise<Response> {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const next = String(body?.new ?? '');
|
||||
const resetToken = String(body?.reset_token ?? '').trim();
|
||||
if (!next) return c.json({ error: 'new(新密碼)必填' }, 400);
|
||||
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
||||
|
||||
// ── 忘記密碼那一格:憑連結,不問現有密碼 ──
|
||||
if (resetToken) {
|
||||
const payload = await consumeResetToken(c.env, resetToken);
|
||||
if (!payload) {
|
||||
return c.json(
|
||||
{ error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。', code: 'reset_token_invalid' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
await writeNewPassword(c.env, payload.record_id, next);
|
||||
return c.json({ success: true, email: payload.email, via: 'reset_link' });
|
||||
}
|
||||
|
||||
// ── 修改密碼那一格:要登入、要現有密碼 ──
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const current = String(body?.current ?? '');
|
||||
if (!current) return c.json({ error: 'current(現有密碼)必填' }, 400);
|
||||
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
|
||||
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
|
||||
|
||||
await writeNewPassword(c.env, auth.user.recordId, next);
|
||||
return c.json({ success: true, via: 'current_password' });
|
||||
}
|
||||
|
||||
portalRouter.post('/portal/password/change', (c) => run(c, () => handlePasswordChange(c)));
|
||||
|
||||
// POST /portal/me/password — 舊名,**轉呼同一支**(現有前端與 CLI 還在用這個路徑)。
|
||||
// 保留別名而不是留第二份實作:兩份必然漂移(D39/arcrun-rag#40「同一個事實兩份」)。
|
||||
portalRouter.post('/portal/me/password', (c) => run(c, () => handlePasswordChange(c)));
|
||||
|
||||
// ═══════════════════════════════ admin 端點 ══════════════════════════════════
|
||||
|
||||
// POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是
|
||||
|
||||
@@ -115,6 +115,19 @@ export type Bindings = {
|
||||
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
|
||||
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
|
||||
PORTAL_SESSION_TTL?: string;
|
||||
// Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它;
|
||||
// D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。
|
||||
UI_ORIGINS?: string;
|
||||
// ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)───────────────────────────
|
||||
// 中央代寄服務的 base URL(landing worker)。**用戶自己的實例沒有寄信能力**——安裝器
|
||||
// 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize,
|
||||
// **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。
|
||||
// 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。
|
||||
// ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。
|
||||
PORTAL_MAIL_RELAY_BASE?: string;
|
||||
// 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端
|
||||
// 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。
|
||||
PORTAL_MAIL_RELAY_KEY?: string;
|
||||
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
|
||||
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
|
||||
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* PATCH /kbdb/records/:recordId proxy 測試(2026-08-11,三元組 library 補標需求核實)
|
||||
*
|
||||
* 背景:基本盤 kbdb/src/routes/records.ts 早有 PATCH /records/:recordId(mira-dissolve T2.1,
|
||||
* updateRecord 已支援「補一個 record 原本沒有的 slot 值」的 idempotent grow)。但這條 cypher
|
||||
* proxy(kbdb-proxy.ts)之前只轉發 GET/POST /kbdb/records,沒開 PATCH——外部(工作流/CLI/
|
||||
* 任何走 X-Arcrun-API-Key 的呼叫者)打不到,等於基本盤能力在,通道沒開。
|
||||
*
|
||||
* 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-map-proxy.test.ts 慣例):
|
||||
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
|
||||
* 2. body 沒有 values → 400,不轉發
|
||||
* 3. 轉發:PATCH /kbdb/records/:id → base PATCH /records/:id,body 只帶 { values }
|
||||
* 4. base 404(record 不存在)→ 原樣透傳,不假裝成功
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——測試絕不外連。
|
||||
*/
|
||||
import { SELF, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 租戶閘', () => {
|
||||
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ values: { library: 'kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 參數驗證', () => {
|
||||
it('body 沒有 values → 400,不轉發', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /kbdb/records/:recordId — 轉發', () => {
|
||||
it('轉發 base PATCH /records/:id,body 只帶 values(不夾帶其他欄位)', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records/rec_1',
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
record: { record_id: 'rec_1', template_id: 'tpl-triplet', values: { library: 'gitea:Leo/kb' } },
|
||||
});
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.record.values.library).toBe('gitea:Leo/kb');
|
||||
});
|
||||
|
||||
it('base 404(record 不存在)→ 原樣透傳,不假裝成功', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/records/nope', method: 'PATCH' })
|
||||
.reply(404, { success: false, error: 'not found' });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records/nope', {
|
||||
method: 'PATCH',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ values: { library: 'kb' } }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -582,3 +582,103 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
|
||||
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ D62 + arcrun-rag#66(2026-08-10)═══════════════
|
||||
//
|
||||
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
|
||||
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
|
||||
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
|
||||
|
||||
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
|
||||
const D62_EMAIL = 'd62-reset@example.com';
|
||||
|
||||
it('/portal/password/change 帶 reset_token:**不需要登入、不需要現有密碼**,且票用完即失效', async () => {
|
||||
// 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張),
|
||||
// 存的是 token 的 sha256——KV 裡看不到可用的連結。
|
||||
const { sha256Hex } = await import('../src/lib/portal-auth');
|
||||
const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||
const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
|
||||
await env.SESSIONS_KV.put(
|
||||
`portal_pwreset:${await sha256Hex(token)}`,
|
||||
JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// 票有效時,先「看一眼」不會消耗它
|
||||
const peek = await json('GET', `/portal/password/reset?token=${token}`);
|
||||
expect(peek.status).toBe(200);
|
||||
expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);
|
||||
|
||||
// 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回)
|
||||
const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
|
||||
expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的
|
||||
// 關鍵斷言:同一條連結**不能再用第二次**
|
||||
const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
|
||||
expect(again.status).toBe(400);
|
||||
expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
|
||||
// 而且票在 KV 裡真的沒了
|
||||
expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => {
|
||||
for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
|
||||
const res = await json('GET', `/portal/password/reset?token=${t}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json() as { valid: boolean }).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => {
|
||||
const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => {
|
||||
const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => {
|
||||
const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
|
||||
const TOKEN_A = 'sess-token-66-propagating';
|
||||
const TOKEN_B = 'sess-token-66-really-gone';
|
||||
const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;
|
||||
|
||||
it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => {
|
||||
await seedPortalSession(TOKEN_A, MISSING);
|
||||
// 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗
|
||||
await env.SESSIONS_KV.put(
|
||||
'auth_store_recent',
|
||||
JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
|
||||
);
|
||||
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
|
||||
// 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來
|
||||
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => {
|
||||
await seedPortalSession(TOKEN_B, MISSING);
|
||||
await env.SESSIONS_KV.delete('auth_store_recent');
|
||||
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
|
||||
expect(res.status).toBe(401);
|
||||
// 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收
|
||||
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => {
|
||||
await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+283
-13
@@ -216,12 +216,63 @@ const MAX_LIKE_TERMS = 6;
|
||||
|
||||
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
|
||||
|
||||
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
|
||||
// ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(Arcrun#94)────────────────
|
||||
//
|
||||
// 病徵:搜尋框打 `100%` 或 `owner_id`,回來一堆跟那些字無關的東西。
|
||||
// SQLite LIKE 只有兩個萬用字元——`%`(任意長度)與 `_`(任意一個字元),而且
|
||||
// **沒有預設跳脫字元**(不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」)。
|
||||
// 我們把使用者輸入直接內插成 `'%' + q + '%'` ⇒ 他打的符號被當成 pattern 語法:
|
||||
// `100%` → `%100%%` → 「100」開頭後面接什麼都算 ⇒ 撈回一堆不相干的
|
||||
// `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ `ownerXid`、`owner-id` 也中
|
||||
// `%`/`_` 單打 → `%%%`/`%_%` → **整個庫都回來**(`_` 只要有一個字元就中)
|
||||
//
|
||||
// 這是舊病,不是 08-10 斷詞(47c6aae→本檔上一段)引進的:pattern 一直都是這樣拼的。
|
||||
// 之前關鍵字搜尋幾乎恆為 0 命中(整串比對),這個洞被那個洞蓋住,看不出來;
|
||||
// 斷詞讓搜尋真的會回東西之後它才浮出來。**斷詞那段一個字都沒動。**
|
||||
//
|
||||
// 修法:三個字元都跳脫,並在每個 LIKE 後面掛 `ESCAPE '\'`。
|
||||
//
|
||||
// 為什麼**跳脫字元本身(`\`)也要跳脫**(邊界問題的答案,不是順手多做):
|
||||
// 一旦宣告了 ESCAPE,`\` 在 pattern 裡就變成有意義的字元,於是「使用者打的 `\`」
|
||||
// 同樣會被誤讀——而且是更糟的一種,因為它會**把後面那個字吃掉**:
|
||||
// 使用者打 `100\%` → 不跳脫 `\` ⇒ pattern `%100\%%` ⇒ `\%`=字面 %
|
||||
// ⇒ 實際找的是 `100%`,**跟他打的字不一樣**
|
||||
// 使用者打 `C:\` → pattern `%C:\%` ⇒ 尾巴 `\%`=字面 %
|
||||
// ⇒ 找的是 `C:%`,而真正的 `C:\` 反而找不到
|
||||
// ⇒ 三個字元是一組的:宣告 ESCAPE 卻不跳脫 `\` 等於用新的漏洞換掉舊的。
|
||||
// (SQLite 對「`\` 後面接其他字元」是寬容的——照字面匹配下一個字、不報錯——
|
||||
// 所以不跳脫不會炸,只會靜靜地找錯東西,正是最難發現的那種。)
|
||||
//
|
||||
// 為什麼**只有這三個**:SQLite 的 LIKE 萬用字元就只有 `%` 和 `_`(`[...]`、`?`、`*`
|
||||
// 是別的方言/GLOB 的東西,LIKE 不吃),加上自己宣告的跳脫字元 `\`,就這三個。
|
||||
// 不多跳脫其他字元——跳脫沒有語法意義的字元只會白白吃掉 pattern 的 byte 預算。
|
||||
//
|
||||
// 🔴 與 50 bytes 上限的交互作用(不能只加跳脫就收工):跳脫會**變長**(`%`→`\%`),
|
||||
// 所以所有 byte 預算改用「跳脫後」的長度算(likeBytes),否則使用者打一串 `%`
|
||||
// 會讓 pattern 膨脹回 50 bytes 以上 ⇒ 退回 2026-08-03 那個 500。
|
||||
// 不含這三個字元的查詢,likeBytes ≡ utf8Len ⇒ **既有查詢的行為逐字不變**。
|
||||
const LIKE_ESCAPE = '\\';
|
||||
/** 每個 `content LIKE ?` 都要帶著它的 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面。 */
|
||||
const CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
|
||||
|
||||
/** 把使用者輸入當「字面字串」送進 LIKE(純函式,單測用 export)。 */
|
||||
export function escapeLikeLiteral(s: string): string {
|
||||
// 一次掃描、每個字元各自替換 ⇒ 不會發生「先換 % 再換 \ 把剛加的跳脫又跳脫一次」。
|
||||
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
|
||||
}
|
||||
|
||||
/** 這段文字**跳脫後**佔的 byte 數(=它在 LIKE pattern 裡真正佔的長度)。 */
|
||||
const likeBytes = (s: string): number => utf8Len(escapeLikeLiteral(s));
|
||||
|
||||
/** 子字串比對用的 pattern:只有頭尾那兩個 `%` 是萬用字元,中間全是字面。 */
|
||||
const likePattern = (s: string): string => `%${escapeLikeLiteral(s)}%`;
|
||||
|
||||
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。上限算的是**跳脫後**的長度。 */
|
||||
function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = '';
|
||||
for (const ch of s) {
|
||||
if (utf8Len(cur + ch) > maxBytes) {
|
||||
if (likeBytes(cur + ch) > maxBytes) {
|
||||
if (cur) out.push(cur);
|
||||
cur = ch;
|
||||
} else {
|
||||
@@ -237,8 +288,8 @@ function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
|
||||
*/
|
||||
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
|
||||
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
|
||||
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
|
||||
}
|
||||
const terms: string[] = [];
|
||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||
@@ -251,12 +302,215 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
|
||||
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
|
||||
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
|
||||
return {
|
||||
conds: terms.map(() => 'content LIKE ?'),
|
||||
params: terms.map((t) => `%${t}%`),
|
||||
conds: terms.map(() => CONTENT_LIKE),
|
||||
params: terms.map(likePattern),
|
||||
split: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ─────────────────────────
|
||||
//
|
||||
// 病徵(2026-08-10 總管在 leo21c 上實測,有對照組):
|
||||
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||
// ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。
|
||||
// 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一
|
||||
// `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。
|
||||
//
|
||||
// 為什麼這是**結構性**故障、不是準度問題:
|
||||
// **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰
|
||||
// ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」——
|
||||
// 接上了也還是瞎的,因為接上之後查什麼都沒有。
|
||||
// (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。)
|
||||
//
|
||||
// 這件 47c6aae(2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事;
|
||||
// 本次只動**查詢端**,buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。
|
||||
//
|
||||
// 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。
|
||||
// · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**,
|
||||
// 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。
|
||||
// · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛
|
||||
// ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2)
|
||||
// ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。
|
||||
// · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一,
|
||||
// `local arcrun` 那 5 筆不會被稀釋掉。
|
||||
// · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套):
|
||||
// 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。
|
||||
//
|
||||
// 回歸保證(不是靠測試碰運氣,是靠構造):
|
||||
// · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern),
|
||||
// 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。
|
||||
// · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞),
|
||||
// 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。
|
||||
//
|
||||
// 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段
|
||||
// +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、
|
||||
// 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。
|
||||
// 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到
|
||||
// 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。
|
||||
const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數)
|
||||
const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號
|
||||
// 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照):
|
||||
// 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾);
|
||||
// 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響
|
||||
// ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」,
|
||||
// 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。
|
||||
const KEYWORD_RELATIVE_CUT = 0.6;
|
||||
|
||||
// CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。**
|
||||
//
|
||||
// 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果
|
||||
// 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。
|
||||
// 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都
|
||||
// 同時是虛詞與實詞的組成部分)。
|
||||
// ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。
|
||||
// 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。
|
||||
//
|
||||
// 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額),
|
||||
// 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡
|
||||
// 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。
|
||||
const CJK_STOP_CHARS = new Set(
|
||||
'的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''),
|
||||
);
|
||||
|
||||
// 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。
|
||||
const ASCII_STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were',
|
||||
'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with',
|
||||
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would',
|
||||
'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they',
|
||||
]);
|
||||
|
||||
const isCjkChar = (ch: string): boolean => /[-ヿ㐀-䶿一-鿿豈-]/.test(ch);
|
||||
const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch);
|
||||
|
||||
/** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */
|
||||
export function splitRuns(q: string): { text: string; cjk: boolean }[] {
|
||||
const runs: { text: string; cjk: boolean }[] = [];
|
||||
let cur = ''; let curCjk = false;
|
||||
const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; };
|
||||
for (const ch of q) {
|
||||
const cjk = isCjkChar(ch);
|
||||
if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔
|
||||
if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意)
|
||||
cur += ch; curCjk = cjk;
|
||||
}
|
||||
flush();
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */
|
||||
function contentBigrams(run: string): string[] {
|
||||
const chars = [...run];
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i + 1 < chars.length; i++) {
|
||||
if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue;
|
||||
out.push(chars[i] + chars[i + 1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SearchTerm { term: string; weight: number }
|
||||
|
||||
/**
|
||||
* 把查詢句拆成帶份量的查詢詞(純函式,單測用 export)。
|
||||
* 份量=字數(上限 MAX_TERM_WEIGHT);愈長愈專指 ⇒ 排序時壓過泛詞。
|
||||
* 依份量由大到小截斷到 MAX_SEARCH_TERMS,確保被砍掉的是最泛的那幾個。
|
||||
*/
|
||||
export function tokenizeQuery(q: string): SearchTerm[] {
|
||||
const found = new Map<string, number>();
|
||||
const add = (t: string, w: number) => {
|
||||
for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束
|
||||
if (!piece) continue;
|
||||
found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT)));
|
||||
}
|
||||
};
|
||||
|
||||
const runs = splitRuns(q);
|
||||
// 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同:
|
||||
// · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。
|
||||
// · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。
|
||||
// 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有
|
||||
// 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的
|
||||
// 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。
|
||||
const isQuestion = runs.length > 1;
|
||||
|
||||
for (const run of runs) {
|
||||
if (!run.cjk) {
|
||||
const w = run.text.toLowerCase();
|
||||
if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length);
|
||||
continue;
|
||||
}
|
||||
const chars = [...run.text];
|
||||
// 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。
|
||||
if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length);
|
||||
// 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。
|
||||
// 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。
|
||||
if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2);
|
||||
}
|
||||
|
||||
return [...found.entries()]
|
||||
.map(([term, weight]) => ({ term, weight }))
|
||||
.sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
|
||||
.slice(0, MAX_SEARCH_TERMS);
|
||||
}
|
||||
|
||||
export interface SearchScorePlan {
|
||||
/** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */
|
||||
scoreExpr: string;
|
||||
scoreParams: string[];
|
||||
terms: SearchTerm[];
|
||||
/** true = 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */
|
||||
legacyShape: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生「覆蓋率分數」的 SQL 表達式(純函式,單測用 export)。
|
||||
*
|
||||
* 整句相鄰另給一份重賞(=所有詞份量總和),確保**舊行為排最前**:
|
||||
* 含整句的內容分數必然高於只含零散詞的,`local arcrun` 那 5 筆永遠在最上面。
|
||||
*/
|
||||
export function buildSearchScore(q: string): SearchScorePlan {
|
||||
const trimmed = q.trim();
|
||||
const terms = tokenizeQuery(trimmed);
|
||||
|
||||
// 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。
|
||||
if (terms.length === 0) {
|
||||
const m = buildContentLike(trimmed);
|
||||
return {
|
||||
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '),
|
||||
scoreParams: m.params,
|
||||
terms: [],
|
||||
legacyShape: true,
|
||||
};
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const params: string[] = [];
|
||||
for (const { term, weight } of terms) {
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
|
||||
params.push(likePattern(term));
|
||||
}
|
||||
|
||||
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
|
||||
params.push(likePattern(trimmed));
|
||||
}
|
||||
|
||||
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||
}
|
||||
|
||||
/** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */
|
||||
export function applyRelativeCut<T extends { match_score: number }>(rows: T[]): T[] {
|
||||
if (rows.length <= 1) return rows;
|
||||
const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT;
|
||||
return rows.filter((r) => r.match_score >= cut);
|
||||
}
|
||||
|
||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||
@@ -309,6 +563,11 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||
// 2026-08-12(Arcrun#94):q 裡的 `%` `_` `\` 一律當字面字元(escapeLikeLiteral + ESCAPE 宣告)
|
||||
// ——使用者打什麼字就照那些字找。舊病,見上面 LIKE_ESCAPE 那段。
|
||||
// 2026-08-10:q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||
export async function searchEntries(
|
||||
db: D1Database,
|
||||
q: string,
|
||||
@@ -318,18 +577,29 @@ export async function searchEntries(
|
||||
library?: string[],
|
||||
source?: string,
|
||||
includeDeprecated = false,
|
||||
): Promise<Entry[]> {
|
||||
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
||||
const conds = [...m.conds];
|
||||
const params: unknown[] = [...m.params];
|
||||
): Promise<(Entry & { match_score: number })[]> {
|
||||
const plan = buildSearchScore(q); // 斷詞+算分;單詞查詢=與舊版逐字相同的單一 LIKE
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [...plan.scoreParams];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
||||
// 分數在子查詢算、外層才篩 match_score > 0:SQLite 不保證能在 WHERE 引用 SELECT 別名,
|
||||
// 用子查詢就不必把整組 LIKE 參數再綁一次(參數重複=將來改一邊漏一邊的漂移來源)。
|
||||
// 其他 filter 留在**內層**,讓 owner/library/deprecated 先篩掉,算分只發生在該算的列上。
|
||||
const inner = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const res = await db
|
||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
||||
.prepare(
|
||||
`SELECT * FROM (
|
||||
SELECT *, (${plan.scoreExpr}) AS match_score FROM entries ${inner}
|
||||
) WHERE match_score > 0
|
||||
ORDER BY match_score DESC, updated_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(...params, Math.min(limit, 200))
|
||||
.all<Entry>();
|
||||
return res.results ?? [];
|
||||
.all<Entry & { match_score: number }>();
|
||||
// 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。
|
||||
return applyRelativeCut(res.results ?? []);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// 標庫 backfill(Arcrun#85 二次裁決,2026-08-11;相關票 Arcrun#87「藏書地圖是空的」)。
|
||||
//
|
||||
// 背景:leo 把向量化優先序講成一句話後又補一刀:「它是兩件事?其實是一件——沒有庫就
|
||||
// 剩一個,但你把庫標好以後,原來的判定要修改對吧」。⇒ 判定標準(embed.ts 的
|
||||
// SelectionCriteria)必須從第一天就同時容納時間與庫,本檔提供「庫」這一半真正的資料。
|
||||
//
|
||||
// base 的 write path 早就支援(`createEntry` 的 `metadata_json.$.library`,t52:「庫由
|
||||
// ingest 蓋章決定」)——既有的搜尋/embed/deprecate-by-library 全都讀這個欄位,
|
||||
// **缺的不是機制,是既有資料沒被蓋章**(library-map.ts 檔頭 2026-07-19 對 prod 核實:
|
||||
// 既有 entries 的 metadata.library 是空的)。「源頭寫入就貼標」是呼叫端(ingest)的事,
|
||||
// base 這裡管不到、也不該猜(base 對內容語意無知的既有原則);triplet 的實際寫入更是
|
||||
// 在另一個 repo(見 kbdb/src/index.ts 檔頭「triplet (separate repo)」)。
|
||||
//
|
||||
// 這個模組只做「補存量」那一半,且刻意設計成呼叫端驅動:
|
||||
// - base 不猜「這筆該屬於哪個庫」——那是語意判斷。呼叫端給一個 target library 值
|
||||
// +一組篩選條件,base 只負責把符合條件、目前未標記的 entries 安全、節流地蓋上這個值。
|
||||
// - 篩選條件有兩種精度(2026-08-11 leo 定案的做法後補上):
|
||||
// ① 精準比對 `page_names`(IN 清單)——leo 定的正解:「有 2 份原稿,在 gitea 和我的
|
||||
// Mac……去 gitea 把每個庫有哪些的卡名列出,跑來遍歷應該就搞定了」。呼叫端(daemon/
|
||||
// #87)從 Gitea repo 列出卡名,逐批把「這些卡名屬於庫 X」精準地寫進來,不必猜。
|
||||
// ② `source_prefix`/`page_name_prefix` 前綴 fallback(同 library-map.ts
|
||||
// recomputeLibraryMap 的 source_prefix 精神,只是這裡是寫入不是聚合)——沒有精準
|
||||
// 清單時的過渡手段,精度不如①,兩者可並用(AND)縮小範圍。
|
||||
// - 冪等:已標記的 entries 不會再入選(WHERE 帶「library 為空」)。
|
||||
//
|
||||
// D69 節流:與 reconcileEmbedGeneration(embed.ts)共用 maintenance-quota.ts 的同一顆
|
||||
// 每日 D1 寫入計數器——兩者都是「多筆 D1 row write、不打 AI」的背景維護操作,不共用
|
||||
// 計數器的話,補存量時會把世代核對的閘繞過去(leo 2026-08-11 二次裁決原話:「每日上限
|
||||
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
|
||||
import type { Bindings } from '../types';
|
||||
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
|
||||
|
||||
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
|
||||
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
|
||||
const MAX_PAGE_NAMES = 300;
|
||||
|
||||
export interface LibraryBackfillCriteria {
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
page_names?: string[]; // 精準比對 page_name(IN 清單)——leo 定案的正解:從 Gitea repo
|
||||
// 列出卡名,逐批精準點名「這些卡名屬於庫 X」(見檔頭說明①)。
|
||||
source_prefix?: string; // metadata_json.$.source LIKE prefix%(過渡 fallback,見檔頭②)
|
||||
page_name_prefix?: string; // page_name LIKE prefix%(過渡 fallback,見檔頭②)
|
||||
since?: number; // created_at >= since(unix seconds)
|
||||
until?: number; // created_at < until(unix seconds)
|
||||
}
|
||||
|
||||
export interface LibraryBackfillResult {
|
||||
library: string;
|
||||
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
|
||||
tagged: number; // 本次真的寫入 metadata_json.$.library 的筆數。
|
||||
remaining: number; // 本次之後仍待補標(符合條件、仍未標記)的筆數,不受額度影響。
|
||||
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與 reconcile 共用)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷。
|
||||
}
|
||||
|
||||
// 單次呼叫候選上限(避開 subrequest/CPU/timeout;一批只有 1 次 SELECT + 1 次 UPDATE,
|
||||
// 比 reconcile 多一次 Vectorize 呼叫的成本低,故上限可以放寬一些)。
|
||||
const HARD_LIMIT_CAP = 500;
|
||||
|
||||
function criteriaPredicate(c: LibraryBackfillCriteria): { conds: string[]; params: unknown[] } {
|
||||
// 冪等的核心:只選「目前沒有 library 值」的候選,已標記過的(含標成 'general' 的)不會再入選。
|
||||
const conds: string[] = [
|
||||
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')",
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
if (c.owner_id) { conds.push('owner_id = ?'); params.push(c.owner_id); }
|
||||
if (c.entry_type) { conds.push('entry_type = ?'); params.push(c.entry_type); }
|
||||
if (c.page_names && c.page_names.length > 0) {
|
||||
const names = c.page_names.slice(0, MAX_PAGE_NAMES);
|
||||
conds.push(`page_name IN (${names.map(() => '?').join(',')})`);
|
||||
params.push(...names);
|
||||
}
|
||||
if (c.source_prefix) { conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'"); params.push(c.source_prefix); }
|
||||
if (c.page_name_prefix) { conds.push("page_name LIKE ? || '%'"); params.push(c.page_name_prefix); }
|
||||
if (typeof c.since === 'number') { conds.push('created_at >= ?'); params.push(c.since); }
|
||||
if (typeof c.until === 'number') { conds.push('created_at < ?'); params.push(c.until); }
|
||||
return { conds, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 對「符合條件、目前未標記 library」的既有 entries 批次蓋上 target library 值。
|
||||
* 冪等 + 分批(單次 limit 上限)+ budget(與 reconcile 共用每日 D1 寫入額度,見檔頭)。
|
||||
* 呼叫端(ingest / Arcrun#87)決定「這批是誰、該貼哪個庫」,本函式只負責安全、節流地
|
||||
* 把值寫進去——base 不猜語意,也因此不假裝「這樣就把 #87 做完了」(mindset §7)。
|
||||
*
|
||||
* `owner_id` 刻意設成**必填**(不同於 LibraryBackfillCriteria 其餘欄位皆選填):
|
||||
* 2026-08-11 leo 在票上點出「補標補在錯的 owner 底下等於白做」(實查發現卡片實際掛在
|
||||
* `owner_id=bfezv28v`,換成 `owner_id='leo'` 查卻是空的——兩個候選 owner 已經在互相打架)。
|
||||
* 跟既有的 `deprecateEntriesByLibrary`(同樣是「依 library 批次改一大片既有資料」的操作)
|
||||
* 同一個防線:不給不知道自己在改誰的資料的呼叫端一個「忘記帶 owner_id 就變成跨租戶全庫掃」
|
||||
* 的後門,逼呼叫端明確想清楚「這批是哪個 owner」再動手。
|
||||
*/
|
||||
export async function backfillEntryLibraryTags(
|
||||
db: D1Database,
|
||||
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
|
||||
opts: { library: string; owner_id: string; limit?: number } & Omit<LibraryBackfillCriteria, 'owner_id'>,
|
||||
): Promise<LibraryBackfillResult> {
|
||||
const library = (opts.library ?? '').trim();
|
||||
if (!library) throw new Error('library required');
|
||||
const ownerId = (opts.owner_id ?? '').trim();
|
||||
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
|
||||
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
|
||||
|
||||
const sel = criteriaPredicate({ ...opts, owner_id: ownerId });
|
||||
const where = sel.conds.join(' AND ');
|
||||
const params = sel.params;
|
||||
|
||||
const res = await db
|
||||
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write,與 reconcile 共用同一顆計數器。
|
||||
const budget = await maintenanceBudgetToday(env, db);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
|
||||
let tagged = 0;
|
||||
if (ids.length > 0) {
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`,
|
||||
)
|
||||
.bind(library, ...ids)
|
||||
.run();
|
||||
tagged = ids.length;
|
||||
}
|
||||
|
||||
try {
|
||||
await addMaintenanceUsage(db, tagged);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
|
||||
}
|
||||
|
||||
const remRow = await db
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
return {
|
||||
library,
|
||||
scanned,
|
||||
tagged,
|
||||
remaining: remRow?.c ?? 0,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + tagged,
|
||||
quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待補標統計(回報用):符合條件、目前未標記 library 的筆數。 */
|
||||
export async function libraryBackfillStatus(
|
||||
db: D1Database,
|
||||
opts: LibraryBackfillCriteria = {},
|
||||
): Promise<{ pending: number }> {
|
||||
const sel = criteriaPredicate(opts);
|
||||
const where = sel.conds.join(' AND ');
|
||||
const row = await db
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...sel.params)
|
||||
.first<{ c: number }>();
|
||||
return { pending: row?.c ?? 0 };
|
||||
}
|
||||
@@ -330,6 +330,15 @@ type LibraryNameSet = Set<string>;
|
||||
|
||||
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
|
||||
// triplet 歸 'general')——與 GET /records/triplet-stats(t142)同一套分組語意,兩處數字對得上。
|
||||
//
|
||||
// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status,
|
||||
// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不
|
||||
// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上,
|
||||
// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map
|
||||
// record(superseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede
|
||||
// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作,
|
||||
// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出
|
||||
// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。
|
||||
async function liveTripletCountsByLibrary(
|
||||
db: D1Database,
|
||||
tripletTemplateId: string,
|
||||
@@ -337,15 +346,18 @@ async function liveTripletCountsByLibrary(
|
||||
): Promise<LibraryCountMap> {
|
||||
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||
const res = await db
|
||||
.prepare(
|
||||
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||||
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||
FROM (
|
||||
SELECT DISTINCT ev.record_id
|
||||
SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
||||
GROUP BY ev.record_id
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
|
||||
)
|
||||
.bind(...params)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// 背景維護寫入的共用 D1 每日額度(Arcrun#85 D69,2026-08-11)。
|
||||
//
|
||||
// 為什麼需要這個模組(不是每個 caller 各自算):
|
||||
// D68 已經替「補算向量」的 Workers AI 呼叫量設了每日軟上限(embed.ts
|
||||
// DEFAULT_BACKFILL_DAILY_LIMIT),但 leo 逐行複核後指出還有一個沒堵的洞——
|
||||
// 世代核對(reconcileEmbedGeneration)**不打 AI,卻一樣逐筆寫 D1**(補標 content_hash
|
||||
// 或重置 is_embedded),47 萬筆候選 ≈ 4.7 倍 D1 免費層 100,000 rows written/日,而它
|
||||
// 當時零保護。2026-08-11 leo 補了第二刀:**標庫(library backfill)也是同一種操作**
|
||||
// ——多筆 D1 row write、不打 AI——若各自設一顆獨立計數器,做標庫時會把 reconcile
|
||||
// 的閘繞過去(兩者加起來還是可能燒穿同一顆 D1)。
|
||||
// ⇒ 兩者必須共用同一顆「今天 D1 背景維護寫入還剩多少」計數器,這裡就是那顆計數器。
|
||||
//
|
||||
// 儲存精神完全比照 execution-log.ts checkUsage/embed.ts getBackfillUsageToday:單一
|
||||
// entries 列/日(entry_type='kbdb_maintenance_usage'),upsert,不新增表(D38)。
|
||||
//
|
||||
// 額度怎麼選(不是拍腦袋,比照 execution-log.ts DEFAULT_DAILY_LIMIT 的既有算法):
|
||||
// D1 免費層 100,000 rows written/日。execution_log 自設 20%(20,000)留給知識卡;
|
||||
// 本模組管的是「背景維護」(reconcile + 標庫 backfill,兩者都是低優先、非使用者
|
||||
// 當下等待的操作),同樣自設 20%(20,000/日)——不是硬性 Cloudflare 限制,是不讓
|
||||
// 背景維護把當天寫入額度和知識卡片的正常寫入/execution_log 搶光的自我節制,
|
||||
// 可用 env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT 覆寫。
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 20000;
|
||||
|
||||
export function maintenanceDailyLimit(env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>): number {
|
||||
const raw = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
}
|
||||
|
||||
function utcDay(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 額度計數器 entries id(單一列/日;不分租戶——D1 rows-written 額度是實例級,非租戶級)。 */
|
||||
function maintenanceUsageId(): string {
|
||||
return `kbdb-maintenance-usage:${utcDay()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今天背景維護寫入已消耗的筆數。讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open,
|
||||
* 精神同 embed.ts getBackfillUsageToday)。
|
||||
*/
|
||||
export async function getMaintenanceUsageToday(db: D1Database): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(maintenanceUsageId())
|
||||
.first<{ metadata_json: string | null }>();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { writes?: number }) : {};
|
||||
return Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 今天背景維護額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
|
||||
export async function addMaintenanceUsage(db: D1Database, by: number): Promise<void> {
|
||||
if (by <= 0) return;
|
||||
const id = maintenanceUsageId();
|
||||
const existing = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ metadata_json: string | null }>();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
|
||||
prev = Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
prev = 0;
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||||
.bind(JSON.stringify({ day: utcDay(), writes: prev + by }), id)
|
||||
.run();
|
||||
} else {
|
||||
await db
|
||||
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'kbdb_maintenance_usage', ?)`)
|
||||
.bind(id, JSON.stringify({ day: utcDay(), writes: by }))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export interface MaintenanceBudget {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
/** 今天還剩多少背景維護 D1 寫入額度(reconcile/標庫 backfill 呼叫前先問這個)。 */
|
||||
export async function maintenanceBudgetToday(
|
||||
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
|
||||
db: D1Database,
|
||||
): Promise<MaintenanceBudget> {
|
||||
const limit = maintenanceDailyLimit(env);
|
||||
let used = 0;
|
||||
try {
|
||||
used = await getMaintenanceUsageToday(db);
|
||||
} catch {
|
||||
used = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連背景維護都做不了
|
||||
}
|
||||
return { limit, used, remaining: Math.max(0, limit - used) };
|
||||
}
|
||||
+305
-13
@@ -12,6 +12,7 @@
|
||||
// base 只認這個通用旗標 → base 維持對內容語意無知。
|
||||
|
||||
import type { Bindings, Entry } from './types';
|
||||
import { maintenanceBudgetToday, addMaintenanceUsage } from './actions/maintenance-quota';
|
||||
|
||||
// ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)────────────────
|
||||
//
|
||||
@@ -132,7 +133,12 @@ export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean
|
||||
},
|
||||
]);
|
||||
// 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。
|
||||
await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(entry.id).run();
|
||||
// content_hash 順手蓋成「這次嵌入用的模型」(世代戳記,見下方 reconcileEmbedGeneration 的
|
||||
// 說明)——這裡是「新寫的立刻算」的路徑,寫入當下 model 必為現行 model,不會有世代落差。
|
||||
await env.DB
|
||||
.prepare('UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?')
|
||||
.bind(embedModel(env), entry.id)
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -173,12 +179,134 @@ function parseMeta(json: string | null): Record<string, unknown> | null {
|
||||
const BACKFILL_PREDICATE =
|
||||
"is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
|
||||
|
||||
// ── 每日額度上限(D68,2026-08-11:leo「補算向量照時間新到舊、且每天有額度上限」)─────────
|
||||
//
|
||||
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日免費 10,000 neurons(UTC 午夜重置,
|
||||
// 見頂層 wiki ops-facts.md「萃取與向量化吃同一份 Workers AI 額度」)。backfill 是背景低優先
|
||||
// 動作,不該把當天額度燒光讓萃取/今天的新寫入整天卡死(embedOnWrite 不受此上限——「新寫的
|
||||
// 立刻算」是 D68 三條之一,不能被 backfill 的節制連坐)。自設「軟上限」,非 Cloudflare 硬限制,
|
||||
// 可用 env.EMBED_BACKFILL_DAILY_LIMIT 覆寫(精神比照 execution-log.ts 的 DEFAULT_DAILY_LIMIT)。
|
||||
//
|
||||
// 預設值怎麼選(不是拍腦袋,2026-08-11 查證 Cloudflare 官方定價後回推):
|
||||
// bge-m3 定價:1,075 neurons / 1,000,000 input tokens(無輸出 token 成本,embedding 只有輸入)。
|
||||
// 保守估計每筆中文知識卡片 ~800 tokens(寧可高估——CJK tokenizer 密度通常高於英文,
|
||||
// 高估 token 數 ⇒ 算出的「每日可嵌筆數」偏保守,不會撞真的 CF 額度):
|
||||
// 800 tokens × 1,075 / 1,000,000 ≈ 0.86 neurons/entry
|
||||
// backfill 分到日配額 20%(比照 execution-log.ts「自我節制、留大部分給主流程」的既有慣例):
|
||||
// 10,000 × 20% = 2,000 neurons/日
|
||||
// 2,000 ÷ 0.86 ≈ 2,325 entries/日,再打八折留緩衝(token 估計誤差/其他背景消耗):
|
||||
// 2,325 × 0.8 ≈ 1,860 → 取整數 1,800。
|
||||
const DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
|
||||
|
||||
function backfillDailyLimit(env: Pick<Bindings, 'EMBED_BACKFILL_DAILY_LIMIT'>): number {
|
||||
const raw = env.EMBED_BACKFILL_DAILY_LIMIT;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
|
||||
}
|
||||
|
||||
function utcDay(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 額度計數器 entries id(單一列/日,UTC 日期字串,換日自然歸零;不分租戶——Workers AI 額度是帳號級)。 */
|
||||
function backfillUsageId(): string {
|
||||
return `embed-backfill-usage:${utcDay()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今天 backfill 已消耗的筆數。儲存精神完全比照 execution-log.ts 的 checkUsage:單一 entries 列/日
|
||||
* (entry_type='embed_backfill_usage',計數包進 metadata_json),不新增表。
|
||||
* 讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open)。
|
||||
*/
|
||||
async function getBackfillUsageToday(db: D1Database): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(backfillUsageId())
|
||||
.first<{ metadata_json: string | null }>();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { embedded?: number }) : {};
|
||||
return Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
return 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死額度機制
|
||||
}
|
||||
}
|
||||
|
||||
/** 今天 backfill 額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
|
||||
async function addBackfillUsage(db: D1Database, by: number): Promise<void> {
|
||||
if (by <= 0) return;
|
||||
const id = backfillUsageId();
|
||||
const existing = await db
|
||||
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ metadata_json: string | null }>();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { embedded?: number }) : {};
|
||||
prev = Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
prev = 0;
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||||
.bind(JSON.stringify({ day: utcDay(), embedded: prev + by }), id)
|
||||
.run();
|
||||
} else {
|
||||
await db
|
||||
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`)
|
||||
.bind(id, JSON.stringify({ day: utcDay(), embedded: by }))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 「挑哪一批」可以從外面指定(Arcrun#85,2026-08-11 leo 二度裁決)───────────────
|
||||
//
|
||||
// leo 的優先序不是「一律新到舊」的單一佇列,是**分層**:今天寫的立刻/這週在跑的先跑/
|
||||
// 有查詢紀錄的庫優先/半年前的慢慢跑。分層要能實作,前提是「這次補哪一批」要能從外面
|
||||
// (工作流)指定,不能只靠資料層自己決定的固定排序——策略要住在 leo 打得開的地方
|
||||
// (工作流頁),不是焊死在這裡看不見也改不動。
|
||||
//
|
||||
// 這裡不預先幫 caller 決定「四層怎麼切」(那是策略,屬於呼叫端/工作流,見 Arcrun#85
|
||||
// D70 段落的意圖草案),只提供**同一套篩選形狀**讓任何一層都能表達:
|
||||
// - since/until:時間窗(unix seconds,created_at 半開區間 [since, until))——時間分層
|
||||
// (①今天/②本週/④半年前)都是同一個 since/until 參數,差別只在呼叫端傳的值。
|
||||
// - library:依 metadata_json.$.library 過濾——一旦資料身上有庫這個資訊(Arcrun#87),
|
||||
// 「有查詢紀錄的庫優先」這層可以直接用同一個參數,不必再改介面形狀。
|
||||
// 三個操作(backfillEmbeddings/reconcileEmbedGeneration/backfillEntryLibraryTags,
|
||||
// 見 actions/library-backfill.ts)共用這個形狀,這就是「判定標準只有一份」的意思——
|
||||
// 不是先做時間、之後為了庫再回頭改介面。
|
||||
export interface SelectionCriteria {
|
||||
owner_id?: string;
|
||||
source?: string;
|
||||
library?: string; // 精確比對 metadata_json.$.library(未標記的舊資料一律歸 'general',同 embedOnWrite 慣例)
|
||||
since?: number; // created_at >= since(unix seconds)
|
||||
until?: number; // created_at < until(unix seconds)
|
||||
}
|
||||
|
||||
function selectionCriteriaPredicate(opts: SelectionCriteria): { conds: string[]; params: unknown[] } {
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
|
||||
if (opts.library) {
|
||||
conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?");
|
||||
params.push(opts.library);
|
||||
}
|
||||
if (typeof opts.since === 'number') { conds.push('created_at >= ?'); params.push(opts.since); }
|
||||
if (typeof opts.until === 'number') { conds.push('created_at < ?'); params.push(opts.until); }
|
||||
return { conds, params };
|
||||
}
|
||||
|
||||
export interface BackfillResult {
|
||||
enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。
|
||||
processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
|
||||
skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。
|
||||
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。
|
||||
skipped: number; // 掃到但沒嵌(例如 embedText 回 null,或本批被額度擋下)的筆數。
|
||||
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0,與額度無關——單純候選總量)。
|
||||
scanned: number; // 本批掃出的候選筆數(受 limit 限制)。
|
||||
quota_limit: number; // 今日 backfill 額度上限(env.EMBED_BACKFILL_DAILY_LIMIT 或預設值)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的 backfill 額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有可嵌的候選但今天不再打 AI,等明天/調高上限)。
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,9 +321,14 @@ export interface BackfillResult {
|
||||
*/
|
||||
export async function backfillEmbeddings(
|
||||
env: Bindings,
|
||||
opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {},
|
||||
opts: SelectionCriteria & { limit?: number; reindex?: boolean; offset?: number } = {},
|
||||
): Promise<BackfillResult> {
|
||||
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0,
|
||||
quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
||||
const offset = Math.max(opts.offset ?? 0, 0);
|
||||
|
||||
@@ -210,21 +343,39 @@ export async function backfillEmbeddings(
|
||||
// 🔴 2026-08-05:**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。
|
||||
// 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活,
|
||||
// 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
|
||||
// 「挑哪一批」(Arcrun#85):owner_id/source/library/since/until 全部走同一套
|
||||
// selectionCriteriaPredicate,讓呼叫端(工作流)能表達時間分層與庫分層,不必等
|
||||
// base 幫忙決定;本函式不預設任何一層,caller 傳什麼就篩什麼。
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
|
||||
const params: unknown[] = [...sel.params];
|
||||
const where = conds.join(' AND ');
|
||||
|
||||
// D68:由新到舊——最可能被查到的最先補回來(見檔頭 DEFAULT_BACKFILL_DAILY_LIMIT 段的決策脈絡)。
|
||||
const res = await env.DB
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`)
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
||||
.bind(...params, limit, offset)
|
||||
.all<Entry>();
|
||||
const rows = res.results ?? [];
|
||||
const scanned = rows.length;
|
||||
|
||||
// D68:每日額度上限。額度是「這次呼叫要不要打 AI」的唯一守門——reindex 一樣要打 AI.run,
|
||||
// 同樣受限(不因為是 reindex 就例外,會打 Workers AI 的動作都算)。
|
||||
const dailyCap = backfillDailyLimit(env);
|
||||
let usedToday = 0;
|
||||
try {
|
||||
usedToday = await getBackfillUsageToday(env.DB);
|
||||
} catch {
|
||||
usedToday = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連 backfill 都不做
|
||||
}
|
||||
const remainingQuota = Math.max(0, dailyCap - usedToday);
|
||||
|
||||
let processed = 0;
|
||||
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||
const candidates = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||
// 額度截斷:candidates 已按 created_at DESC 排序,取前 remainingQuota 筆=優先保留最新的。
|
||||
const embeddable = candidates.slice(0, remainingQuota);
|
||||
const quotaExceeded = candidates.length > embeddable.length;
|
||||
|
||||
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
||||
const texts = embeddable.map((e) => (e.content ?? '').trim());
|
||||
const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] };
|
||||
@@ -246,8 +397,18 @@ export async function backfillEmbeddings(
|
||||
await env.VECTORIZE.upsert(vectors);
|
||||
const ids = vectors.map((v) => v.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run();
|
||||
// content_hash 順手蓋成現行模型(世代戳記,見 reconcileEmbedGeneration)。
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`)
|
||||
.bind(embedModel(env), ...ids)
|
||||
.run();
|
||||
processed = vectors.length;
|
||||
try {
|
||||
await addBackfillUsage(env.DB, processed);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的嵌入(別讓 bookkeeping 故障吞掉已做的工);
|
||||
// 代價是下次呼叫可能少算一點用量——比「明明做了卻沒生效」安全(誠實限制,mindset §7)。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +420,16 @@ export async function backfillEmbeddings(
|
||||
// 非 reindex:predicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。
|
||||
// reindex:predicate 不含 is_embedded,COUNT 恆等於總數 → 改用 offset 分頁計 remaining(否則永不終止)。
|
||||
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
|
||||
return { enabled: true, processed, skipped: scanned - processed, remaining, scanned };
|
||||
return {
|
||||
enabled: true,
|
||||
processed,
|
||||
skipped: scanned - processed,
|
||||
remaining,
|
||||
scanned,
|
||||
quota_limit: dailyCap,
|
||||
quota_used_today: usedToday + processed,
|
||||
quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */
|
||||
@@ -283,6 +453,128 @@ export async function backfillStatus(
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
|
||||
export interface ReconcileResult {
|
||||
enabled: boolean;
|
||||
checked: number; // 本批「真的核對+寫回」的筆數(受下方 D1 額度截斷後的量)。
|
||||
confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。
|
||||
reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。
|
||||
remaining: number; // 本次之後仍待核對的筆數(不受額度影響,可重複呼叫直到 0)。
|
||||
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
|
||||
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與標庫 backfill 共用,見 maintenance-quota.ts)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有候選但今天不再寫 D1,等明天/調高上限)。
|
||||
}
|
||||
|
||||
/**
|
||||
* 世代核對(Generation reconciliation,D68 配套修復,2026-08-11)。
|
||||
*
|
||||
* 背景:`is_embedded=1` 只代表「曾經對某個 Vectorize index 嵌過」,不保證是**現行**的
|
||||
* index/模型(見檔頭 2026-08-03 換代註解:換模型必須換 index,舊向量收不進新 index、也刪不掉)。
|
||||
* 從備份整批灌回的資料尤其會帶著對**已退役索引**(例:768 維 `arcrun-kbdb-embed`)的
|
||||
* `is_embedded=1`——現行 backfill 的預設路徑(只補 `is_embedded=0`)永遠不會碰它們,
|
||||
* 語意搜尋對現行(1024 維 `arcrun-kbdb-embed-m3`)索引而言永遠搜不到那批東西,畫面不會說壞掉。
|
||||
*
|
||||
* 做法:不猜(`is_embedded` 本身此刻不可信),直接問現行 Vectorize index「這些 id 真的在你這嗎」
|
||||
* (`env.VECTORIZE.getByIds`,ground truth,而非比對 content_hash 字串本身——後者在這次修復
|
||||
* 之前從未被寫過,所有既有 is_embedded=1 的列 content_hash 皆為 NULL,無法只憑字串判斷「哪些是
|
||||
* 這次修復前的正常資料、哪些是真正的舊世代殘留」,必須問 Vectorize 本身):
|
||||
* - 真的在現行 index → 只是這次修復之前的正常資料,沒補寫過 content_hash。補標記,不重打 AI
|
||||
* (不浪費額度在已經正確的資料上)。
|
||||
* - 不在現行 index → 對現行 index 而言等於沒嵌過,重置 is_embedded=0、清空 content_hash,
|
||||
* 交回正常 backfill 佇列(下一輪照樣受「新到舊」排序+每日額度上限保護,不特別優待)。
|
||||
*
|
||||
* 不消耗 Workers AI 額度:零 AI.run,只有一次 D1 掃描 + 一次 Vectorize.getByIds + D1 寫回。
|
||||
*
|
||||
* D69(Arcrun#85,2026-08-11 leo 逐行複核找到的破口):**這一步雖不打 AI,但逐筆寫 D1**——
|
||||
* 每個候選最多消耗一次 row write(補標 content_hash 或重置 is_embedded,兩條路互斥、恰好一次),
|
||||
* 47 萬筆候選 ≈ 4.7 倍 D1 100,000 rows written/日免費額度。與標庫 backfill(同樣是多筆 D1
|
||||
* write、不打 AI)共用 `actions/maintenance-quota.ts` 的同一顆每日計數器——不共用的話,
|
||||
* 補標庫時會把這裡的閘繞過去(反之亦然)。額度用完 → 誠實截斷候選,不再寫 D1,等明天。
|
||||
*
|
||||
* 「挑哪一批」:owner_id/library/since/until 走 SelectionCriteria(同 backfillEmbeddings/
|
||||
* backfillEntryLibraryTags 共用的篩選形狀),讓時間分層/庫分層能從外面指定。
|
||||
*/
|
||||
export async function reconcileEmbedGeneration(
|
||||
env: Bindings,
|
||||
opts: Pick<SelectionCriteria, 'owner_id' | 'library' | 'since' | 'until'> & { limit?: number } = {},
|
||||
): Promise<ReconcileResult> {
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0,
|
||||
scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
const currentModel = embedModel(env);
|
||||
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [
|
||||
'is_embedded = 1',
|
||||
'(content_hash IS NULL OR content_hash != ?)',
|
||||
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
|
||||
...sel.conds,
|
||||
];
|
||||
const params: unknown[] = [currentModel, ...sel.params];
|
||||
const where = conds.join(' AND ');
|
||||
|
||||
const res = await env.DB
|
||||
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write,直接照剩餘額度砍候選清單長度。
|
||||
const budget = await maintenanceBudgetToday(env, env.DB);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
const checked = ids.length;
|
||||
|
||||
let confirmed_current = 0;
|
||||
let reset_to_pending = 0;
|
||||
if (ids.length > 0 && env.VECTORIZE) {
|
||||
const found = await env.VECTORIZE.getByIds(ids);
|
||||
const foundIds = new Set(found.map((v) => v.id));
|
||||
const presentIds = ids.filter((id) => foundIds.has(id));
|
||||
const missingIds = ids.filter((id) => !foundIds.has(id));
|
||||
|
||||
if (presentIds.length > 0) {
|
||||
const ph = presentIds.map(() => '?').join(',');
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`)
|
||||
.bind(currentModel, ...presentIds)
|
||||
.run();
|
||||
confirmed_current = presentIds.length;
|
||||
}
|
||||
if (missingIds.length > 0) {
|
||||
const ph = missingIds.map(() => '?').join(',');
|
||||
await env.DB
|
||||
.prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`)
|
||||
.bind(...missingIds)
|
||||
.run();
|
||||
reset_to_pending = missingIds.length;
|
||||
}
|
||||
}
|
||||
|
||||
const remRow = await env.DB
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
const written = confirmed_current + reset_to_pending;
|
||||
try {
|
||||
await addMaintenanceUsage(env.DB, written);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的核對寫入(精神同 backfillEmbeddings 的
|
||||
// addBackfillUsage 失敗處理——寧可下次呼叫少算一點用量,也不讓計數故障吞掉已做的工)。
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0,
|
||||
scanned, quota_limit: budget.limit, quota_used_today: budget.used + written, quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfTestResult {
|
||||
enabled: boolean; // embed 模組是否開(binding 都在)
|
||||
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed';
|
||||
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest, reconcileEmbedGeneration } from '../embed';
|
||||
|
||||
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -16,11 +16,14 @@ const OFF_HINT =
|
||||
'語義補嵌需先開 embed 模組(Vectorize+AI binding)。叫 CC「幫我開語義查詢」(設 kbdb_embed:true + redeploy 注入 binding)後再呼叫本端點。';
|
||||
|
||||
// POST /embed/backfill — batch-embed existing embeddable entries with is_embedded=0.
|
||||
// body(皆選填):{ limit?:1-100(預設25), owner_id?, source?, reindex?, offset? }。
|
||||
// body(皆選填):{ limit?:1-100(預設25), owner_id?, source?, library?, since?, until?, reindex?, offset? }。
|
||||
// 冪等:重跑不會重複嵌(已 is_embedded=1 的不再入選;upsert 同 id 冪等)。
|
||||
// 分批:單次最多 limit 筆;回傳 remaining>0 表示還有 → 重複呼叫直到 remaining=0。
|
||||
// reindex:true(Arcrun#11):改重推「所有 embeddable」既有向量(含 is_embedded=1),
|
||||
// 讓事後建立的 Vectorize metadata index 收錄它們(否則帶過濾語意查詢回 0);配 offset 分頁。
|
||||
// library/since/until(Arcrun#85,2026-08-11):「挑哪一批」從外面指定——時間分層
|
||||
// (今天/本週/半年前)與庫分層(有查詢紀錄的庫優先)共用同一套 SelectionCriteria,
|
||||
// 由呼叫端(工作流)決定這次要補的是哪一批,不是資料層焊死單一排序(見 embed.ts 檔頭說明)。
|
||||
// 模組未開 → 409 + capability_hint(不假綠)。
|
||||
embedRoutes.post('/backfill', async (c) => {
|
||||
if (!embedEnabled(c.env)) {
|
||||
@@ -33,6 +36,9 @@ embedRoutes.post('/backfill', async (c) => {
|
||||
limit?: number | string;
|
||||
owner_id?: string;
|
||||
source?: string;
|
||||
library?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
reindex?: boolean;
|
||||
offset?: number | string;
|
||||
};
|
||||
@@ -40,6 +46,9 @@ embedRoutes.post('/backfill', async (c) => {
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
owner_id: body.owner_id || undefined,
|
||||
source: body.source || undefined,
|
||||
library: body.library || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
// reindex(Arcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
|
||||
reindex: body.reindex === true,
|
||||
offset: body.offset !== undefined ? Number(body.offset) : undefined,
|
||||
@@ -56,6 +65,38 @@ embedRoutes.get('/backfill/status', async (c) => {
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// POST /embed/reconcile — 世代核對(D68 配套修復,2026-08-11;D69 額度節流同日補上):
|
||||
// 對「is_embedded=1 但 content_hash 非現行模型」的候選,問現行 Vectorize index 是否真的收錄;
|
||||
// 真的在 → 補標 content_hash(不打 AI);不在 → 重置 is_embedded=0,回到正常 /embed/backfill 佇列。
|
||||
// 解「從備份整批灌回、帶著對已退役索引的 is_embedded=1,永遠不被 backfill 碰到」這個坑。
|
||||
// body(皆選填):{ limit?:1-200(預設50), owner_id?, library?, since?, until? }。重複呼叫直到 remaining=0。
|
||||
// D69:每筆候選最多消耗一次 D1 row write,與 POST /entries/backfill-library 共用同一顆每日
|
||||
// 「背景維護 D1 寫入」額度(見 actions/maintenance-quota.ts)——額度用完會誠實回
|
||||
// quota_exceeded:true 並停手,不會把當天 D1 免費額度燒穿(2026-08-11 leo 逐行複核找到的破口)。
|
||||
embedRoutes.post('/reconcile', async (c) => {
|
||||
if (!embedEnabled(c.env)) {
|
||||
return c.json(
|
||||
{ success: false, error: 'embed module not enabled (need VECTORIZE + AI bindings)', capability_hint: OFF_HINT },
|
||||
409,
|
||||
);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
limit?: number | string;
|
||||
owner_id?: string;
|
||||
library?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
};
|
||||
const result = await reconcileEmbedGeneration(c.env, {
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
owner_id: body.owner_id || undefined,
|
||||
library: body.library || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
|
||||
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
|
||||
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
EmbedQueryFailedError,
|
||||
} from '../embed';
|
||||
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
|
||||
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../actions/library-backfill';
|
||||
|
||||
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -367,6 +368,64 @@ entryRoutes.patch('/deprecate-by-library', async (c) => {
|
||||
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
||||
});
|
||||
|
||||
// POST /entries/backfill-library — 標庫補存量(Arcrun#85 二次裁決/相關票 Arcrun#87,2026-08-11)。
|
||||
// body(必填 library + owner_id):{ library, owner_id, page_names?(string[],精準比對,
|
||||
// leo 定案的正解——見 actions/library-backfill.ts 檔頭「拿原稿遍歷」), entry_type?,
|
||||
// source_prefix?, page_name_prefix?(後兩者為過渡 fallback,精度不如 page_names),
|
||||
// since?, until?, limit?(1-500,預設100) }。
|
||||
// 冪等:只選「目前未標記 library」的候選;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
|
||||
// budget:與 /embed/reconcile 共用同一顆每日 D1 寫入額度(見 actions/maintenance-quota.ts)——
|
||||
// 兩者都是「多筆 D1 write、不打 AI」的背景維護操作,不共用額度的話補存量會把世代核對的閘繞過去。
|
||||
// base 對內容語意無知:不猜「這批該貼哪個庫」,呼叫端(ingest/#87)決定 library 與篩選條件;
|
||||
// owner_id 必填(同 /entries/deprecate-by-library 的既有防線——批次改一大片既有資料不准無租戶範圍地掃)。
|
||||
// 此路由必須在 '/:id' 之前註冊,否則 'backfill-library' 會被當成 id 參數。
|
||||
entryRoutes.post('/backfill-library', async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
library?: string;
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
page_names?: string[];
|
||||
source_prefix?: string;
|
||||
page_name_prefix?: string;
|
||||
since?: number | string;
|
||||
until?: number | string;
|
||||
limit?: number | string;
|
||||
};
|
||||
const library = String(body.library ?? '').trim();
|
||||
const ownerId = String(body.owner_id ?? '').trim();
|
||||
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
|
||||
try {
|
||||
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
|
||||
library,
|
||||
owner_id: ownerId,
|
||||
entry_type: body.entry_type || undefined,
|
||||
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : undefined,
|
||||
source_prefix: body.source_prefix || undefined,
|
||||
page_name_prefix: body.page_name_prefix || undefined,
|
||||
since: body.since !== undefined ? Number(body.since) : undefined,
|
||||
until: body.until !== undefined ? Number(body.until) : undefined,
|
||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /entries/backfill-library/status?owner_id=&entry_type=&source_prefix=&page_name_prefix=&since=&until=
|
||||
// — 符合條件、目前未標記 library 的筆數(backfill 前後都能查,判斷還剩多少)。
|
||||
entryRoutes.get('/backfill-library/status', async (c) => {
|
||||
const status = await libraryBackfillStatus(c.env.DB, {
|
||||
owner_id: c.req.query('owner_id') || undefined,
|
||||
entry_type: c.req.query('entry_type') || undefined,
|
||||
source_prefix: c.req.query('source_prefix') || undefined,
|
||||
page_name_prefix: c.req.query('page_name_prefix') || undefined,
|
||||
since: c.req.query('since') ? Number(c.req.query('since')) : undefined,
|
||||
until: c.req.query('until') ? Number(c.req.query('until')) : undefined,
|
||||
});
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
entryRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
+15
-1
@@ -24,6 +24,18 @@ export type Bindings = {
|
||||
// kbdb/src/actions/execution-log.ts DEFAULT_DAILY_LIMIT 說明)。未設 → 20000
|
||||
// (D1 100,000 rows written/日的 20%,留 80% 給知識卡 entries)。
|
||||
EXECUTION_LOG_DAILY_WRITE_LIMIT?: string;
|
||||
// embed backfill 每日軟上限(D68,2026-08-11:補算向量照時間新到舊、且每天有額度上限)。
|
||||
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日 10,000 免費 neurons(見頂層
|
||||
// wiki ops-facts.md);backfill 是背景低優先動作,自設軟上限不把當天額度燒光。未設 → 見
|
||||
// kbdb/src/embed.ts DEFAULT_BACKFILL_DAILY_LIMIT 說明(含選值算式,非拍腦袋)。
|
||||
EMBED_BACKFILL_DAILY_LIMIT?: string;
|
||||
// 背景維護寫入(reconcile 世代核對 + 標庫 backfill)共用的 D1 每日寫入軟上限
|
||||
// (Arcrun#85 D69 修法,2026-08-11:兩者都是「多筆 D1 row write、不打 AI」的操作,
|
||||
// 各自不設防都會單獨燒穿 D1 100,000 rows/日免費額度——reconcile 47 萬筆 candidate
|
||||
// ≈ 4.7 倍全日額度,已在票上實測;標庫 backfill 同樣是逐筆 D1 write,若各管各的,
|
||||
// 補標庫時會把 reconcile 的閘繞過去。兩者共用同一顆「今天還剩多少」計數器。
|
||||
// 未設 → 見 kbdb/src/actions/maintenance-quota.ts DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT。
|
||||
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT?: string;
|
||||
};
|
||||
|
||||
export type EntryType =
|
||||
@@ -35,7 +47,9 @@ export type EntryType =
|
||||
| 'workflow'
|
||||
| 'recipe_stat'
|
||||
| 'execution_log'
|
||||
| 'execution_log_usage';
|
||||
| 'execution_log_usage'
|
||||
| 'embed_backfill_usage'
|
||||
| 'kbdb_maintenance_usage';
|
||||
|
||||
export interface Entry {
|
||||
id: string;
|
||||
|
||||
+410
-108
@@ -1,130 +1,196 @@
|
||||
// embed backfill — D68(2026-08-11 leo 拍板:補算向量照時間新到舊、且每天有額度上限)測試。
|
||||
//
|
||||
// 測試策略比照 execution-log.test.ts/library-map.test.ts:真 SQLite(node:sqlite)套
|
||||
// migrations/0001_base.sql 原檔,比手刻假 DB 更硬——驗的是真實 SQL 語意(ORDER BY/WHERE/
|
||||
// JSON 函式),不是「以為 SQL 長這樣」。AI/VECTORIZE 仍是輕量假物件(Cloudflare binding,
|
||||
// 不是 SQL,沒有真 runtime 可套)。
|
||||
//
|
||||
// 覆蓋 D68 三條 + is_embedded 世代旗標坑,四項都要有實測輸出:
|
||||
// 1. 由新到舊:造 created_at 跨時間的候選,證明先被處理的是最新那幾筆
|
||||
// 2. 每日額度上限真的擋:cap 設小,跑到撞上限,證明它停手不再打 AI(不是繼續打)
|
||||
// 3. 帶著舊世代旗標(is_embedded=1 但對應已退役索引)的列補得回來
|
||||
// 4. 現有 idempotent/batching/reindex 行為不因本次改動而壞掉
|
||||
//
|
||||
// 本檔在 kbdb/tests/(牆外,非 kbdb/src|migrations),依 D38 kbdb-api-wall-guard 規則,
|
||||
// 所有直接對 SQLite 治具下 SQL 的行都集中在下面幾個 helper(每行標 kbdb-sql-ok 留痕)——
|
||||
// 這是**測試治具本身**(node:sqlite→D1 shim,模擬 D1 binding),不是牆外業務邏輯繞過 API。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { backfillEmbeddings, backfillStatus, embedEnabled } from '../src/embed';
|
||||
import type { Bindings, Entry } from '../src/types';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
backfillEmbeddings,
|
||||
backfillStatus,
|
||||
embedEnabled,
|
||||
reconcileEmbedGeneration,
|
||||
} from '../src/embed';
|
||||
import type { Bindings, Entry, EntryType } from '../src/types';
|
||||
|
||||
// ── Minimal in-memory fakes (no Workers runtime) ─────────────────────────────
|
||||
// The fake DB interprets only the 3 statement shapes backfill issues, by keyword:
|
||||
// SELECT * ... LIMIT ? OFFSET ? → candidate rows (embeddable & non-empty content;
|
||||
// +is_embedded=0 for normal backfill, any for reindex)
|
||||
// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids
|
||||
// SELECT COUNT(*) → count of matching candidates
|
||||
// embeddable = metadata.embed===true & non-empty content(reindex predicate)。
|
||||
function isEmbeddable(e: Entry): boolean {
|
||||
if (!e.content || e.content.trim() === '') return false;
|
||||
try {
|
||||
const m = JSON.parse(e.metadata_json ?? 'null');
|
||||
return m?.embed === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// normal backfill 額外要求 is_embedded=0(漏網補嵌)。
|
||||
function isCandidate(e: Entry): boolean {
|
||||
return e.is_embedded === 0 && isEmbeddable(e);
|
||||
}
|
||||
const CURRENT_MODEL = '@cf/baai/bge-m3'; // embed.ts DEFAULT_EMBED_MODEL(未 export,測試按文件字面核對)
|
||||
|
||||
function makeFakeDB(store: Entry[]) {
|
||||
const prepare = (sql: string) => {
|
||||
// reindex predicate 不含 "is_embedded = 0" → 依 SQL 判斷該用哪個 filter(對齊 embed.ts)。
|
||||
const pred = /is_embedded = 0/.test(sql) ? isCandidate : isEmbeddable;
|
||||
let bound: unknown[] = [];
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { bound = args; return stmt; },
|
||||
async all<T>() {
|
||||
// SELECT * ... LIMIT ? OFFSET ? (bound tail = [..., limit, offset])
|
||||
const offset = Number(bound[bound.length - 1]);
|
||||
const limit = Number(bound[bound.length - 2]);
|
||||
const results = store.filter(pred).slice(offset, offset + limit) as unknown as T[];
|
||||
return { results };
|
||||
},
|
||||
async first<T>() {
|
||||
// SELECT COUNT(*) as c ...
|
||||
const c = store.filter(pred).length;
|
||||
return { c } as unknown as T;
|
||||
},
|
||||
// ── node:sqlite → D1 介面最小 adapter(同 execution-log.test.ts/library-map.test.ts 手法)──
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:');
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
|
||||
function stmt(sql: string, params: unknown[]) {
|
||||
const s = {
|
||||
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async run() {
|
||||
// UPDATE entries SET is_embedded = 1 WHERE id IN (...) → bound = ids
|
||||
const ids = new Set(bound.map(String));
|
||||
for (const e of store) if (ids.has(e.id)) e.is_embedded = 1;
|
||||
return { success: true };
|
||||
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
|
||||
return { success: true, meta: { changes: r.changes } };
|
||||
},
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
return s;
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function mkEntry(id: string, content: string | null, embed: boolean, is_embedded = 0): Entry {
|
||||
return {
|
||||
id, content, entry_type: 'workflow', owner_id: 'leo', parent_id: null, page_name: null,
|
||||
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
|
||||
confidence: null, metadata_json: JSON.stringify({ embed }), created_at: 1, updated_at: 1,
|
||||
};
|
||||
// ── 測試專用資料存取 helper:把所有直接下 SQL 的呼叫收斂到這裡(每行標記留痕)──────────
|
||||
function insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
|
||||
const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料
|
||||
e.id,
|
||||
e.content === undefined ? 'x' : e.content, // 區分「沒提供」(undefined→預設'x') 與「顯式 null」(保留 null)
|
||||
|
||||
(e.entry_type ?? 'workflow') as EntryType,
|
||||
e.owner_id ?? 'leo',
|
||||
e.content_hash ?? null,
|
||||
e.is_embedded ?? 0,
|
||||
e.metadata_json ?? JSON.stringify({ embed: true }),
|
||||
e.created_at,
|
||||
e.created_at,
|
||||
).run();
|
||||
}
|
||||
|
||||
function makeEnv(store: Entry[], withBindings: boolean): Bindings {
|
||||
async function getRow(db: D1Database, id: string): Promise<{ id: string; is_embedded: number; content_hash: string | null } | null> {
|
||||
return db.prepare('SELECT id, is_embedded, content_hash FROM entries WHERE id = ?').bind(id).first(); // kbdb-sql-ok:測試治具讀回斷言用
|
||||
}
|
||||
|
||||
async function listAllRows(db: D1Database): Promise<{ id: string; is_embedded: number; content_hash: string | null }[]> {
|
||||
const res = await db.prepare('SELECT id, is_embedded, content_hash FROM entries').all<{ id: string; is_embedded: number; content_hash: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用
|
||||
return res.results;
|
||||
}
|
||||
|
||||
async function listEmbeddedIds(db: D1Database): Promise<string[]> {
|
||||
const res = await db.prepare("SELECT id FROM entries WHERE is_embedded = 1").all<{ id: string }>(); // kbdb-sql-ok:測試治具讀回斷言用
|
||||
return res.results.map((r) => r.id);
|
||||
}
|
||||
|
||||
async function countUsageRows(db: D1Database): Promise<{ id: string; entry_type: string }[]> {
|
||||
const res = await db.prepare("SELECT id, entry_type FROM entries WHERE entry_type = 'embed_backfill_usage'").all<{ id: string; entry_type: string }>(); // kbdb-sql-ok:測試治具驗證「不新增表、單列 upsert」
|
||||
return res.results;
|
||||
}
|
||||
|
||||
function makeEnv(db: D1Database, opts: { withBindings?: boolean; dailyLimit?: string; maintenanceLimit?: string } = {}): Bindings {
|
||||
const withBindings = opts.withBindings ?? true;
|
||||
const upserts: { id: string }[] = [];
|
||||
const aiCalls: string[][] = [];
|
||||
const getByIdsCalls: string[][] = [];
|
||||
const vectorizeStore = new Set<string>(); // ids "present" in the current (fake) Vectorize index
|
||||
const env = {
|
||||
DB: makeFakeDB(store),
|
||||
DB: db,
|
||||
ENVIRONMENT: 'test',
|
||||
EMBED_BACKFILL_DAILY_LIMIT: opts.dailyLimit,
|
||||
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
|
||||
...(withBindings
|
||||
? {
|
||||
AI: { async run(_m: string, i: { text: string[] }) { aiCalls.push(i.text); return { data: i.text.map(() => [0.1, 0.2, 0.3]) }; } },
|
||||
VECTORIZE: { async upsert(v: { id: string }[]) { upserts.push(...v); return { count: v.length }; } },
|
||||
AI: {
|
||||
async run(_m: string, i: { text: string[] }) {
|
||||
aiCalls.push(i.text);
|
||||
return { data: i.text.map(() => [0.1, 0.2, 0.3]) };
|
||||
},
|
||||
},
|
||||
VECTORIZE: {
|
||||
async upsert(v: { id: string }[]) {
|
||||
upserts.push(...v);
|
||||
for (const x of v) vectorizeStore.add(x.id);
|
||||
return { count: v.length };
|
||||
},
|
||||
async getByIds(ids: string[]) {
|
||||
getByIdsCalls.push(ids);
|
||||
return ids.filter((id) => vectorizeStore.has(id)).map((id) => ({ id, values: [0.1] }));
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as unknown as Bindings;
|
||||
(env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__upserts = upserts;
|
||||
(env as unknown as { __upserts: unknown[]; __ai: unknown[] }).__ai = aiCalls;
|
||||
const bag = env as unknown as {
|
||||
__upserts: unknown[]; __ai: unknown[]; __getByIds: unknown[];
|
||||
__seedVectorized: (ids: string[]) => void;
|
||||
};
|
||||
bag.__upserts = upserts;
|
||||
bag.__ai = aiCalls;
|
||||
bag.__getByIds = getByIdsCalls;
|
||||
bag.__seedVectorized = (ids: string[]) => { for (const id of ids) vectorizeStore.add(id); };
|
||||
return env;
|
||||
}
|
||||
|
||||
describe('backfillEmbeddings', () => {
|
||||
it('module off → enabled:false, no-op (誠實不假綠)', async () => {
|
||||
const store = [mkEntry('e1', 'hello', true)];
|
||||
const env = makeEnv(store, false);
|
||||
function aiCallsOf(env: Bindings): string[][] {
|
||||
return (env as unknown as { __ai: string[][] }).__ai;
|
||||
}
|
||||
function upsertsOf(env: Bindings): { id: string }[] {
|
||||
return (env as unknown as { __upserts: { id: string }[] }).__upserts;
|
||||
}
|
||||
function seedVectorized(env: Bindings, ids: string[]): void {
|
||||
(env as unknown as { __seedVectorized: (ids: string[]) => void }).__seedVectorized(ids);
|
||||
}
|
||||
|
||||
describe('backfillEmbeddings — 模組未開', () => {
|
||||
it('誠實不假綠:no-op,含新增的 quota 欄位', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', created_at: 1 });
|
||||
const env = makeEnv(db, { withBindings: false });
|
||||
expect(embedEnabled(env)).toBe(false);
|
||||
const r = await backfillEmbeddings(env);
|
||||
expect(r).toEqual({ enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 });
|
||||
expect(store[0].is_embedded).toBe(0); // untouched
|
||||
expect(r).toEqual({
|
||||
enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0,
|
||||
quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('embeds embeddable+is_embedded=0 entries, marks is_embedded=1, batches AI+upsert', async () => {
|
||||
const store = [
|
||||
mkEntry('e1', 'doorbell workflow', true),
|
||||
mkEntry('e2', 'notify workflow', true),
|
||||
mkEntry('e3', 'not tagged', false), // embed:false → not a candidate
|
||||
mkEntry('e4', 'already done', true, 1), // is_embedded=1 → not a candidate
|
||||
mkEntry('e5', ' ', true), // empty content → not embeddable
|
||||
];
|
||||
const env = makeEnv(store, true);
|
||||
describe('backfillEmbeddings — 基本行為(沿用既有覆蓋,改動後仍要綠)', () => {
|
||||
it('embeds embeddable+is_embedded=0 entries, marks is_embedded=1 + content_hash,批次 AI+upsert', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'doorbell workflow', created_at: 1 });
|
||||
insertEntry(db, { id: 'e2', content: 'notify workflow', created_at: 2 });
|
||||
insertEntry(db, { id: 'e3', content: 'not tagged', created_at: 3, metadata_json: JSON.stringify({ embed: false }) });
|
||||
insertEntry(db, { id: 'e4', content: 'already done', created_at: 4, is_embedded: 1 });
|
||||
insertEntry(db, { id: 'e5', content: null, created_at: 5 }); // NULL content → 排除,非本次改動範圍的既有行為
|
||||
const env = makeEnv(db);
|
||||
const r = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.processed).toBe(2); // only e1,e2
|
||||
expect(r.remaining).toBe(0); // nothing left embeddable
|
||||
expect(store.find((e) => e.id === 'e1')!.is_embedded).toBe(1);
|
||||
expect(store.find((e) => e.id === 'e2')!.is_embedded).toBe(1);
|
||||
expect(store.find((e) => e.id === 'e3')!.is_embedded).toBe(0);
|
||||
const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts;
|
||||
expect(upserts.map((u) => u.id).sort()).toEqual(['e1', 'e2']);
|
||||
const ai = (env as unknown as { __ai: string[][] }).__ai;
|
||||
expect(ai.length).toBe(1); // single batched AI.run for the whole batch
|
||||
expect(ai[0].length).toBe(2);
|
||||
expect(r.processed).toBe(2); // only e1, e2
|
||||
expect(r.remaining).toBe(0);
|
||||
const rows = await listAllRows(db);
|
||||
const byId = Object.fromEntries(rows.map((x) => [x.id, x]));
|
||||
expect(byId.e1.is_embedded).toBe(1);
|
||||
expect(byId.e1.content_hash).toBe(CURRENT_MODEL); // 世代戳記有寫
|
||||
expect(byId.e2.is_embedded).toBe(1);
|
||||
expect(byId.e3.is_embedded).toBe(0);
|
||||
expect(upsertsOf(env).map((u) => u.id).sort()).toEqual(['e1', 'e2']);
|
||||
expect(aiCallsOf(env).length).toBe(1);
|
||||
expect(aiCallsOf(env)[0].length).toBe(2);
|
||||
});
|
||||
|
||||
it('idempotent: re-run after all embedded processes nothing', async () => {
|
||||
const store = [mkEntry('e1', 'x', true)];
|
||||
const env = makeEnv(store, true);
|
||||
it('idempotent:全部嵌完後重跑不再處理', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'x', created_at: 1 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
await backfillEmbeddings(env);
|
||||
const r2 = await backfillEmbeddings(env);
|
||||
expect(r2.processed).toBe(0);
|
||||
expect(r2.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('batches via limit → remaining reported so caller can loop to zero', async () => {
|
||||
const store = [mkEntry('a', 'x', true), mkEntry('b', 'y', true), mkEntry('c', 'z', true)];
|
||||
const env = makeEnv(store, true);
|
||||
it('batches via limit → remaining 讓 caller 可重複呼叫到 0', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', content: 'x', created_at: 1 });
|
||||
insertEntry(db, { id: 'b', content: 'y', created_at: 2 });
|
||||
insertEntry(db, { id: 'c', content: 'z', created_at: 3 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
const r1 = await backfillEmbeddings(env, { limit: 2 });
|
||||
expect(r1.processed).toBe(2);
|
||||
expect(r1.remaining).toBe(1);
|
||||
@@ -133,34 +199,270 @@ describe('backfillEmbeddings', () => {
|
||||
expect(r2.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('reindex: 重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0(Arcrun#11)', async () => {
|
||||
// 三筆皆已 is_embedded=1(既有向量):正常 backfill 不會碰(pending=0),reindex 要全部重推
|
||||
// 讓事後建立的 Vectorize metadata index 收錄。
|
||||
const store = [
|
||||
mkEntry('a', 'x', true, 1), mkEntry('b', 'y', true, 1), mkEntry('c', 'z', true, 1),
|
||||
];
|
||||
const env = makeEnv(store, true);
|
||||
// 正常 backfill:沒有 is_embedded=0 → 什麼都不做(證明「不重推就補不到」)。
|
||||
it('reindex:重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0(Arcrun#11)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', content: 'x', created_at: 1, is_embedded: 1 });
|
||||
insertEntry(db, { id: 'b', content: 'y', created_at: 2, is_embedded: 1 });
|
||||
insertEntry(db, { id: 'c', content: 'z', created_at: 3, is_embedded: 1 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
const normal = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(normal.processed).toBe(0);
|
||||
// reindex 分頁:第一批 2 筆、remaining=1;第二批 1 筆、remaining=0。
|
||||
expect(normal.processed).toBe(0); // 沒有 is_embedded=0 → 什麼都不做
|
||||
const r1 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 0 });
|
||||
expect(r1.processed).toBe(2);
|
||||
expect(r1.remaining).toBe(1);
|
||||
const r2 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 2 });
|
||||
expect(r2.processed).toBe(1);
|
||||
expect(r2.remaining).toBe(0);
|
||||
const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts;
|
||||
expect(upserts.map((u) => u.id).sort()).toEqual(['a', 'b', 'c']);
|
||||
expect(upsertsOf(env).map((u) => u.id).sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('status reports pending/embedded counts', async () => {
|
||||
const store = [mkEntry('e1', 'x', true), mkEntry('e2', 'y', true, 1)];
|
||||
const env = makeEnv(store, true);
|
||||
it('status 回報 pending/embedded 計數', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'x', created_at: 1 });
|
||||
insertEntry(db, { id: 'e2', content: 'y', created_at: 2, is_embedded: 1 });
|
||||
const env = makeEnv(db);
|
||||
const s = await backfillStatus(env);
|
||||
// fake first() returns candidate count for pending; embedded query also runs through
|
||||
// the same COUNT fake, so this asserts the call path works (enabled:true).
|
||||
expect(s.enabled).toBe(true);
|
||||
expect(typeof s.pending).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('D68①:由新到舊排序(實測,不是推論)', () => {
|
||||
it('候選跨時間分佈時,先被嵌入的是 created_at 最新的那幾筆', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// 刻意亂序插入,證明排序看的是 created_at 不是插入順序 / id 字母序
|
||||
insertEntry(db, { id: 'old-2024', content: 'half year ago', created_at: 1_000 });
|
||||
insertEntry(db, { id: 'today', content: 'written today', created_at: 100_000 });
|
||||
insertEntry(db, { id: 'mid-2025', content: 'a few months ago', created_at: 50_000 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
// limit=1:一次只能處理一筆,若排序正確,該筆必須是 'today'(created_at 最大)
|
||||
const r = await backfillEmbeddings(env, { limit: 1 });
|
||||
expect(r.processed).toBe(1);
|
||||
const embedded = await listEmbeddedIds(db);
|
||||
expect(embedded).toEqual(['today']);
|
||||
expect(aiCallsOf(env)[0]).toEqual(['written today']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D68②:每日額度上限真的擋(把上限設小,跑到撞上限)', () => {
|
||||
it('額度耗盡後停手,不再繼續打 AI;未耗盡的仍優先保留最新的(額度截斷 + 排序疊加)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e-oldest', content: 'c1', created_at: 1 });
|
||||
insertEntry(db, { id: 'e-old', content: 'c2', created_at: 2 });
|
||||
insertEntry(db, { id: 'e-new', content: 'c3', created_at: 3 });
|
||||
insertEntry(db, { id: 'e-newest', content: 'c4', created_at: 4 });
|
||||
const env = makeEnv(db, { dailyLimit: '2' }); // 上限設得比候選數(4)小
|
||||
const r = await backfillEmbeddings(env, { limit: 100 });
|
||||
|
||||
// 停手,不是繼續打:AI 只被叫過一次,且只帶 2 筆文字(不是全部 4 筆)
|
||||
expect(aiCallsOf(env).length).toBe(1);
|
||||
expect(aiCallsOf(env)[0].length).toBe(2);
|
||||
expect(r.processed).toBe(2);
|
||||
expect(r.quota_limit).toBe(2);
|
||||
expect(r.quota_used_today).toBe(2);
|
||||
expect(r.quota_exceeded).toBe(true); // 還有候選但今天不再打 AI
|
||||
|
||||
// 被留下處理的兩筆是最新的(e-newest, e-new),不是隨機或最舊的
|
||||
const embedded = await listEmbeddedIds(db);
|
||||
expect(embedded.sort()).toEqual(['e-new', 'e-newest']);
|
||||
|
||||
// 再跑一次(同一天):額度已用完,processed=0,AI 呼叫次數仍是 1(沒有再打)
|
||||
const r2 = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(r2.processed).toBe(0);
|
||||
expect(r2.quota_exceeded).toBe(true);
|
||||
expect(aiCallsOf(env).length).toBe(1); // 沒有新增呼叫
|
||||
});
|
||||
|
||||
it('額度上限被拿掉時本測試會變紅(反向驗證:測試真的在測東西,不是恆真)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
for (let i = 1; i <= 5; i++) insertEntry(db, { id: `e${i}`, content: `c${i}`, created_at: i });
|
||||
// 不設 dailyLimit(用預設 1800,遠大於 5)→ 全部應被處理,模擬「上限被拿掉」的行為
|
||||
const env = makeEnv(db);
|
||||
const r = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(r.processed).toBe(5);
|
||||
expect(r.quota_exceeded).toBe(false);
|
||||
// 對照組:把上限設到比候選數小,行為必須不同(證明上一組「額度=2」的測試不是巧合)
|
||||
const db2 = makeSqliteD1();
|
||||
for (let i = 1; i <= 5; i++) insertEntry(db2, { id: `e${i}`, content: `c${i}`, created_at: i });
|
||||
const env2 = makeEnv(db2, { dailyLimit: '2' });
|
||||
const r2 = await backfillEmbeddings(env2, { limit: 100 });
|
||||
expect(r2.processed).toBe(2);
|
||||
expect(r2.processed).not.toBe(r.processed); // 有 cap vs 沒 cap 必須不同,否則 cap 沒在起作用
|
||||
});
|
||||
|
||||
it('額度計數跨呼叫累加,換日字串變動即歸零(不新增表,entries 單列 upsert)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'c1', created_at: 1 });
|
||||
insertEntry(db, { id: 'e2', content: 'c2', created_at: 2 });
|
||||
const env = makeEnv(db, { dailyLimit: '10' });
|
||||
const r1 = await backfillEmbeddings(env, { limit: 1 });
|
||||
expect(r1.quota_used_today).toBe(1);
|
||||
const r2 = await backfillEmbeddings(env, { limit: 1 });
|
||||
expect(r2.quota_used_today).toBe(2); // 累加,不是每次重算成當批數
|
||||
// 驗證只有一列計數器,且落在既有三表(entries),沒有新表
|
||||
const usageRows = await countUsageRows(db);
|
||||
expect(usageRows.length).toBe(1);
|
||||
expect(usageRows[0].id).toMatch(/^embed-backfill-usage:\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D68③:leo21c 資料還原情境——is_embedded=1 但對應已退役索引的列補得回來', () => {
|
||||
it('reconcile:確認在現行 index 的只補 content_hash,不打 AI', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// 模擬「這次修復之前」就已經正確嵌入現行 index 的資料:is_embedded=1、content_hash 從未寫過(NULL)
|
||||
insertEntry(db, { id: 'ok-legacy', content: 'x', created_at: 1, is_embedded: 1, content_hash: null });
|
||||
const env = makeEnv(db);
|
||||
seedVectorized(env, ['ok-legacy']); // 現行 index 真的有它
|
||||
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r.checked).toBe(1);
|
||||
expect(r.confirmed_current).toBe(1);
|
||||
expect(r.reset_to_pending).toBe(0);
|
||||
expect(aiCallsOf(env).length).toBe(0); // 沒有打 AI
|
||||
|
||||
const row = await getRow(db, 'ok-legacy');
|
||||
expect(row!.is_embedded).toBe(1); // 沒被誤重置
|
||||
expect(row!.content_hash).toBe(CURRENT_MODEL); // 補標記
|
||||
});
|
||||
|
||||
it('reconcile 揪出真正對舊索引的殘留 → 重置回 pending → 正常 backfill 真的把它補回來(端到端)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// leo21c 情境:從備份整批灌回,is_embedded=1 但這是對已退役 768 維索引說的;
|
||||
// 現行(1024 維)Vectorize index 裡沒有這個向量(不呼叫 seedVectorized)。
|
||||
insertEntry(db, { id: 'restored-stale', content: '從備份還原的舊卡片', created_at: 999, is_embedded: 1, content_hash: null });
|
||||
const env = makeEnv(db);
|
||||
|
||||
// step 1:reconcile 應該發現它不在現行 index,重置成 pending
|
||||
const r1 = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r1.checked).toBe(1);
|
||||
expect(r1.confirmed_current).toBe(0);
|
||||
expect(r1.reset_to_pending).toBe(1);
|
||||
const midRow = await getRow(db, 'restored-stale');
|
||||
expect(midRow!.is_embedded).toBe(0);
|
||||
expect(midRow!.content_hash).toBe(null);
|
||||
expect(r1.remaining).toBe(0); // 處理完,沒有更多待核對的了
|
||||
|
||||
// step 2:正常 backfill 現在會撿到它(因為 is_embedded=0 了),真的打 AI 補回來
|
||||
const r2 = await backfillEmbeddings(env, { limit: 100 });
|
||||
expect(r2.processed).toBe(1);
|
||||
expect(aiCallsOf(env).length).toBe(1);
|
||||
expect(aiCallsOf(env)[0]).toEqual(['從備份還原的舊卡片']);
|
||||
|
||||
const finalRow = await getRow(db, 'restored-stale');
|
||||
expect(finalRow!.is_embedded).toBe(1); // 補回來了
|
||||
expect(finalRow!.content_hash).toBe(CURRENT_MODEL); // 蓋上現行世代戳記,下次 reconcile 不會再選到它
|
||||
});
|
||||
|
||||
it('已經是現行世代(content_hash 等於現行模型)的列不會被 reconcile 重複選中', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'fresh', content: 'x', created_at: 1, is_embedded: 1, content_hash: CURRENT_MODEL });
|
||||
const env = makeEnv(db);
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r.checked).toBe(0);
|
||||
expect(r.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('模組未開 → 誠實回 enabled:false,不假裝', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'e1', content: 'x', created_at: 1, is_embedded: 1 });
|
||||
const env = makeEnv(db, { withBindings: false });
|
||||
const r = await reconcileEmbedGeneration(env);
|
||||
expect(r).toEqual({
|
||||
enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0,
|
||||
scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arcrun#85 D69:reconcile 的 D1 寫入額度(與標庫 backfill 共用的計數器)', () => {
|
||||
it('額度耗盡後 reconcile 停手:不再寫 D1,quota_exceeded=true', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'r1', content: 'c1', created_at: 1, is_embedded: 1, content_hash: null });
|
||||
insertEntry(db, { id: 'r2', content: 'c2', created_at: 2, is_embedded: 1, content_hash: null });
|
||||
insertEntry(db, { id: 'r3', content: 'c3', created_at: 3, is_embedded: 1, content_hash: null });
|
||||
const env = makeEnv(db, { maintenanceLimit: '2' }); // 上限比候選數(3)小
|
||||
seedVectorized(env, ['r1', 'r2', 'r3']); // 全在現行 index(confirmed_current 路徑,仍是 D1 write)
|
||||
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r.scanned).toBe(3); // 掃到 3 筆候選
|
||||
expect(r.checked).toBe(2); // 但只處理了額度允許的 2 筆
|
||||
expect(r.confirmed_current).toBe(2);
|
||||
expect(r.quota_limit).toBe(2);
|
||||
expect(r.quota_used_today).toBe(2);
|
||||
expect(r.quota_exceeded).toBe(true);
|
||||
|
||||
// 只有 2 筆真的被寫回 content_hash(最新的兩筆,ORDER BY created_at DESC)
|
||||
const rows = await listAllRows(db);
|
||||
const byId = Object.fromEntries(rows.map((x) => [x.id, x]));
|
||||
expect(byId.r3.content_hash).toBe(CURRENT_MODEL);
|
||||
expect(byId.r2.content_hash).toBe(CURRENT_MODEL);
|
||||
expect(byId.r1.content_hash).toBe(null); // 額度用完,沒輪到它
|
||||
|
||||
// 再跑一次(同一天):額度已用完,checked=0
|
||||
const r2 = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r2.checked).toBe(0);
|
||||
expect(r2.quota_exceeded).toBe(true);
|
||||
});
|
||||
|
||||
it('額度上限被拿掉時本測試會變紅(反向驗證,同 D68② 手法)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
for (let i = 1; i <= 5; i++) insertEntry(db, { id: `r${i}`, content: `c${i}`, created_at: i, is_embedded: 1, content_hash: null });
|
||||
const env = makeEnv(db); // 不設 maintenanceLimit → 用預設 20000,遠大於 5,全部應被處理
|
||||
seedVectorized(env, ['r1', 'r2', 'r3', 'r4', 'r5']);
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100 });
|
||||
expect(r.checked).toBe(5);
|
||||
expect(r.quota_exceeded).toBe(false);
|
||||
|
||||
const db2 = makeSqliteD1();
|
||||
for (let i = 1; i <= 5; i++) insertEntry(db2, { id: `r${i}`, content: `c${i}`, created_at: i, is_embedded: 1, content_hash: null });
|
||||
const env2 = makeEnv(db2, { maintenanceLimit: '2' });
|
||||
seedVectorized(env2, ['r1', 'r2', 'r3', 'r4', 'r5']);
|
||||
const r2 = await reconcileEmbedGeneration(env2, { limit: 100 });
|
||||
expect(r2.checked).toBe(2);
|
||||
expect(r2.checked).not.toBe(r.checked); // 有 cap vs 沒 cap 必須不同,否則 cap 沒在作用
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arcrun#85:「挑哪一批」可以從外面指定(SelectionCriteria:since/until/library)', () => {
|
||||
it('backfillEmbeddings 帶 since/until 只補時間窗內的候選', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'too-old', content: 'x', created_at: 100 });
|
||||
insertEntry(db, { id: 'in-window-1', content: 'y', created_at: 500 });
|
||||
insertEntry(db, { id: 'in-window-2', content: 'z', created_at: 800 });
|
||||
insertEntry(db, { id: 'too-new', content: 'w', created_at: 1500 });
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
const r = await backfillEmbeddings(env, { limit: 100, since: 400, until: 1000 });
|
||||
expect(r.processed).toBe(2);
|
||||
expect((await listEmbeddedIds(db)).sort()).toEqual(['in-window-1', 'in-window-2']);
|
||||
});
|
||||
|
||||
it('backfillEmbeddings 帶 library 只補該庫的候選(未標記歸 general)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'finance-1', content: 'x', created_at: 1, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
|
||||
insertEntry(db, { id: 'hr-1', content: 'y', created_at: 2, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||||
insertEntry(db, { id: 'untagged', content: 'z', created_at: 3 }); // 無 library → general
|
||||
const env = makeEnv(db, { dailyLimit: '100' });
|
||||
const r = await backfillEmbeddings(env, { limit: 100, library: 'finance' });
|
||||
expect(r.processed).toBe(1);
|
||||
expect(await listEmbeddedIds(db)).toEqual(['finance-1']);
|
||||
|
||||
const r2 = await backfillEmbeddings(env, { limit: 100, library: 'general' });
|
||||
expect(r2.processed).toBe(1);
|
||||
expect((await listEmbeddedIds(db)).sort()).toEqual(['finance-1', 'untagged']);
|
||||
});
|
||||
|
||||
it('reconcile 帶 since/until/library 同樣受篩選(同一套 SelectionCriteria,非獨立實作)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'old', content: 'x', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
|
||||
insertEntry(db, { id: 'new', content: 'y', created_at: 100, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
|
||||
insertEntry(db, { id: 'other-lib', content: 'z', created_at: 100, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||||
const env = makeEnv(db);
|
||||
seedVectorized(env, ['old', 'new', 'other-lib']);
|
||||
const r = await reconcileEmbedGeneration(env, { limit: 100, library: 'finance', since: 50 });
|
||||
expect(r.checked).toBe(1);
|
||||
const row = await getRow(db, 'new');
|
||||
expect(row!.content_hash).toBe(CURRENT_MODEL);
|
||||
const oldRow = await getRow(db, 'old');
|
||||
expect(oldRow!.content_hash).toBe(null); // 在時間窗外,沒被動到
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// 標庫 backfill(Arcrun#85 二次裁決,2026-08-11)測試。
|
||||
//
|
||||
// 測試策略比照 embed-backfill.test.ts:真 SQLite(node:sqlite)套 migrations/0001_base.sql
|
||||
// 原檔,驗真實 SQL 語意(json_set/WHERE/LIMIT),不是「以為 SQL 長這樣」。
|
||||
//
|
||||
// 覆蓋:
|
||||
// 1. 只補「符合條件、目前未標記 library」的候選;已標記的不動(冪等)
|
||||
// 2. owner_id 必填(缺了要拋錯,防「補錯 owner 等於白做」——2026-08-11 leo 直令)
|
||||
// 3. source_prefix/page_name_prefix/since/until 篩選條件真的在篩
|
||||
// 4. 與 reconcileEmbedGeneration 共用同一顆每日 D1 寫入額度(D69 的核心訴求:
|
||||
// 補標不能把世代核對的閘繞過去,反之亦然)
|
||||
//
|
||||
// 本檔在 kbdb/tests/(牆外),依 D38 kbdb-api-wall-guard 規則,直接對 SQLite 治具下 SQL
|
||||
// 的行集中在 helper(測試治具本身,非牆外業務邏輯繞過 API,每行標 kbdb-sql-ok 留痕)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../src/actions/library-backfill';
|
||||
import { reconcileEmbedGeneration } from '../src/embed';
|
||||
import type { Bindings, Entry, EntryType } from '../src/types';
|
||||
|
||||
// ── node:sqlite → D1 介面最小 adapter(同 embed-backfill.test.ts 手法)──────────────
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:');
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
|
||||
function stmt(sql: string, params: unknown[]) {
|
||||
const s = {
|
||||
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async run() {
|
||||
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
|
||||
return { success: true, meta: { changes: r.changes } };
|
||||
},
|
||||
};
|
||||
return s;
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
|
||||
const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, page_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料
|
||||
e.id,
|
||||
e.content === undefined ? 'x' : e.content,
|
||||
(e.entry_type ?? 'block') as EntryType,
|
||||
e.owner_id ?? 'bfezv28v',
|
||||
e.content_hash ?? null,
|
||||
e.is_embedded ?? 0,
|
||||
e.metadata_json === undefined ? null : e.metadata_json,
|
||||
e.page_name ?? null,
|
||||
e.created_at,
|
||||
e.created_at,
|
||||
).run();
|
||||
}
|
||||
|
||||
async function getLibrary(db: D1Database, id: string): Promise<string | null> {
|
||||
const row = await db.prepare("SELECT json_extract(metadata_json, '$.library') AS library FROM entries WHERE id = ?").bind(id).first<{ library: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用
|
||||
return row?.library ?? null;
|
||||
}
|
||||
|
||||
function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
|
||||
return {
|
||||
DB: db,
|
||||
ENVIRONMENT: 'test',
|
||||
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
describe('backfillEntryLibraryTags — 基本行為', () => {
|
||||
it('只標記符合條件、目前未標記 library 的候選;已標記的不動', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1 }); // 無 metadata_json → 未標記
|
||||
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({}) }); // 有 metadata_json 但無 library
|
||||
insertEntry(db, { id: 'c', created_at: 3, metadata_json: JSON.stringify({ library: 'hr' }) }); // 已標記,不該被動
|
||||
const env = makeEnv(db);
|
||||
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||||
expect(r.tagged).toBe(2);
|
||||
expect(r.remaining).toBe(0);
|
||||
expect(await getLibrary(db, 'a')).toBe('finance');
|
||||
expect(await getLibrary(db, 'b')).toBe('finance');
|
||||
expect(await getLibrary(db, 'c')).toBe('hr'); // 未被覆寫
|
||||
});
|
||||
|
||||
it('冪等:全部標記完後重跑不再處理', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1 });
|
||||
const env = makeEnv(db);
|
||||
await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||||
const r2 = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||||
expect(r2.tagged).toBe(0);
|
||||
expect(r2.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('owner_id 缺了要拋錯(防補錯 owner 等於白做,2026-08-11 leo 直令)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1 });
|
||||
const env = makeEnv(db);
|
||||
await expect(
|
||||
backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: '' }),
|
||||
).rejects.toThrow(/owner_id/);
|
||||
});
|
||||
|
||||
it('library 缺了要拋錯', async () => {
|
||||
const db = makeSqliteD1();
|
||||
const env = makeEnv(db);
|
||||
await expect(
|
||||
backfillEntryLibraryTags(db, env, { library: '', owner_id: 'bfezv28v' }),
|
||||
).rejects.toThrow(/library/);
|
||||
});
|
||||
|
||||
it('owner_id 篩選:只動指定租戶的資料,其他租戶不受影響(跨租戶隔離)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'mine', created_at: 1, owner_id: 'bfezv28v' });
|
||||
insertEntry(db, { id: 'theirs', created_at: 2, owner_id: 'someone-else' });
|
||||
const env = makeEnv(db);
|
||||
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
|
||||
expect(r.tagged).toBe(1);
|
||||
expect(await getLibrary(db, 'mine')).toBe('finance');
|
||||
expect(await getLibrary(db, 'theirs')).toBe(null); // 別的租戶完全沒被動到
|
||||
});
|
||||
|
||||
it('source_prefix/page_name_prefix/since/until 篩選真的在篩', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'match-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/kb/foo.md' }) });
|
||||
insertEntry(db, { id: 'other-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/other/bar.md' }) });
|
||||
const r1 = await backfillEntryLibraryTags(db, makeEnv(db), {
|
||||
library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea://Leo/kb/',
|
||||
});
|
||||
expect(r1.tagged).toBe(1);
|
||||
expect(await getLibrary(db, 'match-source')).toBe('kb');
|
||||
expect(await getLibrary(db, 'other-source')).toBe(null);
|
||||
|
||||
const db2 = makeSqliteD1();
|
||||
insertEntry(db2, { id: 'in-window', created_at: 500, page_name: 'wiki/foo' });
|
||||
insertEntry(db2, { id: 'out-window', created_at: 5000, page_name: 'wiki/bar' });
|
||||
const r2 = await backfillEntryLibraryTags(db2, makeEnv(db2), {
|
||||
library: 'wiki', owner_id: 'bfezv28v', page_name_prefix: 'wiki/', since: 0, until: 1000,
|
||||
});
|
||||
expect(r2.tagged).toBe(1);
|
||||
expect(await getLibrary(db2, 'in-window')).toBe('wiki');
|
||||
expect(await getLibrary(db2, 'out-window')).toBe(null);
|
||||
});
|
||||
|
||||
it('page_names 精準比對(leo 定案的正解:拿 Gitea 原稿卡名逐批遍歷點名)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1, page_name: 'card-alpha' });
|
||||
insertEntry(db, { id: 'b', created_at: 2, page_name: 'card-beta' });
|
||||
insertEntry(db, { id: 'c', created_at: 3, page_name: 'card-gamma' }); // 不在點名清單內
|
||||
const r = await backfillEntryLibraryTags(db, makeEnv(db), {
|
||||
library: 'kb', owner_id: 'bfezv28v', page_names: ['card-alpha', 'card-beta'],
|
||||
});
|
||||
expect(r.tagged).toBe(2);
|
||||
expect(await getLibrary(db, 'a')).toBe('kb');
|
||||
expect(await getLibrary(db, 'b')).toBe('kb');
|
||||
expect(await getLibrary(db, 'c')).toBe(null); // 沒被點名,不動
|
||||
});
|
||||
|
||||
it('libraryBackfillStatus 回報待補標筆數', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'a', created_at: 1 });
|
||||
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({ library: 'hr' }) });
|
||||
const s = await libraryBackfillStatus(db, { owner_id: 'bfezv28v' });
|
||||
expect(s.pending).toBe(1); // 只有 'a' 未標記
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arcrun#85 D69:標庫 backfill 與 reconcile 共用同一顆 D1 每日寫入額度', () => {
|
||||
it('reconcile 先消耗額度 → 標庫 backfill 看到的剩餘額度真的變少', async () => {
|
||||
const db = makeSqliteD1();
|
||||
// reconcile 的候選:is_embedded=1 且 content_hash 非現行世代
|
||||
// library 已標記('hr')→ 不會被下面的標庫 backfill 選中,讓兩種候選池互不重疊,
|
||||
// 才能單純驗證「額度共用」本身,不被「標庫候選也吃到 reconcile 資料」干擾。
|
||||
insertEntry(db, { id: 'reconcile-1', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||||
insertEntry(db, { id: 'reconcile-2', created_at: 2, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
|
||||
// 標庫的候選:未標記 library
|
||||
insertEntry(db, { id: 'tag-1', created_at: 3 });
|
||||
insertEntry(db, { id: 'tag-2', created_at: 4 });
|
||||
insertEntry(db, { id: 'tag-3', created_at: 5 });
|
||||
|
||||
const maintenanceLimit = '3'; // 5 個候選(2 reconcile + 3 tag),額度只夠 3 個
|
||||
const reconcileEnv = {
|
||||
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
|
||||
AI: { async run() { return { data: [] }; } },
|
||||
VECTORIZE: {
|
||||
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); }, // 全部視為現行 index 已有
|
||||
async upsert() { return { count: 0 }; },
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
|
||||
// 先跑 reconcile:吃掉 2 筆額度(3 - 2 = 1 剩)
|
||||
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
|
||||
expect(rc.checked).toBe(2);
|
||||
expect(rc.quota_used_today).toBe(2);
|
||||
|
||||
// 標庫 backfill 用同一顆 DB/同一個每日上限:只剩 1 筆額度可用,即使候選有 3 筆
|
||||
const tagEnv = makeEnv(db, { maintenanceLimit });
|
||||
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
|
||||
expect(tagResult.scanned).toBe(3); // 3 筆候選都掃到了
|
||||
expect(tagResult.tagged).toBe(1); // 但只剩 1 筆額度,只標了 1 筆
|
||||
expect(tagResult.quota_exceeded).toBe(true);
|
||||
expect(tagResult.quota_used_today).toBe(3); // 2(reconcile)+ 1(本次)= 3,額度用滿
|
||||
});
|
||||
|
||||
it('反過來也一樣:標庫 backfill 先消耗額度 → reconcile 看到的剩餘額度真的變少', async () => {
|
||||
const db = makeSqliteD1();
|
||||
insertEntry(db, { id: 'tag-1', created_at: 1 });
|
||||
insertEntry(db, { id: 'tag-2', created_at: 2 });
|
||||
insertEntry(db, { id: 'reconcile-1', created_at: 3, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true }) });
|
||||
|
||||
const maintenanceLimit = '2';
|
||||
const tagEnv = makeEnv(db, { maintenanceLimit });
|
||||
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
|
||||
expect(tagResult.tagged).toBe(2); // 額度剛好夠標完兩筆
|
||||
|
||||
const reconcileEnv = {
|
||||
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
|
||||
AI: { async run() { return { data: [] }; } },
|
||||
VECTORIZE: {
|
||||
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); },
|
||||
async upsert() { return { count: 0 }; },
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
|
||||
expect(rc.scanned).toBe(1); // 有 1 筆候選
|
||||
expect(rc.checked).toBe(0); // 但額度已被標庫 backfill 用光,reconcile 一筆都動不了
|
||||
expect(rc.quota_exceeded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ensureFreshLibraryMaps,
|
||||
LIBRARY_MAP_SLOTS,
|
||||
} from '../src/actions/library-map';
|
||||
import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud';
|
||||
import { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud';
|
||||
import { createEntry } from '../src/actions/entry-crud';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
@@ -292,6 +292,39 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動
|
||||
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
|
||||
});
|
||||
|
||||
it('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
|
||||
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 status,recomputeLibraryMap 只算
|
||||
// active——只要庫裡混了 superseded triplet,兩邊算出來的數字永遠對不上,
|
||||
// ensureFreshLibraryMaps 就永遠判定 stale,每次讀地圖都重算、每次都新建一筆 record。
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active
|
||||
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰
|
||||
|
||||
const { app, env } = makeApp(db);
|
||||
|
||||
// 第一次讀:資料是新的(從沒 recompute 過),觸發一次重算是正常的。
|
||||
const first = await app.request('/map', {}, env);
|
||||
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); // 只算 active 那筆
|
||||
|
||||
const countAfterFirst = (await searchByTemplate(db, 'library_map')).length;
|
||||
|
||||
// 第二次讀:中間沒有任何寫入動作。修好之前,這裡會再次判定 stale 並多新建一筆 record。
|
||||
const second = await app.request('/map', {}, env);
|
||||
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
|
||||
|
||||
const countAfterSecond = (await searchByTemplate(db, 'library_map')).length;
|
||||
expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record
|
||||
|
||||
// 第三次也一樣,多讀幾次確認不是巧合。
|
||||
await app.request('/map', {}, env);
|
||||
const countAfterThird = (await searchByTemplate(db, 'library_map')).length;
|
||||
expect(countAfterThird).toBe(countAfterFirst);
|
||||
});
|
||||
|
||||
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// 搜尋框裡的 `%` 與 `_` 是「要找的字」,不是萬用字元 —— Arcrun#94(2026-08-12)
|
||||
//
|
||||
// 病徵(leo 回報):搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字**無關**的東西。
|
||||
// 根因:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有兩個
|
||||
// 萬用字元 `%`/`_` 且**沒有預設跳脫字元** ⇒ 使用者打的符號被當成 pattern 語法。
|
||||
//
|
||||
// 舊病,不是 08-10 斷詞(search-tokenize.test.ts)引進的:pattern 從來就是這樣拼的。
|
||||
// 之前關鍵字搜尋幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
|
||||
//
|
||||
// 測試策略:**用真 SQLite 跑真的 SQL**(node:sqlite,與 library-map/embed-backfill 同款 adapter)。
|
||||
// 只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
|
||||
// 每組驗收都同時跑「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡(legacyPattern)。
|
||||
//
|
||||
// 註:直接對 SQLite 治具下 SQL 的行集中在下面的 helper(測試治具本身,非牆外業務邏輯繞過
|
||||
// API),每行標 kbdb-sql-ok 留痕——與 embed-backfill/library-backfill 等既有測試同慣例。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
escapeLikeLiteral,
|
||||
buildContentLike,
|
||||
buildSearchScore,
|
||||
searchEntries,
|
||||
createEntry,
|
||||
} from '../src/actions/entry-crud';
|
||||
|
||||
// ── node:sqlite → D1 最小 adapter ────────────────────────────────────────────
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok:記憶體測試替身,非真 KBDB D1
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
|
||||
function stmt(sql: string, params: unknown[]) {
|
||||
return {
|
||||
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||
async all<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async first<T>() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
async run() { raw.prepare(sql).run(...(params as never[])); return { success: true }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||
};
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
/** 舊寫法(本次修掉的那個):使用者輸入直接內插、LIKE 不帶 ESCAPE。前後對照用。 */
|
||||
const legacyPattern = (q: string) => `%${q}%`;
|
||||
|
||||
/** 對真 SQLite 跑一次 `content LIKE ?`,回命中的 content(要不要帶 ESCAPE 可選)。 */
|
||||
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
|
||||
const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
|
||||
const res = await db
|
||||
.prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
|
||||
.bind(pattern)
|
||||
.all<{ content: string }>();
|
||||
return (res.results ?? []).map((x) => x.content);
|
||||
}
|
||||
|
||||
/** 把 searchEntries 送出的 SQL 側錄下來(不改行為,只是中間插一層)。 */
|
||||
function sqlSpy(db: D1Database): { spy: D1Database; sqls: string[] } {
|
||||
const sqls: string[] = [];
|
||||
const spy = {
|
||||
prepare: (sql: string) => { sqls.push(sql); return db.prepare(sql); }, // kbdb-sql-ok:測試治具側錄,轉呼叫同一顆治具 DB
|
||||
} as unknown as D1Database;
|
||||
return { spy, sqls };
|
||||
}
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限(承 2026-08-03 的 500 修復)
|
||||
|
||||
// 一組刻意設計的語料:每一筆都用來分辨「字面命中」與「萬用字元誤中」。
|
||||
const CORPUS = [
|
||||
'毛利率 100% 達成', // 含字面 %
|
||||
'共有 100 個待辦項目', // 含 100 但不含 %,`%100%%` 會誤中它
|
||||
'owner_id 是租戶隔離的欄位', // 含字面 _
|
||||
'ownerXid 是打錯的欄位名', // `_` 當萬用字元才會中
|
||||
'路徑 C:\\_temp 底下', // 含字面「反斜線+底線」(跳脫字元本身 + 萬用字元)
|
||||
'路徑 C:\\Xtemp 底下', // 反斜線後接任一字元——`_` 漏成萬用字元才會中
|
||||
'完全無關的一筆內容', // 對照組:什麼都不該中
|
||||
];
|
||||
|
||||
async function seeded(): Promise<D1Database> {
|
||||
const db = makeSqliteD1();
|
||||
for (const [i, content] of CORPUS.entries()) {
|
||||
await createEntry(db, { id: `e${i}`, content, entry_type: 'block', owner_id: 'leo' });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('① 前後對照:使用者打什麼字,就照那些字找', () => {
|
||||
it('`100%`:舊寫法把 % 當萬用字元、連「100 個待辦」都撈回來;現行只回真的含 100% 的', async () => {
|
||||
const db = await seeded();
|
||||
const before = await likeHits(db, legacyPattern('100%'), false);
|
||||
const after = await likeHits(db, buildContentLike('100%').params[0], true);
|
||||
|
||||
expect(before).toEqual(['毛利率 100% 達成', '共有 100 個待辦項目']); // ← 病徵:多了不相干的
|
||||
expect(after).toEqual(['毛利率 100% 達成']); // ← 只有真的含「100%」的
|
||||
});
|
||||
|
||||
it('`owner_id`:舊寫法 _ 匹配任一字元、把 ownerXid 也撈回來;現行只回字面相符的', async () => {
|
||||
const db = await seeded();
|
||||
expect(await likeHits(db, legacyPattern('owner_id'), false)).toEqual([
|
||||
'owner_id 是租戶隔離的欄位',
|
||||
'ownerXid 是打錯的欄位名', // ← 病徵
|
||||
]);
|
||||
expect(await likeHits(db, buildContentLike('owner_id').params[0], true)).toEqual([
|
||||
'owner_id 是租戶隔離的欄位',
|
||||
]);
|
||||
});
|
||||
|
||||
it('只打一個 `%` 或 `_`:舊寫法把**整個庫**倒回來(leo 回報的那個畫面)', async () => {
|
||||
const db = await seeded();
|
||||
// `%%%` 匹配任何字串;`%_%` 只要有一個字元就中 ⇒ 兩者都等於「全庫」
|
||||
expect(await likeHits(db, legacyPattern('%'), false)).toHaveLength(CORPUS.length);
|
||||
expect(await likeHits(db, legacyPattern('_'), false)).toHaveLength(CORPUS.length);
|
||||
|
||||
// 現行:只回真的含那個字元的(`_` 有兩筆——欄位名那筆與路徑那筆,兩筆都是字面命中)
|
||||
expect(await likeHits(db, buildContentLike('%').params[0], true)).toEqual(['毛利率 100% 達成']);
|
||||
expect(await likeHits(db, buildContentLike('_').params[0], true)).toEqual([
|
||||
'owner_id 是租戶隔離的欄位',
|
||||
'路徑 C:\\_temp 底下',
|
||||
]);
|
||||
});
|
||||
|
||||
it('走完整搜尋路徑(searchEntries,含斷詞與算分)結果一致——不是只有底層函式對', async () => {
|
||||
const db = await seeded();
|
||||
expect((await searchEntries(db, '100%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
|
||||
expect((await searchEntries(db, 'owner_id', 'leo')).map((e) => e.content)).toEqual([
|
||||
'owner_id 是租戶隔離的欄位',
|
||||
]);
|
||||
// 單打一個符號:以前是全庫,現在是「真的含那個字的那一筆」
|
||||
expect((await searchEntries(db, '%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
|
||||
expect((await searchEntries(db, '_', 'leo')).map((e) => e.content).sort()).toEqual(
|
||||
['owner_id 是租戶隔離的欄位', '路徑 C:\\_temp 底下'].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('② 邊界:跳脫字元本身(`\\`)也必須跳脫', () => {
|
||||
// 為什麼這組必須存在:宣告了 ESCAPE 之後,`\` 就變成 pattern 裡有意義的字元。
|
||||
// 只跳脫 % 和 _、不跳脫 `\`,等於用新的漏洞換掉舊的——而且更難發現,因為它
|
||||
// **不會報錯**,只會靜靜地把後面那個字吃掉、去找一個使用者沒打過的字串。
|
||||
const halfDone = (q: string) => `%${q.replace(/[%_]/g, (c) => '\\' + c)}%`; // 只跳脫 %/_ 的假想修法
|
||||
|
||||
it('打 `C:\\`:不跳脫反斜線的話尾巴變成「字面 %」,反而找不到任何真正含 `C:\\` 的內容', async () => {
|
||||
const db = await seeded();
|
||||
// pattern `%C:\%` ⇒ 尾巴的 `\%` 被讀成「字面的 %」⇒ 實際去找 `C:%`,庫裡沒有 ⇒ 全漏
|
||||
expect(await likeHits(db, halfDone('C:\\'), true)).toEqual([]);
|
||||
expect(await likeHits(db, buildContentLike('C:\\').params[0], true)).toEqual([
|
||||
'路徑 C:\\_temp 底下',
|
||||
'路徑 C:\\Xtemp 底下',
|
||||
]);
|
||||
});
|
||||
|
||||
it('打 `C:\\_temp`:反斜線沒跳脫 ⇒ 它把 `_` 的跳脫吃掉,萬用字元漏回來、撈到不相干的', async () => {
|
||||
const db = await seeded();
|
||||
// `%C:\\_temp%`:`\\` 先被讀成「字面 \」,後面那個 `_` 就變回萬用字元 ⇒ C:\Xtemp 也中
|
||||
expect(await likeHits(db, halfDone('C:\\_temp'), true)).toEqual([
|
||||
'路徑 C:\\_temp 底下',
|
||||
'路徑 C:\\Xtemp 底下', // ← 使用者沒打過這個字
|
||||
]);
|
||||
expect(await likeHits(db, buildContentLike('C:\\_temp').params[0], true)).toEqual([
|
||||
'路徑 C:\\_temp 底下',
|
||||
]);
|
||||
});
|
||||
|
||||
it('打 `100\\%`:三個都不跳脫 ⇒ `\\%` 被讀成「字面 %」⇒ 去找 `100%`,跟他打的不一樣', async () => {
|
||||
const db = await seeded();
|
||||
// 原始寫法(一個都不跳脫)= pattern `%100\%%`:`\%`=字面 %、尾巴那個 `%`=萬用字元
|
||||
expect(await likeHits(db, legacyPattern('100\\%'), true)).toEqual(['毛利率 100% 達成']); // ← 找錯東西
|
||||
// 正解:庫裡沒有字面的 `100\%` ⇒ 就該零命中,而不是拿別的東西充數
|
||||
expect(await likeHits(db, buildContentLike('100\\%').params[0], true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('escapeLikeLiteral 只碰這三個字元,且不會把自己剛加的跳脫再跳脫一次', () => {
|
||||
expect(escapeLikeLiteral('100%')).toBe('100\\%');
|
||||
expect(escapeLikeLiteral('owner_id')).toBe('owner\\_id');
|
||||
expect(escapeLikeLiteral('C:\\')).toBe('C:\\\\');
|
||||
expect(escapeLikeLiteral('%_\\')).toBe('\\%\\_\\\\'); // 三個各自跳脫一次,不是兩次
|
||||
// LIKE 沒有 [] ? * 這些萬用字元(那是 GLOB/別的方言)⇒ 不該白白吃掉 byte 預算
|
||||
expect(escapeLikeLiteral('a[b]?c*d 中文')).toBe('a[b]?c*d 中文');
|
||||
});
|
||||
});
|
||||
|
||||
describe('③ 每個 LIKE 都要帶 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面', () => {
|
||||
it('buildContentLike/buildSearchScore 產生的謂詞都含 ESCAPE', () => {
|
||||
for (const c of buildContentLike('100%').conds) expect(c).toContain("ESCAPE '\\'");
|
||||
for (const c of buildContentLike('a'.repeat(200)).conds) expect(c).toContain("ESCAPE '\\'");
|
||||
expect(buildSearchScore('Gemini 逃生口').scoreExpr).toContain("ESCAPE '\\'");
|
||||
expect(buildSearchScore('。。。').scoreExpr).toContain("ESCAPE '\\'"); // 一個詞都拆不出來的退路
|
||||
});
|
||||
|
||||
it('沒有任何 `content LIKE ?` 是裸的(漏掉一個就等於那條路沒修)', async () => {
|
||||
const { spy, sqls } = sqlSpy(await seeded());
|
||||
for (const q of ['100%', 'Gemini 逃生口', '。。。', 'a'.repeat(200)]) await searchEntries(spy, q, 'leo');
|
||||
expect(sqls.length).toBeGreaterThan(0);
|
||||
for (const sql of sqls) expect(sql.match(/content LIKE \?(?! ESCAPE)/g) ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ESCAPE 宣告本身是合法 SQL(D1=SQLite;真的跑得起來,不是形狀對而已)', async () => {
|
||||
const db = await seeded();
|
||||
await expect(likeHits(db, '%100\\%%', true)).resolves.toEqual(['毛利率 100% 達成']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('④ 不退化:不含 % _ \\ 的查詢,行為與修改前逐字相同', () => {
|
||||
it('pattern 一個字都沒變(跳脫對這些字串是恆等變換)', () => {
|
||||
for (const q of ['語意檢索', 'arcrun', 'Gemini 逃生口', '為什麼今天額度用完']) {
|
||||
expect(escapeLikeLiteral(q)).toBe(q);
|
||||
}
|
||||
expect(buildContentLike('語意檢索').params).toEqual(['%語意檢索%']);
|
||||
expect(buildSearchScore('arcrun').scoreParams).toEqual(['%arcrun%']);
|
||||
expect(buildSearchScore('語意檢索').legacyShape).toBe(true); // 最熱路徑仍是單一 LIKE
|
||||
});
|
||||
|
||||
it('斷詞(08-10,Arcrun#84 已判定留下)沒被動到:問句照樣拆得開', () => {
|
||||
expect(buildSearchScore('Gemini 逃生口').scoreParams).toEqual(
|
||||
expect.arrayContaining(['%Gemini%', '%逃生口%']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('⑤ 跳脫會變長 ⇒ byte 預算要用「跳脫後」的長度算,否則退回 2026-08-03 那個 500', () => {
|
||||
it('滿是 % 的長查詢,每個 pattern 仍在 D1 的 50 bytes 上限內', () => {
|
||||
const qs = [
|
||||
'%'.repeat(200), // 每個字元跳脫後變 2 bytes
|
||||
'_'.repeat(60),
|
||||
'\\'.repeat(60),
|
||||
`${'%'.repeat(30)}中文${'_'.repeat(30)}`,
|
||||
'a'.repeat(24) + '%'.repeat(24), // 卡在舊上限附近的混合
|
||||
];
|
||||
for (const q of qs) {
|
||||
for (const p of buildContentLike(q).params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
const plan = buildSearchScore(q);
|
||||
expect(plan.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
|
||||
for (const p of plan.scoreParams) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
}
|
||||
});
|
||||
|
||||
it('48 個 `%`(跳脫前剛好在舊上限內)不會產生 98 bytes 的 pattern', () => {
|
||||
const q = '%'.repeat(48);
|
||||
expect(bytes(q)).toBe(48); // 用舊的算法看,它「在上限內」
|
||||
const m = buildContentLike(q);
|
||||
expect(m.split).toBe(true); // 用跳脫後的長度看,它必須被拆開
|
||||
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
});
|
||||
|
||||
it('拆片段仍切在字元邊界上,不會把跳脫序列切成半個', async () => {
|
||||
const db = await seeded();
|
||||
for (const p of buildContentLike(`${'%'.repeat(40)}中文${'_'.repeat(40)}`).params) {
|
||||
expect(p).not.toContain('\uFFFD');
|
||||
// 切壞的跳脫序列(尾巴是落單的 `\`)會讓 SQLite 把後面的 `%` 讀成字面 ⇒ 語意錯掉
|
||||
expect(/(^|[^\\])(\\\\)*\\%$/.test(p)).toBe(false);
|
||||
await expect(likeHits(db, p, true)).resolves.toBeDefined(); // 真的送得進 SQLite
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,16 @@ import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||
const MAX_PATTERN = 50; // D1 上限
|
||||
// 謂詞字串在 Arcrun#94 多了 ESCAPE 宣告(`content LIKE ? ESCAPE '\'`)。這裡跟著改的是
|
||||
// **比對用的常數**,不是放寬檢查——底下仍然逐字相等比對,只是比的是現在正確的那個字串。
|
||||
// pattern 本身('%語意檢索%')一個字都沒變:那句話裡沒有 % _ \,跳脫後與原文相同。
|
||||
const LIKE_PRED = "content LIKE ? ESCAPE '\\'";
|
||||
|
||||
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
|
||||
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
|
||||
const m = buildContentLike('語意檢索');
|
||||
expect(m.split).toBe(false);
|
||||
expect(m.conds).toEqual(['content LIKE ?']);
|
||||
expect(m.conds).toEqual([LIKE_PRED]);
|
||||
expect(m.params).toEqual(['%語意檢索%']);
|
||||
});
|
||||
|
||||
@@ -43,7 +47,7 @@ describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () =
|
||||
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
|
||||
expect(m.split).toBe(true);
|
||||
expect(m.conds.length).toBeGreaterThan(1);
|
||||
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
|
||||
expect(m.conds.every((c) => c === LIKE_PRED)).toBe(true);
|
||||
expect(m.params).toContain('%語意檢索%');
|
||||
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// 查詢斷詞 + 覆蓋率排序 —— 讓「AI 問一個問句」查得到東西(2026-08-10,Leo/mira#4)
|
||||
//
|
||||
// 病徵(總管在 leo21c 上實測,有對照組,非推論):
|
||||
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||
// ⇒ 對照組證明:查詢字串是**整串**拿去 LIKE 的,從來沒被拆開。
|
||||
// ⇒ 而 **AI 問的永遠是問句**,問句的詞不可能在原文裡剛好相鄰 ⇒ 這條路對 AI 恆為 0。
|
||||
//
|
||||
// 本檔守四件事:
|
||||
// ① 拆得開 —— 詞存在但不相鄰的問句要能命中
|
||||
// ② 不退化 —— 單詞查詢送出的 SQL 與舊版**逐字相同**(最熱路徑一個字都不能變)
|
||||
// ③ 不崩壞 —— 相關的排前面、雜訊尾巴被相對門檻砍掉,不是把整個庫撈回來
|
||||
// ④ 不再炸 —— 每個 LIKE pattern 仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復)
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
tokenizeQuery,
|
||||
buildSearchScore,
|
||||
applyRelativeCut,
|
||||
searchEntries,
|
||||
} from '../src/actions/entry-crud';
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限
|
||||
const termsOf = (q: string) => tokenizeQuery(q).map((t) => t.term);
|
||||
const weightOf = (q: string, term: string) => tokenizeQuery(q).find((t) => t.term === term)?.weight;
|
||||
|
||||
describe('① 拆得開:問句要被拆成詞', () => {
|
||||
it('「Gemini 逃生口」拆成兩個詞(就是驗收題本身)', () => {
|
||||
expect(termsOf('Gemini 逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||
});
|
||||
|
||||
it('沒有空白的 CJK/ASCII 交界也要切開(吸收 t95 normalizeCjkQuery 的用意)', () => {
|
||||
expect(termsOf('Gemini逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||
expect(termsOf('AI協作')).toEqual(expect.arrayContaining(['AI', '協作']));
|
||||
});
|
||||
|
||||
it('自然語言問句:虛詞被丟掉,只留實詞', () => {
|
||||
const t = termsOf('Gemini 在這套系統裡的角色是什麼?');
|
||||
expect(t).toEqual(expect.arrayContaining(['Gemini', '系統', '角色']));
|
||||
// 「這/的/是/什麼/套」是虛詞與量詞,不該變成查詢詞——否則會把整個庫撈回來
|
||||
for (const junk of ['這', '的', '是', '什麼', '套系統']) expect(t).not.toContain(junk);
|
||||
});
|
||||
|
||||
it('標點(含全形)當分隔,不會混進詞裡', () => {
|
||||
expect(termsOf('額度、向量化;為什麼?')).toEqual(expect.arrayContaining(['額度', '向量化']));
|
||||
for (const t of termsOf('額度、向量化;為什麼?')) {
|
||||
expect(t).not.toMatch(/[、;?,。]/);
|
||||
}
|
||||
});
|
||||
|
||||
it('超過 4 字的黏著長段補雙字組合(「專案管理工具」要能命中「專案管理」的寫法)', () => {
|
||||
expect(termsOf('專案管理工具')).toEqual(expect.arrayContaining(['專案', '管理']));
|
||||
});
|
||||
|
||||
it('任何查詢都至少留下一個詞,不會一個都不剩', () => {
|
||||
for (const q of ['是什麼', '的', '。。。', 'a']) {
|
||||
expect(buildSearchScore(q).scoreParams.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// 🔴 這組是「第一版寫錯、被自己的測試擋下來」的那個錯(2026-08-10):
|
||||
// 第一版拿虛詞去**切段**,結果 `向` 把「向量化」切成「量化」、`能` 把「功能」切掉
|
||||
// ⇒ 使用者真正要查的詞被切爛。沒有詞典的中文,切段一定誤傷實詞。
|
||||
// 現在的做法是「整段不動、只過濾雙字組合」,這組測試就是不准再走回去。
|
||||
it('實詞不准被虛詞切爛(向量化/功能/需要/使用者/規則/原因)', () => {
|
||||
expect(termsOf('額度、向量化;為什麼?')).toContain('向量化');
|
||||
expect(termsOf('這個功能是什麼')).toContain('功能');
|
||||
for (const [q, word] of [
|
||||
['系統需要什麼', '需要'], ['使用者是誰', '使用'], ['這個規則是什麼', '規則'],
|
||||
['原因是什麼', '原因'], ['更新了什麼', '更新'],
|
||||
] as const) {
|
||||
expect(termsOf(q)).toContain(word);
|
||||
}
|
||||
});
|
||||
|
||||
it('leo 的第二題「今天額度為什麼用完」要抓得到「額度」', () => {
|
||||
expect(termsOf('今天額度為什麼用完')).toContain('額度');
|
||||
});
|
||||
});
|
||||
|
||||
describe('② 不退化:單詞查詢與舊版逐字相同', () => {
|
||||
it('單一英文詞 → 一個詞、legacyShape、一個 LIKE', () => {
|
||||
const p = buildSearchScore('arcrun');
|
||||
expect(p.terms.map((t) => t.term)).toEqual(['arcrun']);
|
||||
expect(p.legacyShape).toBe(true);
|
||||
expect(p.scoreParams).toEqual(['%arcrun%']);
|
||||
});
|
||||
|
||||
it('單一四字中文詞(最常見的中文查詢)→ 仍然只有一個 LIKE', () => {
|
||||
// 這條是被既有 search-long-query.test.ts 擋出來的:雙字組合門檻若設 3,
|
||||
// 「語意檢索」會從 1 個 LIKE 變成 5 個 ⇒ 最熱路徑成本 ×5。
|
||||
const p = buildSearchScore('語意檢索');
|
||||
expect(p.legacyShape).toBe(true);
|
||||
expect(p.scoreParams).toEqual(['%語意檢索%']);
|
||||
});
|
||||
|
||||
it('searchEntries:單詞查詢送出的 SQL 只有一個 content LIKE,pattern 與舊版相同', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, '語意檢索', 'demo');
|
||||
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
|
||||
expect(captured[0].params[0]).toBe('%語意檢索%');
|
||||
});
|
||||
|
||||
it('單詞查詢分數全等 ⇒ 相對門檻一筆都砍不掉(排序退化回 updated_at DESC)', () => {
|
||||
const rows = Array.from({ length: 50 }, (_, i) => ({ id: `e${i}`, match_score: 3 }));
|
||||
expect(applyRelativeCut(rows)).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('③ 不崩壞:相關的排前面,雜訊尾巴砍掉', () => {
|
||||
it('詞愈長份量愈重(specific 壓過泛詞,這是相關性不崩壞的機制)', () => {
|
||||
const q = 'Gemini 在這套系統裡的角色是什麼?';
|
||||
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '系統')!);
|
||||
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '角色')!);
|
||||
});
|
||||
|
||||
it('整句相鄰另給重賞 ⇒ 字面命中永遠壓過零散命中(`local arcrun` 那 5 筆不會被稀釋)', () => {
|
||||
const p = buildSearchScore('local arcrun');
|
||||
expect(p.legacyShape).toBe(false);
|
||||
expect(p.scoreParams).toContain('%local arcrun%'); // 整句那一項存在
|
||||
const bonus = p.terms.reduce((s, t) => s + t.weight, 0);
|
||||
const scattered = p.terms.reduce((s, t) => s + t.weight, 0); // 全部詞都命中但不相鄰
|
||||
expect(bonus + scattered).toBeGreaterThan(scattered); // 相鄰者必然更高分
|
||||
});
|
||||
|
||||
it('相對門檻砍掉低於最高分 60% 的尾巴', () => {
|
||||
const rows = [
|
||||
{ id: 'a', match_score: 10 }, // 兩個詞都中
|
||||
{ id: 'b', match_score: 6 }, // 只中重的那個
|
||||
{ id: 'c', match_score: 2 }, // 只中泛詞 ⇒ 雜訊,砍掉
|
||||
];
|
||||
expect(applyRelativeCut(rows).map((r) => r.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('相對門檻是對「最高分」取比例、不是對「滿分」——否則驗收題會被自己的門檻誤殺', () => {
|
||||
// 「Gemini 逃生口」:全庫沒有「逃生口」,最高分那群只中了 Gemini 一個詞。
|
||||
// 若拿滿分當分母,這群會全部低於門檻 ⇒ 又回到 0 筆。
|
||||
const onlyOneTermHit = Array.from({ length: 40 }, (_, i) => ({ id: `g${i}`, match_score: 6 }));
|
||||
expect(applyRelativeCut(onlyOneTermHit)).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('查詢詞數有上限(每多一個詞就多掃一次全表)', () => {
|
||||
const t = tokenizeQuery('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫 額度 向量');
|
||||
expect(t.length).toBeLessThanOrEqual(6);
|
||||
// 被砍掉的必須是最泛的那些 ⇒ 留下來的按份量遞減
|
||||
const w = t.map((x) => x.weight);
|
||||
expect([...w].sort((a, b) => b - a)).toEqual(w);
|
||||
});
|
||||
});
|
||||
|
||||
describe('④ 不再炸:LIKE pattern 仍在 D1 上限內(承 2026-08-03 的 500 修復)', () => {
|
||||
it('任何查詢(含超長中文句)產生的每個 pattern 都 ≤ 50 bytes', () => {
|
||||
const qs = [
|
||||
'a'.repeat(300),
|
||||
'為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢',
|
||||
'Gemini 在這套系統裡的角色是什麼?今天額度為什麼用完?',
|
||||
'。'.repeat(60),
|
||||
];
|
||||
for (const q of qs) {
|
||||
const p = buildSearchScore(q);
|
||||
expect(p.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
|
||||
for (const pattern of p.scoreParams) expect(bytes(pattern)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEntries 送出的 SQL', () => {
|
||||
it('多詞查詢:拆成多個 CASE WHEN,且只回 match_score > 0 的、按分數排序', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, 'Gemini 逃生口', 'demo');
|
||||
const sql = captured[0].sql;
|
||||
expect((sql.match(/CASE WHEN content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
|
||||
expect(sql).toContain('match_score > 0');
|
||||
expect(sql).toContain('ORDER BY match_score DESC');
|
||||
expect(captured[0].params).toEqual(expect.arrayContaining(['%Gemini%', '%逃生口%']));
|
||||
});
|
||||
|
||||
it('其他 filter(owner/library/deprecated)留在內層,先篩再算分', async () => {
|
||||
const { db, captured } = fakeDb();
|
||||
await searchEntries(db, 'Gemini 逃生口', 'demo', undefined, 50, ['kb'], 'kb://x');
|
||||
const inner = captured[0].sql.split('WHERE match_score')[0];
|
||||
expect(inner).toContain('owner_id = ?');
|
||||
expect(inner).toContain("json_extract(metadata_json, '$.source')");
|
||||
expect(inner).toContain('json_extract(metadata_json, \'$.status\')'); // NOT_DEPRECATED
|
||||
});
|
||||
});
|
||||
|
||||
function fakeDb() {
|
||||
const captured: { sql: string; params: unknown[] }[] = [];
|
||||
const db = {
|
||||
prepare(sql: string) {
|
||||
return {
|
||||
bind(...params: unknown[]) {
|
||||
captured.push({ sql, params });
|
||||
return { all: async () => ({ results: [] }) };
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
return { db, captured };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 三元組 library 補標 — 源頭順序 + 存量補標 + 冪等(2026-08-11,leo 貼 wiki 卡「三元組要恢復」)
|
||||
//
|
||||
// 背景(system-dev/wiki/ops-facts.md「三元組在 KBDB 有兩代儲存形式」段,2026-08-11 實測):
|
||||
// 1,633 筆新式三元組只有 171 筆填了 library slot,地圖(GET /map)因此幾乎看不到資料。
|
||||
// 根因是**寫入順序**:createRecord 只會替 template.slots_json 裡「已宣告」的 slot 建 entry_value
|
||||
// (見 src/actions/record-crud.ts createRecord:`for (const slot of slots) { if (!(slot in
|
||||
// input.values)) continue }`——注意是遍歷 template 既有 slots,不是遍歷 caller 傳的 values)。
|
||||
// 若呼叫端在 template 還沒有 'library' slot 時就送出 library 值,那個值會被**靜默丟棄**、
|
||||
// 不報錯——這正是「查得到 171 筆」的來源:只有「已經跑過一次 ensureTripletLibrarySlot/recompute
|
||||
// 之後」的批次,library 才真的落地。
|
||||
//
|
||||
// 本檔驗三件事(對應 leo 交辦的三個「要驗的」):
|
||||
// 1. 源頭:ensure-slot 必須在 write 之前,不能事後補(重現+證明順序才是正解)
|
||||
// 2. 存量:對一批缺 library 的舊 triplet 補標,前後地圖輸出對照
|
||||
// 3. 不會重複做:同一批跑兩次,第二次 touch 0 筆
|
||||
//
|
||||
// 測試手法沿 library-map.test.ts 慣例:真 node:sqlite(Node ≥22.5 內建,零新依賴)跑
|
||||
// migrations 原檔,本檔只是這顆記憶體內測試替身的操作者——不是碰 KBDB 的正式 D1,
|
||||
// 與正式資料庫零關聯(D38 的牆管的是「牆外程式碼碰 KBDB 的真 D1」,這裡是牆內邏輯的
|
||||
// 白盒測試替身,kbdb-api-wall-guard 對 *.test.ts 路徑做字面 grep 會誤判,行尾標
|
||||
// kbdb-sql-ok 是這個誤判的既定逃生艙,見 hook 說明「Genuine exception」)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
recomputeLibraryMap,
|
||||
ensureTripletLibrarySlot,
|
||||
ensureFreshLibraryMaps,
|
||||
listLibraryMaps,
|
||||
} from '../src/actions/library-map';
|
||||
import { createTemplate, createRecord, updateRecord, getRecord, searchByTemplate } from '../src/actions/record-crud';
|
||||
|
||||
function makeSqliteD1(): D1Database {
|
||||
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok: 記憶體測試替身,非真 KBDB D1
|
||||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 灌測試替身 schema,非真 D1
|
||||
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 同上
|
||||
function stmt(sql: string, params: unknown[]) {
|
||||
const s = {
|
||||
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 記憶體測試替身
|
||||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 記憶體測試替身
|
||||
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 記憶體測試替身
|
||||
};
|
||||
return s;
|
||||
}
|
||||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||
}
|
||||
|
||||
// prod 實際 triplet template 的 slots(library-map.test.ts 同款常數,2026-07-19 kbdb_list_templates
|
||||
// 核實)——注意:沒有 library。用它模擬「template 還沒被任何 recompute 摸過」的乾淨起點。
|
||||
const PROD_TRIPLET_SLOTS = [
|
||||
'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
|
||||
'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
|
||||
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
|
||||
];
|
||||
|
||||
async function seedTripletTemplate(db: D1Database): Promise<void> {
|
||||
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
|
||||
}
|
||||
|
||||
// 對齊 design.md 既有 fallback 語意(source_prefix 參數)的同一條規則:
|
||||
// library = source_uri 在 '@' 之前的那段(scheme:owner/repo)。這就是本檔+報告裡建議
|
||||
// kbdb-graph-plugin 在 write 時就该套用的推導規則(見報告,本檔只證明「規則正確、
|
||||
// 順序對了就能用」,不代表已經改了 kbdb-graph-plugin 的程式碼——那是另一個 repo)。
|
||||
function deriveLibrary(sourceUri: string): string {
|
||||
const at = sourceUri.indexOf('@');
|
||||
return at > 0 ? sourceUri.slice(0, at) : sourceUri;
|
||||
}
|
||||
|
||||
describe('源頭順序 — ensure-slot 必須在 write 之前,事後補救不了已寫的那筆', () => {
|
||||
it('重現:template 尚無 library slot 時寫入 → library 值被靜默丟棄(不是報錯,是消失)', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
|
||||
const rec = await createRecord(db, {
|
||||
template: 'triplet',
|
||||
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
|
||||
owner_id: 'leo',
|
||||
});
|
||||
const stored = await getRecord(db, rec.record_id);
|
||||
// 關鍵斷言:library 完全沒落地,不是空字串、是 undefined(key 都不存在)。
|
||||
expect(stored!.values.library).toBeUndefined();
|
||||
expect(stored!.values.source_uri).toBe('gitea:Leo/kb@a.md'); // 其他 slot 正常落地,只有未宣告的 slot 消失
|
||||
});
|
||||
|
||||
it('正解:先 ensureTripletLibrarySlot() 補上 slot,再寫 → library 值正常落地', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
|
||||
const added = await ensureTripletLibrarySlot(db, 'triplet');
|
||||
expect(added).toBe(true); // 第一次呼叫確實補了 slot
|
||||
|
||||
const rec = await createRecord(db, {
|
||||
template: 'triplet',
|
||||
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
|
||||
owner_id: 'leo',
|
||||
});
|
||||
const stored = await getRecord(db, rec.record_id);
|
||||
expect(stored!.values.library).toBe('gitea:Leo/kb');
|
||||
|
||||
// 冪等:對已有 slot 的 template 再呼叫一次 → false(不重複加),不影響既有資料。
|
||||
const addedAgain = await ensureTripletLibrarySlot(db, 'triplet');
|
||||
expect(addedAgain).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('存量補標 — 對缺 library 的舊 triplet 補標,地圖輸出前後對照', () => {
|
||||
it('補標前:地圖看不到任何庫(triplet 全部因缺 library 而未被地圖聚合);補標後:庫名正確出現', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
// 模擬現況:三筆舊 triplet,寫入時 template 還沒有 library slot(如實重現存量現況),
|
||||
// 只帶了 source_uri(之後補標要靠它反推 library)。
|
||||
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', status: 'active' }, owner_id: 'leo' });
|
||||
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md', status: 'active' }, owner_id: 'leo' });
|
||||
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'github:uncle6-me/notes@c.md', status: 'active' }, owner_id: 'leo' });
|
||||
|
||||
// 讀端自動核對重算(M3 收尾機制):這時三筆都缺 library 值。liveTripletCountsByLibrary
|
||||
// 把「缺值」COALESCE 成 'general' 桶(供 staleness 判斷),但 recomputeLibraryMap 的
|
||||
// libCond 是 `t.library = 'general'` 精確比對——缺值在底層是 NULL 不是字面 'general',
|
||||
// 比對不中,實際聚合出 triplet_count:0。**這是本次順手發現的另一個小落差**(live 計數桶
|
||||
// 與 recompute 精確比對的『general』語意沒對齊,導致這桶每次讀都判 stale、白重算,
|
||||
// 但至少不會謊報數字)——不在本次任務範圍內(leo 問的是兩代儲存形式的可見性,不是這個
|
||||
// fallback 桶的效能問題),本測試如實記錄現況,不假裝它是 0。
|
||||
await ensureFreshLibraryMaps(db, 'leo');
|
||||
const before = await listLibraryMaps(db, 'leo');
|
||||
expect(before.length).toBe(1);
|
||||
expect(before[0].library).toBe('general');
|
||||
expect(before[0].triplet_count).toBe(0); // 誠實:桶名對得上、數字沒謊報,但也沒把 3 筆算近來(見上註)
|
||||
|
||||
// ── 補標(源頭已對:先 ensure slot,才寫值;一次對全部缺值的 record)──
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
const allTriplets = await searchByTemplate(db, 'triplet', 'leo');
|
||||
const missing = allTriplets.filter((t) => !t.values.library && t.values.source_uri);
|
||||
expect(missing.length).toBe(3); // 三筆都缺
|
||||
for (const t of missing) {
|
||||
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri) });
|
||||
}
|
||||
|
||||
// 補標後:對每個實際出現的 library 值重算一次地圖(read-path 的 ensureFreshLibraryMaps
|
||||
// 只認「已知庫名」——entries.metadata.library 或 portal_library;剛補標的三元組 library 值
|
||||
// 尚未被任何一處登記為「已知庫名」,直接 recompute 該庫最直接、也是 caller 實際會做的事)。
|
||||
const libs = [...new Set(missing.map((t) => deriveLibrary(t.values.source_uri!)))];
|
||||
for (const lib of libs) {
|
||||
await recomputeLibraryMap(db, { library: lib, owner_id: 'leo' });
|
||||
}
|
||||
|
||||
const after = await listLibraryMaps(db, 'leo');
|
||||
const byLib = new Map(after.map((m) => [m.library, m.triplet_count]));
|
||||
expect(byLib.get('gitea:Leo/kb')).toBe(2); // r1, r2 同庫 —— 這是 leo 真正要看到的東西
|
||||
expect(byLib.get('github:uncle6-me/notes')).toBe(1); // r3 另一庫
|
||||
// 'general' 桶(補標前遺留的空殼,見上一段註)仍在,但 triplet_count 仍是 0——
|
||||
// 三筆全部被正確歸進真正的庫名,沒有一筆被算進 general(no double counting)。
|
||||
expect(byLib.get('general')).toBe(0);
|
||||
expect(after.length).toBe(3);
|
||||
|
||||
// 交叉核對:三筆的 library 值確實都落地了(不是只有地圖聚合對,底層資料也對)。
|
||||
const r1Rec = await getRecord(db, r1.record_id);
|
||||
const r2Rec = await getRecord(db, r2.record_id);
|
||||
const r3Rec = await getRecord(db, r3.record_id);
|
||||
expect(r1Rec!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r2Rec!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r3Rec!.values.library).toBe('github:uncle6-me/notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('不會重複做 — 同一批補標跑兩次,第二次不改動任何東西', () => {
|
||||
it('第二輪掃描:已有 library 值的 record 一筆都不會被 touch', async () => {
|
||||
const db = makeSqliteD1();
|
||||
await seedTripletTemplate(db);
|
||||
await ensureTripletLibrarySlot(db, 'triplet');
|
||||
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'leo' });
|
||||
// r2 模擬「還沒補標」的舊資料:故意繞過 values 直接不給 library(createRecord 這次雖然
|
||||
// template 已有 slot,但 caller 沒給值 → 該 slot 完全不會被建立 entry_value,等同「缺」)。
|
||||
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md' }, owner_id: 'leo' });
|
||||
await updateRecord(db, r1.record_id, { library: deriveLibrary('gitea:Leo/kb@a.md') }); // r1 先補過
|
||||
|
||||
async function backfillPass(): Promise<number> {
|
||||
const all = await searchByTemplate(db, 'triplet', 'leo');
|
||||
const missing = all.filter((t) => !t.values.library && t.values.source_uri);
|
||||
for (const t of missing) {
|
||||
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri!) });
|
||||
}
|
||||
return missing.length;
|
||||
}
|
||||
|
||||
const firstPass = await backfillPass();
|
||||
expect(firstPass).toBe(1); // 只有 r2 被補(r1 已有值,跳過)
|
||||
|
||||
const secondPass = await backfillPass();
|
||||
expect(secondPass).toBe(0); // 第二輪:兩筆都已有 library,一筆都不 touch
|
||||
|
||||
// 資料仍然正確、沒被第二輪弄壞。
|
||||
const r1After = await getRecord(db, r1.record_id);
|
||||
const r2After = await getRecord(db, r2.record_id);
|
||||
expect(r1After!.values.library).toBe('gitea:Leo/kb');
|
||||
expect(r2After!.values.library).toBe('gitea:Leo/kb');
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,24 @@ const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partn
|
||||
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
|
||||
registerOAuthRoutes(_app);
|
||||
|
||||
// ── GET /health — 讓「這台跑的是哪一版 MCP」用一條 curl 看得到 ─────────────────────
|
||||
// 為什麼要有:cypher-executor 早就有 /health(bundle_version + auth_store 探針),
|
||||
// arcrun-mcp 沒有 ⇒ 要判斷某台實例的 MCP 是哪一代認證,只能打 /authorize 剖 HTML 數欄位
|
||||
// (ops-facts 2026-08-10 的土法)。那個判準脆弱又難教。
|
||||
//
|
||||
// ⚠️ 誠實界定(別把它當世代判準用過頭):本端點是**這個 commit 之後才有的**,所以
|
||||
// 「/health 回 404」只代表「比本版舊」,**不代表就是 owner_secret 世代**——
|
||||
// 現階段判世代仍要看 /authorize 的欄位(一個 owner_secret =舊;email+password =新)。
|
||||
// 等這版推到各實例之後,`auth` 欄位才會變成一眼可讀的世代判準。
|
||||
// 不需認證、不吐任何機密;MCP_BUILD 是部署標記,由各實例 toml [vars] 帶入。
|
||||
_app.get("/health", (c) => c.json({
|
||||
ok: true,
|
||||
service: "arcrun-mcp",
|
||||
auth: "portal-login",
|
||||
build: c.env.MCP_BUILD ?? "unknown",
|
||||
oauth_kv: c.env.OAUTH_KV ? "present" : "missing",
|
||||
}));
|
||||
|
||||
const app = _app.basePath('/mcp');
|
||||
|
||||
app.use("*", cors({
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface Env {
|
||||
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
|
||||
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
|
||||
MCP_ALLOWED_REDIRECT_HOSTS?: string;
|
||||
// 部署標記(非機密):GET /health 原樣回報,讓「這台跑的是哪一版」用一條 curl 看得到。
|
||||
// 由各實例的 wrangler toml [vars] 帶入;沒帶 → /health 回 build:"unknown"(誠實,不假裝)。
|
||||
MCP_BUILD?: string;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# ── arcrun-mcp STAGE(youlin 帳號)────────────────────────────────────────────────
|
||||
#
|
||||
# 這支存在的理由(2026-08-10,Leo/mira#4):
|
||||
# `arcrun-mcp` 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有測試場,
|
||||
# 要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)去冒險。這支把 stage 補上:
|
||||
# youlin = stage(agent-memory「兩台分工」:AI 測 youlin、leo 核實 prod)。
|
||||
#
|
||||
# 用法(在 mcp/ 目錄下跑,-c 必須與 main="src/index.ts" 同目錄,否則找不到入口):
|
||||
# cd mcp
|
||||
# CLOUDFLARE_ACCOUNT_ID=1129efd7df2e8899d537e9c8fbabb6cb \
|
||||
# CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN_YOULIN_CC_USE" \
|
||||
# npx wrangler deploy -c wrangler.stage.toml
|
||||
#
|
||||
# 與出貨用的 wrangler.toml 的差別,就是「手動直推 arcrun-mcp 到自架帳號」的兩個坑
|
||||
# (agent-memory 已記,這裡是把它變成檔案而不是靠人記得):
|
||||
# ① 拿掉 [[routes]] —— `mcp.arcrun.dev` 那個 zone 在 uncle6,自架帳號沒有,帶著會部署失敗
|
||||
# ② OAUTH_KV 填真 id —— 出貨 toml 是佔位符 REPLACE_WITH_REAL_KV_ID,只有走 acr init/update
|
||||
# 才會被自動注入;直推必須自己填(沒填 → /authorize 回 503,OAuth 整條死)
|
||||
#
|
||||
# 🔴 [vars] 必須列**全**:wrangler deploy 會用本檔的 vars 整組取代線上的,漏一個就是靜默清掉。
|
||||
# 下方七個 var 是 2026-08-10 從線上 arcrun-mcp 抓下來的原樣值。
|
||||
# Secrets(KBDB_INTERNAL_TOKEN / MCP_OWNER_SECRET)不進本檔也不會被 deploy 洗掉。
|
||||
|
||||
name = "arcrun-mcp"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-11-27"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
workers_dev = true
|
||||
|
||||
[vars]
|
||||
MULTI_TENANT = "false"
|
||||
CF_ACCOUNT_ID = "1129efd7df2e8899d537e9c8fbabb6cb"
|
||||
WORKER_SUBDOMAIN = "youlin-hsieh-dev"
|
||||
CONSOLE_TENANT = "yuga3bse"
|
||||
MCP_OWNER_NAMESPACE = "yuga3bse"
|
||||
KBDB_BASE_URL = "https://arcrun-kbdb.youlin-hsieh-dev.workers.dev"
|
||||
UI_ORIGINS = "https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev"
|
||||
# 部署標記,GET /health 原樣回報。每次重推 stage 就換一個,別讓它跟線上脫節。
|
||||
MCP_BUILD = "stage-2026-08-10+45f1c09"
|
||||
|
||||
[[services]]
|
||||
binding = "COMPONENT_REGISTRY"
|
||||
service = "arcrun-registry"
|
||||
|
||||
[[services]]
|
||||
binding = "CYPHER_EXECUTOR"
|
||||
service = "arcrun-cypher-executor"
|
||||
|
||||
[[services]]
|
||||
binding = "KBDB"
|
||||
service = "arcrun-kbdb"
|
||||
|
||||
# youlin 的 OAuth KV(安裝器建的 arcrun-rag-yuga3bse-kv-oauth_kv)。
|
||||
[[kv_namespaces]]
|
||||
binding = "OAUTH_KV"
|
||||
id = "6a8d4dd621994607b3998ade4c7e9944"
|
||||
@@ -0,0 +1,64 @@
|
||||
# 卡在人類閘前的產物(`Arcrun#89` / `#90` / `#91`)
|
||||
|
||||
> **為什麼這個資料夾存在**:這三樣東西都做完並實測過了,但落地的最後一步是
|
||||
> **終端機裡等人親手打字的互動閘**,AI 打不進去。
|
||||
> 2026-08-11 它們原本只存在於某個 session 的暫存目錄——**那種目錄一關就沒了**。
|
||||
> 先搶進版控,等人有空時再落地。
|
||||
|
||||
---
|
||||
|
||||
## 一、兩份 recipe(`#89`/`#90`)
|
||||
|
||||
`recipes/gitea_put_file.yaml` — 把檔案寫回 Gitea repo。**出貨線有 7 站等它。**
|
||||
`recipes/cf_worker_deploy_simple.yaml` — 部署單檔 Worker(classic 格式)。
|
||||
|
||||
**落地指令**(一份跑一次):
|
||||
|
||||
```
|
||||
acr recipe push pending-human-gate/recipes/gitea_put_file.yaml
|
||||
```
|
||||
|
||||
跑的時候會停下來要你**親手輸入資源名確認**——那是「把資源變成可被外部呼叫」的暴露同意閘,
|
||||
不是卡住,是設計如此。
|
||||
|
||||
⚠️ **`cf_worker_deploy_simple.yaml` 先別急著推**:`#90` 查出一件結構性的事——
|
||||
recipe 引擎的 body 一律 JSON,而 Cloudflare 上傳 Worker 的 API 要的是原始 JS 或 multipart。
|
||||
⇒ **classic 版只適用於沒有 bindings 的簡單情形**。而實查安裝器那站有 9 把 KV + 一顆 D1,
|
||||
**classic 版幫不上它**。詳見 `Leo/Arcrun#90`。
|
||||
|
||||
### 金鑰(D36)
|
||||
|
||||
兩份 recipe 都只寫名字(`gitea_token`/`cf_api_token`),真身由 credential 中心在執行前回填。
|
||||
對應的 auth-recipe **已經註冊在 leo21c 上**,可以直接查證:
|
||||
|
||||
```
|
||||
curl -s https://arcrun-cypher-executor.leo21c.workers.dev/auth-recipes/gitea
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、`hash` 零件(`#91`)
|
||||
|
||||
`hash-component/` — sha256/sha1/md5,hex/base64。出貨線的版本號機制與成品指紋核對都要它。
|
||||
|
||||
**已實測**(tinygo 編出來、wasmtime 真跑,三種演算法都跟系統原生指令**逐位元一致**)。
|
||||
`.wasm` 是 1.3 MB 編譯產物,**沒有進版控**——要驗自己重編:
|
||||
|
||||
```
|
||||
cd pending-human-gate/hash-component && tinygo build -target=wasi -o /tmp/hash.wasm main.go
|
||||
echo '{"algorithm":"sha256","input":"hello"}' | wasmtime /tmp/hash.wasm
|
||||
printf 'hello' | shasum -a 256 # 兩者應該一致
|
||||
```
|
||||
|
||||
**落地要走零件投稿流程**(D27/D28):`docs/component-pr-review-standard.md` 的 checklist
|
||||
+ 人在終端機互動跑 `scripts/component-arm.sh`。
|
||||
🔴 `registry/components/` 底下有機械閘(`component-guard.sh`)擋著 AI 直接寫入——**那是刻意的**,
|
||||
所以這份放在 `pending-human-gate/`,不是放在它最終該去的位置。
|
||||
|
||||
---
|
||||
|
||||
## 落地之後
|
||||
|
||||
三樣都上去之後,`Arcrun#89`/`#91` 才能從 **◐ 半通** 變 **✅**——
|
||||
而判準是**貼一次真實的執行輸出**(recipe 對某個測試檔案回 2xx、零件在真端點上跑出正確雜湊),
|
||||
不是「推上去了」。
|
||||
@@ -0,0 +1,74 @@
|
||||
canonical_id: "hash"
|
||||
display_name: "計算雜湊"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [input]
|
||||
properties:
|
||||
algorithm:
|
||||
type: string
|
||||
enum: [sha256, sha1, md5]
|
||||
description: 雜湊演算法,預設 sha256
|
||||
input:
|
||||
type: string
|
||||
description: 要算雜湊的內容
|
||||
encoding:
|
||||
type: string
|
||||
enum: [hex, base64]
|
||||
description: 輸出編碼,預設 hex
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: string
|
||||
algorithm:
|
||||
type: string
|
||||
encoding:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "sha256 hex(預設)"
|
||||
given: '{"algorithm":"sha256","input":"hello"}'
|
||||
then_contains: '"result":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"'
|
||||
- scenario: "sha1"
|
||||
given: '{"algorithm":"sha1","input":"hello"}'
|
||||
then_contains: '"result":"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"'
|
||||
- scenario: "md5"
|
||||
given: '{"algorithm":"md5","input":"hello"}'
|
||||
then_contains: '"result":"5d41402abc4b2a76b9719d911017c592"'
|
||||
- scenario: "base64 編碼"
|
||||
given: '{"algorithm":"sha256","input":"hello","encoding":"base64"}'
|
||||
then_contains: '"result":"LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="'
|
||||
- scenario: "預設 algorithm=sha256"
|
||||
given: '{"input":"hello"}'
|
||||
then_contains: '"algorithm":"sha256"'
|
||||
- scenario: "不支援的 algorithm"
|
||||
given: '{"algorithm":"crc32","input":"hello"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, logic, hash, checksum, versioning]
|
||||
description: >-
|
||||
計算內容雜湊(sha256/sha1/md5,輸出 hex 或 base64)。純計算,無網路/檔案 syscall。
|
||||
用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,是「改了東西版本沒動」在結構上
|
||||
不可能發生的機制來源;build 站核對官方成品指紋也用它。
|
||||
config_example: |
|
||||
compute_hash: # 節點名稱(可自訂)
|
||||
algorithm: "sha256" # 演算法(選填,預設 sha256),可選值:sha256/sha1/md5
|
||||
input: "{{ctx.bundle_content}}" # 要算雜湊的內容(必填)
|
||||
encoding: "hex" # 輸出編碼(選填,預設 hex),可選值:hex/base64
|
||||
@@ -0,0 +1,89 @@
|
||||
// hash — 計算內容雜湊(純計算,無網路/檔案 syscall)
|
||||
// 支援: sha256, sha1, md5;輸出編碼: hex(預設), base64
|
||||
// 用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,
|
||||
// 是「改了東西版本沒動」在結構上不可能發生的機制來源。
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Algorithm string `json:"algorithm"` // sha256(預設)| sha1 | md5
|
||||
Input string `json:"input"`
|
||||
Encoding string `json:"encoding"` // hex(預設)| base64
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var in Input
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
algorithm := in.Algorithm
|
||||
if algorithm == "" {
|
||||
algorithm = "sha256"
|
||||
}
|
||||
encoding := in.Encoding
|
||||
if encoding == "" {
|
||||
encoding = "hex"
|
||||
}
|
||||
|
||||
var sum []byte
|
||||
switch algorithm {
|
||||
case "sha256":
|
||||
h := sha256.Sum256([]byte(in.Input))
|
||||
sum = h[:]
|
||||
case "sha1":
|
||||
h := sha1.Sum([]byte(in.Input))
|
||||
sum = h[:]
|
||||
case "md5":
|
||||
h := md5.Sum([]byte(in.Input))
|
||||
sum = h[:]
|
||||
default:
|
||||
writeError("不支援的 algorithm: " + algorithm + "(支援 sha256/sha1/md5)")
|
||||
return
|
||||
}
|
||||
|
||||
var result string
|
||||
switch encoding {
|
||||
case "hex":
|
||||
result = hex.EncodeToString(sum)
|
||||
case "base64":
|
||||
result = base64.StdEncoding.EncodeToString(sum)
|
||||
default:
|
||||
writeError("不支援的 encoding: " + encoding + "(支援 hex/base64)")
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"result": result,
|
||||
"algorithm": algorithm,
|
||||
"encoding": encoding,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "arcrun-hash"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
workers_dev = true
|
||||
|
||||
[vars]
|
||||
COMPONENT_ID = "hash"
|
||||
|
||||
[[routes]]
|
||||
pattern = "hash.arcrun.dev/*"
|
||||
zone_name = "arcrun.dev"
|
||||
@@ -0,0 +1,21 @@
|
||||
canonical_id: cf_worker_deploy_simple
|
||||
display_name: Cloudflare Worker Deploy (single-file, classic format)
|
||||
description: >-
|
||||
PUT /accounts/{account_id}/workers/scripts/{script_name} 部署單檔 Worker(CF 「classic Service
|
||||
Worker」格式,非 ES module)。_path 帶 /{account_id}/workers/scripts/{script_name}。
|
||||
auth: cloudflare_workers static_key(Bearer token)。
|
||||
⚠️ 已知限制(誠實記錄,非隱藏債):這個 recipe 走 arcrun 的「recipe body 一律 JSON.stringify」
|
||||
引擎行為(cypher-executor/src/lib/component-loader.ts makeRecipeRunner),CF 這支 API 卻要求
|
||||
body 是「原始 JS 原始碼」或(現代 ES module + bindings 情境)multipart/form-data——兩者都不是
|
||||
JSON。純 recipe 模型在這支 API 上天生對不上,這不是可以在 recipe schema 裡修的事。
|
||||
正解=07-thin-shell §3.5 自力救濟階梯「第三方 API 缺能力→ workflow/code-node 補丁」:
|
||||
用 http_request 零件直接打(body 走它的原生 string 模式,不透過本 recipe wrapper),
|
||||
header 用 {{credential.cf_api_token}} 直接內插(D36 credential 模板,不必經過 recipe/auth_service
|
||||
間接層);若目標 Worker 需要 bindings/compatibility_flags(現代 ES module 格式常態),
|
||||
上游加一個 code 節點組出 multipart/form-data body(純資料編碼,非業務邏輯,合法局部整形)。
|
||||
本 recipe 保留給「目標帳號仍接受 classic 格式」的簡單場景;不保證覆蓋所有部署情境。
|
||||
endpoint: https://api.cloudflare.com/client/v4/accounts{{_path}}
|
||||
method: PUT
|
||||
auth_service: cloudflare_workers
|
||||
headers:
|
||||
Content-Type: application/javascript
|
||||
@@ -0,0 +1,11 @@
|
||||
canonical_id: gitea_put_file
|
||||
display_name: Gitea Put File (Create/Update)
|
||||
description: >-
|
||||
Gitea PUT /repos/{owner}/{repo}/contents/{filepath} 建立或更新檔案並產生 commit。
|
||||
_path 帶完整路徑(例 /Leo/arcrun-rag-bundles/contents/manifest.json,filepath 各段需 URL-encode)。
|
||||
body 帶 {message, content(base64), branch, sha(更新既有檔案時必填,取自前一次 GET 的 content.sha;
|
||||
新建檔案時不帶)}。auth: gitea static_key,header Authorization: token <TOKEN>(D36:定義只留
|
||||
{{credential.*}} 名字,真身由 credential 中心於執行前回填,非本 recipe 職責)。
|
||||
endpoint: https://git.uncle6.me/api/v1/repos{{_path}}
|
||||
method: PUT
|
||||
auth_service: gitea
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-worker-artifacts.mjs — Arcrun#80:tier2 worker(TS→ 可部署 JS)的**唯一官方編譯點**。
|
||||
*
|
||||
* 背景(Arcrun#80/arcrun-rag#39):這個 repo 過去只把 tier1 零件(TinyGo→wasm)的成品
|
||||
* commit 進 `.component-builds/{name}/component.wasm`;tier2(cypher-executor / kbdb /
|
||||
* http_request / code / mcp 這五顆 TS worker)只有原始碼,沒有編好的成品。於是
|
||||
* arcrun-rag 的安裝器只好自己在**它那邊**跑 esbuild(`installer/scripts/build-bundles.mjs`),
|
||||
* 結果同一份原始碼在不同機器編出不同位元組(見下「已知踩坑」),
|
||||
* 而且「這顆成品是哪個 commit 編的」只有整包一個 `source` 欄位,答不出單顆的來源。
|
||||
*
|
||||
* 本腳本要解的:
|
||||
* 1. 編譯只發生在這裡(Arcrun 本體),成品放固定位置 `.worker-builds/`,commit 進 repo——
|
||||
* 與 `.component-builds/*.wasm` 同一個既有慣例(self-host 用戶從 repo 直接拿部署來源)。
|
||||
* 2. 每顆成品自己記得「我是哪個 commit 編出來的」(`source_commit`,答到單顆目錄層級,
|
||||
* 不是整包一個欄位)。
|
||||
* 3. 同一個 commit、任何人任何時候編,位元組要相同——見下方兩個已知踩坑與對應解法。
|
||||
*
|
||||
* 已知踩坑(2026-08-10 實錄,docs-site/.../changelog.md 1.4.33 段;已回報 arcrun-rag#72):
|
||||
* ① esbuild 的 bundle 輸出會把「入口路徑」寫進產物內部的檔案邊界註解,而該路徑預設是
|
||||
* **相對於 esbuild 執行時的 cwd**——雲端容器跑在 `.../arcrun/`、地端跑在
|
||||
* `.../matrix/arcrun/`,同一份原始碼因此編出不同註解。
|
||||
* 解法:固定 `absWorkingDir` 為**本腳本自己算出的 repo 根目錄**(不吃外部 cwd/env
|
||||
* 路徑),entry 一律用「相對 repo 根目錄」的相對路徑餵給 esbuild——不管這個 clone
|
||||
* 實際被放在磁碟的哪個絕對路徑下,esbuild 內部算出的相對路徑字串都相同。
|
||||
* ② 各 worker 目錄的 node_modules 若用不同套件管理器(pnpm store vs npm 平鋪)安裝,
|
||||
* 可能夾帶不同版本的間接依賴(實測 ajv/uri-js 差 1019 行)。
|
||||
* 解法:本腳本**不自己 npm/pnpm install**——強制要求呼叫者先用「該目錄既有的
|
||||
* lockfile」(pnpm-lock.yaml 用 `pnpm install --frozen-lockfile`;package-lock.json
|
||||
* 用 `npm ci`)裝好 node_modules,並在建置前檢查 lockfile 是否存在,
|
||||
* lockfile 是「同一份依賴圖」的機械保證,比信任「兩台機器裝出來一樣」牢靠。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/build-worker-artifacts.mjs [--check-only]
|
||||
* --check-only:只驗證每個 worker 的 node_modules 是否已按 lockfile 裝好,不編譯。
|
||||
*
|
||||
* 輸出:.worker-builds/<name>/worker.mjs (+ *.wasm)、.worker-builds/manifest.json
|
||||
*/
|
||||
import esbuild from 'esbuild';
|
||||
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, basename, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
|
||||
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
|
||||
// 下面所有 esbuild 呼叫都用「相對 REPO」的相對路徑,輸出字串才會與絕對路徑無關。
|
||||
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||
const OUT = join(REPO, '.worker-builds');
|
||||
const CHECK_ONLY = process.argv.includes('--check-only');
|
||||
|
||||
/** 五顆 tier2 worker——與 arcrun-rag `installer/scripts/bundle-components.mjs`
|
||||
* CORE_COMPONENTS 的 build 參數對齊(那邊的 esbuild 呼叫即將被本腳本的成品取代)。
|
||||
* name 用 arcrun-rag 那邊的慣例(`arcrun-<kebab>`),方便安裝器直接對號。 */
|
||||
const WORKERS = [
|
||||
{ name: 'arcrun-cypher-executor', dir: 'cypher-executor', entry: 'src/index.ts', stripServices: true },
|
||||
{ name: 'arcrun-kbdb', dir: 'kbdb', entry: 'src/index.ts' },
|
||||
{ name: 'arcrun-http-request', dir: '.component-builds/http_request', entry: 'src/index.ts' },
|
||||
{ name: 'arcrun-code', dir: 'registry/components/code', entry: 'index.ts' },
|
||||
{ name: 'arcrun-mcp', dir: 'mcp', entry: 'src/index.ts' },
|
||||
];
|
||||
|
||||
function sha256(buf) {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/** 檢查一個 worker 目錄的 node_modules 是否已按它自己的 lockfile 裝好(踩坑②的閘)。 */
|
||||
function checkNodeModules(dir) {
|
||||
const abs = join(REPO, dir);
|
||||
const hasPnpmLock = existsSync(join(abs, 'pnpm-lock.yaml'));
|
||||
const hasNpmLock = existsSync(join(abs, 'package-lock.json'));
|
||||
if (!hasPnpmLock && !hasNpmLock) {
|
||||
return { ok: false, reason: `${dir} 沒有 pnpm-lock.yaml 也沒有 package-lock.json——依賴版本無法鎖定` };
|
||||
}
|
||||
if (!existsSync(join(abs, 'node_modules'))) {
|
||||
const cmd = hasPnpmLock ? 'pnpm install --frozen-lockfile' : 'npm ci';
|
||||
return { ok: false, reason: `${dir}/node_modules 不存在——先在該目錄跑:${cmd}` };
|
||||
}
|
||||
return { ok: true, via: hasPnpmLock ? 'pnpm (frozen)' : 'npm ci' };
|
||||
}
|
||||
|
||||
/** 極簡 wrangler.toml 讀取(沿用 arcrun-rag build-bundles.mjs 同款邏輯,只抓需要的欄位)。 */
|
||||
function readToml(tomlPath) {
|
||||
const t = existsSync(tomlPath) ? readFileSync(tomlPath, 'utf8') : '';
|
||||
const spec = { kv: [], d1: [], vectorize: [], ai: null, vars: {}, compat_flags: [], compat_date: null };
|
||||
const compatFlags = t.match(/compatibility_flags\s*=\s*\[([^\]]*)\]/);
|
||||
if (compatFlags) spec.compat_flags = [...compatFlags[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
|
||||
const compatDate = t.match(/compatibility_date\s*=\s*["']([^"']+)["']/);
|
||||
if (compatDate) spec.compat_date = compatDate[1];
|
||||
for (const m of t.matchAll(/\[\[kv_namespaces\]\][\s\S]*?binding\s*=\s*["']([^"']+)["']/g)) spec.kv.push(m[1]);
|
||||
for (const m of t.matchAll(/\[\[d1_databases\]\]([\s\S]*?)(?=\n\[|\n*$)/g)) {
|
||||
const b = m[1].match(/binding\s*=\s*["']([^"']+)["']/);
|
||||
const n = m[1].match(/database_name\s*=\s*["']([^"']+)["']/);
|
||||
if (b) spec.d1.push({ binding: b[1], database_name: n ? n[1] : null });
|
||||
}
|
||||
for (const line of t.split('\n')) {
|
||||
if (/^\s*\[\[vectorize\]\]/.test(line)) spec.vectorize.push(true);
|
||||
}
|
||||
if (/^\s*\[ai\]/m.test(t)) spec.ai = true;
|
||||
const varsBlock = t.match(/\[vars\]([\s\S]*?)(?=\n\[|\n*$)/);
|
||||
if (varsBlock) for (const m of varsBlock[1].matchAll(/^\s*([A-Z0-9_]+)\s*=\s*["']([^"']*)["']/gm)) spec.vars[m[1]] = m[2];
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** esbuild plugin:.wasm import 攤平成同目錄檔名,記下要一起複製的 wasm part。 */
|
||||
function wasmPlugin(wasmParts) {
|
||||
return {
|
||||
name: 'wasm-external',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /\.wasm$/ }, (args) => {
|
||||
const abs = resolve(args.resolveDir, args.path);
|
||||
const flat = basename(abs);
|
||||
if (!wasmParts.find((w) => w.part === flat)) wasmParts.push({ part: flat, abs });
|
||||
return { path: './' + flat, external: true };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 每個 worker 自己的 source_commit:答到單顆目錄層級,不是整包一個欄位(Arcrun#80 的核心要求)。 */
|
||||
function sourceCommitFor(dir) {
|
||||
try {
|
||||
const hash = execSync(`git log -1 --format=%H -- ${JSON.stringify(dir)}`, { cwd: REPO, encoding: 'utf8' }).trim();
|
||||
return hash || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function repoHead() {
|
||||
try {
|
||||
const sha = execSync('git rev-parse HEAD', { cwd: REPO, encoding: 'utf8' }).trim();
|
||||
// .worker-builds 是本腳本自己的輸出目錄——它在「寫出成品之前」永遠是 untracked,
|
||||
// 拿它判斷「原始碼乾不乾淨」是假陽性(自己把自己判成髒)。排除掉才是真正的
|
||||
// 「原始碼有沒有未 commit 的變更」。
|
||||
const dirty = execSync('git status --porcelain -- . ":(exclude).worker-builds"', { cwd: REPO, encoding: 'utf8' }).trim();
|
||||
return { sha, dirty: !!dirty };
|
||||
} catch {
|
||||
return { sha: null, dirty: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function buildOne(w) {
|
||||
const dirAbs = join(REPO, w.dir);
|
||||
const entryAbs = join(dirAbs, w.entry);
|
||||
if (!existsSync(entryAbs)) throw new Error(`entry 不存在: ${entryAbs}`);
|
||||
const outDir = join(OUT, w.name);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// 踩坑①的解法核心:entry 用「相對 REPO」的路徑,absWorkingDir 固定為 REPO——
|
||||
// esbuild 內部產生的檔案邊界字串因此只依賴這個相對路徑,與這個 clone 實際被
|
||||
// 放在磁碟的哪個絕對路徑無關。
|
||||
const entryRel = relative(REPO, entryAbs);
|
||||
|
||||
const wasmParts = [];
|
||||
const result = await esbuild.build({
|
||||
absWorkingDir: REPO,
|
||||
entryPoints: [entryRel],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
outfile: relative(REPO, join(outDir, 'worker.mjs')),
|
||||
external: ['cloudflare:*', 'node:*'],
|
||||
plugins: [wasmPlugin(wasmParts)],
|
||||
logLevel: 'silent',
|
||||
metafile: true,
|
||||
});
|
||||
|
||||
const modules = [];
|
||||
for (const wp of wasmParts) {
|
||||
if (!existsSync(wp.abs)) throw new Error(`wasm 找不到: ${wp.abs}(該 worker 需先 build/vendored wasm)`);
|
||||
copyFileSync(wp.abs, join(outDir, wp.part));
|
||||
modules.push({ name: wp.part, type: 'application/wasm', file: `${w.name}/${wp.part}`, sha256: sha256(readFileSync(wp.abs)) });
|
||||
}
|
||||
|
||||
const spec = readToml(join(dirAbs, 'wrangler.toml'));
|
||||
const jsBuf = readFileSync(join(outDir, 'worker.mjs'));
|
||||
return {
|
||||
name: w.name,
|
||||
source_dir: w.dir,
|
||||
source_commit: sourceCommitFor(w.dir),
|
||||
main_module: 'worker.mjs',
|
||||
main_file: `${w.name}/worker.mjs`,
|
||||
js_bytes: jsBuf.length,
|
||||
content_sha256: sha256(jsBuf),
|
||||
modules,
|
||||
compat_date: spec.compat_date,
|
||||
compat_flags: spec.compat_flags,
|
||||
requires: {
|
||||
kv: spec.kv,
|
||||
d1: spec.d1,
|
||||
vectorize: spec.vectorize.length,
|
||||
ai: !!spec.ai,
|
||||
vars: spec.vars,
|
||||
},
|
||||
stripped: w.stripServices ? { services: 13 } : undefined,
|
||||
warnings: result.warnings.map((x) => x.text),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`REPO = ${REPO}`);
|
||||
|
||||
const precheck = WORKERS.map((w) => ({ w, chk: checkNodeModules(w.dir) }));
|
||||
const failed = precheck.filter((p) => !p.chk.ok);
|
||||
if (failed.length) {
|
||||
console.error('\n❌ 建置中止:以下 worker 尚未按 lockfile 裝好依賴(踩坑②的閘):\n');
|
||||
for (const f of failed) console.error(` - ${f.chk.reason}`);
|
||||
console.error('\n這是刻意設計:本腳本不自己跑 install,避免「install 方式不同 → 依賴版本不同 → 位元組不同」。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✔ node_modules 檢查通過:');
|
||||
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
|
||||
|
||||
if (CHECK_ONLY) {
|
||||
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
for (const w of WORKERS) {
|
||||
const d = join(OUT, w.name);
|
||||
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const head = repoHead();
|
||||
const manifest = {
|
||||
schema: 1,
|
||||
built_for: 'arcrun-tier2-worker-artifacts',
|
||||
generated_at: new Date().toISOString(),
|
||||
repo_head: head.sha,
|
||||
repo_dirty: head.dirty,
|
||||
workers: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
let failCount = 0;
|
||||
for (const w of WORKERS) {
|
||||
try {
|
||||
const entry = await buildOne(w);
|
||||
manifest.workers.push(entry);
|
||||
const wasmNote = entry.modules.length ? ` +${entry.modules.length} wasm` : '';
|
||||
console.log(`✔ ${w.name} js=${(entry.js_bytes / 1024).toFixed(0)}KB sha256=${entry.content_sha256.slice(0, 12)} source=${(entry.source_commit || '').slice(0, 8)}${wasmNote}`);
|
||||
} catch (e) {
|
||||
manifest.notes.push(`FAILED ${w.name}: ${e.message}`);
|
||||
console.error(`✗ ${w.name}: ${e.message}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nmanifest → ${join(OUT, 'manifest.json')} (${manifest.workers.length}/${WORKERS.length} built)`);
|
||||
if (failCount > 0 || head.dirty) {
|
||||
if (head.dirty) console.error('⚠️ 工作區不乾淨(有未 commit 的變更)——這份成品的 repo_head 標記不完全可信,僅供本地驗證用。');
|
||||
if (failCount > 0) process.exit(1);
|
||||
}
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "arcrun-build-tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Arcrun#80: tier2 worker 編譯工具的獨立依賴——esbuild 版本鎖在這份 lockfile,任何人在任何機器編譯都用同一版 esbuild(重現性的一部分:工具版本也是輸入之一)。",
|
||||
"devDependencies": {
|
||||
"esbuild": "0.24.0"
|
||||
}
|
||||
}
|
||||
Generated
+265
@@ -0,0 +1,265 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
esbuild:
|
||||
specifier: 0.24.0
|
||||
version: 0.24.0
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.24.0':
|
||||
resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.24.0':
|
||||
resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.24.0':
|
||||
resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.24.0':
|
||||
resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.24.0':
|
||||
resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.24.0':
|
||||
resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.24.0':
|
||||
resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.24.0':
|
||||
resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.24.0':
|
||||
resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.24.0':
|
||||
resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.24.0':
|
||||
resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.24.0':
|
||||
resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.24.0':
|
||||
resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-x64@0.24.0':
|
||||
resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.24.0':
|
||||
resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.24.0':
|
||||
resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.24.0':
|
||||
resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.24.0':
|
||||
resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
esbuild@0.24.0:
|
||||
resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.24.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.24.0':
|
||||
optional: true
|
||||
|
||||
esbuild@0.24.0:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.24.0
|
||||
'@esbuild/android-arm': 0.24.0
|
||||
'@esbuild/android-arm64': 0.24.0
|
||||
'@esbuild/android-x64': 0.24.0
|
||||
'@esbuild/darwin-arm64': 0.24.0
|
||||
'@esbuild/darwin-x64': 0.24.0
|
||||
'@esbuild/freebsd-arm64': 0.24.0
|
||||
'@esbuild/freebsd-x64': 0.24.0
|
||||
'@esbuild/linux-arm': 0.24.0
|
||||
'@esbuild/linux-arm64': 0.24.0
|
||||
'@esbuild/linux-ia32': 0.24.0
|
||||
'@esbuild/linux-loong64': 0.24.0
|
||||
'@esbuild/linux-mips64el': 0.24.0
|
||||
'@esbuild/linux-ppc64': 0.24.0
|
||||
'@esbuild/linux-riscv64': 0.24.0
|
||||
'@esbuild/linux-s390x': 0.24.0
|
||||
'@esbuild/linux-x64': 0.24.0
|
||||
'@esbuild/netbsd-x64': 0.24.0
|
||||
'@esbuild/openbsd-arm64': 0.24.0
|
||||
'@esbuild/openbsd-x64': 0.24.0
|
||||
'@esbuild/sunos-x64': 0.24.0
|
||||
'@esbuild/win32-arm64': 0.24.0
|
||||
'@esbuild/win32-ia32': 0.24.0
|
||||
'@esbuild/win32-x64': 0.24.0
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -44,3 +44,34 @@ leo 否決②——「**藏書地圖就是 arcrun 的最重要功能,讓 AI
|
||||
不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。
|
||||
tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
|
||||
`kbdb_get_map` 回 `{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。
|
||||
|
||||
### M3 止血(2026-08-11,Arcrun#87,總管交辦「動工前的量測」comment 第四節)
|
||||
|
||||
**08-08 那次改法本身留了一個判準缺口,這次補上**:`ensureFreshLibraryMaps` 比對
|
||||
「即時三元組數」(`liveTripletCountsByLibrary`)與「快取的地圖數」(`recomputeLibraryMap`
|
||||
算出來寫進去的),但兩邊的 status 過濾不一致——`recomputeLibraryMap` 只算
|
||||
`COALESCE(status,'active')='active'`,`liveTripletCountsByLibrary` 完全不濾 status。
|
||||
只要一個庫裡混了任何一筆 superseded/deprecated triplet,兩邊數字就永遠對不上,
|
||||
`ensureFreshLibraryMaps` 就永遠判定 stale ⇒ **每次讀地圖都觸發重算,每次都新建一筆
|
||||
library_map record(superseded 舊的),無止盡寫 D1**——且加劇 `recomputeLibraryMap`
|
||||
本身非原子 supersede 的既有競態(更高重算頻率 = 更高並發重算機率),是 `kb` 庫
|
||||
全部 44 筆被標 superseded、`notes` 庫兩筆同時 active(`arcrun-rag#50`)這兩個症狀的
|
||||
共同根因之一。
|
||||
|
||||
**修法**:`liveTripletCountsByLibrary`(`kbdb/src/actions/library-map.ts`)的 SQL 改成
|
||||
先 pivot 出每筆 triplet record 的 status,再套用與 `recomputeLibraryMap` 逐字一致的
|
||||
`COALESCE(status,'active')='active'` 過濾,兩邊判準對齊後,資料未變動時兩個計數必然相等,
|
||||
stale 判定回歸「真的有資料變動才 stale」。
|
||||
|
||||
**驗證**:新增迴歸案「Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次
|
||||
觸發重算」(`kbdb/tests/library-map.test.ts`,19/19 全綠);反向驗證過——把同一顆測試跑在
|
||||
修前的舊 SQL 上會失敗(`library_map` record 數 2 vs 期望 1),證明測試真的釘住這個 bug、
|
||||
不是空氣測試。另外用 leo21c MCP 連線(`bfezv28v`)連讀兩次 `kbdb_get_map()`(無中間寫入)
|
||||
獨立重現修前症狀:`general` 庫 `updated_at` 從 `1786457080` 前進到 `1786457114`。
|
||||
|
||||
**尚待**:改動只在分支 `fix/library-map-recompute-loop-87-v3`(未 push、未部署 leo21c);
|
||||
既有 100 筆 library_map 殘骸(`kb` 44 筆 superseded/`general` 41/`notes` 2)未清——
|
||||
清除需要一個目前不存在的 DELETE 通道(cypher-executor 的 `/kbdb/records/:id` proxy 只有
|
||||
GET/POST/PATCH,無 DELETE;kbdb base 自己雖有 `DELETE /records/:recordId` 但走 leo21c
|
||||
需要 `KBDB_INTERNAL_TOKEN`,非 CC 可持有的機密)——待總管部署本修法+視情況補一支
|
||||
DELETE proxy 後再清。
|
||||
|
||||
Reference in New Issue
Block a user