Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af3edff856 | |||
| c5d696556e | |||
| 5919c6b90f | |||
| b6ef0f07dc | |||
| 507620e313 | |||
| 1e94f8451e | |||
| dcb6ad693b | |||
| 3b0238bb28 | |||
| 788295ed71 | |||
| 797e7f751c | |||
| d1c44a5878 | |||
| a7e23badf2 | |||
| eebb691426 |
@@ -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-11T05:33:37.988Z",
|
||||
"repo_head": "d8bbf2241bd6b117d76fb27d9e386ecfb0ffe8f7",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 568855,
|
||||
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"EXEC_CONTEXT",
|
||||
"WEBHOOKS",
|
||||
"CREDENTIALS_KV",
|
||||
"ANALYTICS_KV",
|
||||
"RECIPES",
|
||||
"USERS_KV",
|
||||
"SESSIONS_KV"
|
||||
],
|
||||
"d1": [
|
||||
{
|
||||
"binding": "CREDENTIALS_DB",
|
||||
"database_name": "arcrun-kbdb"
|
||||
}
|
||||
],
|
||||
"vectorize": 0,
|
||||
"ai": true,
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production",
|
||||
"CF_ACCOUNT_ID": "",
|
||||
"WORKER_SUBDOMAIN": "uncle6-me",
|
||||
"KBDB_BASE_URL": "https://arcrun-kbdb.uncle6-me.workers.dev",
|
||||
"CONSOLE_TENANT": "leo",
|
||||
"PORTAL_SESSION_TTL": "604800",
|
||||
"PORTAL_SHOW_WORKFLOWS": "admin",
|
||||
"GITEA_BASE_URL": "https://git.uncle6.me",
|
||||
"GITEA_SPRINT_REPO": "Leo/InkStoneCo",
|
||||
"GITEA_SPRINT_DIR": "system-dev/docs/3-specs/autonomy-dispatch"
|
||||
}
|
||||
},
|
||||
"stripped": {
|
||||
"services": 13
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "a7e23badf2a771be779a861e69e7efa6e8141dfe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 135910,
|
||||
"content_sha256": "5e5a7a030f4fd1f5549ace6791c3827b6497b0bfdd9add46af041af47c472905",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "arcrun-kbdb"
|
||||
}
|
||||
],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-http-request",
|
||||
"source_dir": ".component-builds/http_request",
|
||||
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-http-request/worker.mjs",
|
||||
"js_bytes": 80073,
|
||||
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-http-request/component.wasm",
|
||||
"sha256": "cc15cc785703e7bbb8dbff2d38dc84a4ac24e2f44316182730abae0f170ef133"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "http_request"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153671,
|
||||
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1165130,
|
||||
"content_sha256": "be15033f32e605f03f69bd10cd87782dafa34dbafeee2ce367bd7361a062a291",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"notes": []
|
||||
}
|
||||
+60
-11
@@ -271,12 +271,24 @@ export async function downloadAndDeploy(
|
||||
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
|
||||
await ensureVectorizeIndex(ctx);
|
||||
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
|
||||
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
|
||||
await ensureVectorizeMetadataIndexes(ctx);
|
||||
// metadata index,否則帶 owner_id/entry_type/source/library 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
|
||||
const created = await ensureVectorizeMetadataIndexes(ctx);
|
||||
console.log(chalk.green(' ✓'));
|
||||
// 新建的 metadata index **只收「建立之後 upsert」的向量** ⇒ 既有向量不重推就永遠 filter 不到。
|
||||
// 這一步不能靜默:leo21c 全盲事件裡,人看到「✓」就以為好了,實際上舊向量一筆都查不到。
|
||||
if (created.length > 0) {
|
||||
console.log(chalk.yellow(
|
||||
` ⚠ 新建了 metadata index(${created.join('/')})。Vectorize 只索引「建立之後寫入」的向量,\n` +
|
||||
' 既有向量必須重推才查得到 → 部署完成後打:\n' +
|
||||
' POST <kbdb>/embed/backfill {"reindex":true} (重複呼叫直到 remaining=0)',
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
console.log(chalk.red(' ✗'));
|
||||
failures.push(
|
||||
`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}` +
|
||||
' ⇒ 語意搜尋會「看起來有開、實際全盲」(帶歸屬條件的查詢一律 0 命中),請先修好這項再驗收語意搜尋。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,8 +475,16 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
|
||||
/**
|
||||
* embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。
|
||||
*
|
||||
* 🔴 這份清單必須與 `kbdb/src/embed.ts` 的 upsert metadata 欄位**逐欄對齊**:少一欄,
|
||||
* 帶那一欄過濾的語意查詢就永遠回 0 命中(Vectorize 只認「已建 metadata index」的欄位),
|
||||
* **而且不會報錯**——與 bge-m3 換代那次同款的靜默漂移(wiki/mistakes.md「改 A 要連動 B」)。
|
||||
* `library` 是 2026-08-11 補的:portal-auth P1 的「庫」filter 早就拿它在查,清單卻一直停在
|
||||
* 三欄(`kbdb/wrangler.toml` 自己記著「library 待補進該清單」,那張欠條在這裡還掉)。
|
||||
*/
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source', 'library'] as const;
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed index 上的 metadata index(owner_id/entry_type/source)存在(Arcrun#11 根因修復)。
|
||||
@@ -472,16 +492,18 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`(indexType=string)。
|
||||
* 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。
|
||||
*/
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<string[]> {
|
||||
const base = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}`;
|
||||
const auth = { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' };
|
||||
const created: string[] = [];
|
||||
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(`${base}/metadata_index/create`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||
headers: auth,
|
||||
body: JSON.stringify({ propertyName, indexType: 'string' }),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (res.ok) continue;
|
||||
if (res.ok) { created.push(propertyName); continue; }
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
|
||||
| null;
|
||||
@@ -489,6 +511,33 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void>
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue;
|
||||
throw new Error(`metadata_index ${propertyName}: ${msg}`);
|
||||
}
|
||||
|
||||
// 🔴 建完一定要複驗(2026-08-11 立,Arcrun#85 D70 事故的直接教訓)。
|
||||
// leo21c 的現役 index 上**一個 metadata index 都沒有**,於是每一條帶 owner_id 的
|
||||
// 語意查詢(=所有真實使用者路徑,租戶隔離一律帶)都回 0 命中,語意搜尋全盲三天。
|
||||
// 真兇是 arcrun-rag 安裝器把端點寫成 `metadata-index/create`(連字號,CF 回 404,
|
||||
// 正解是底線 `metadata_index/create`),而那支把失敗降級成一行 ⚠ 就宣告安裝成功。
|
||||
// ⇒ **「我發過 create 請求」不等於「index 真的在」**。這一段就是那個等號。
|
||||
// 複驗失敗一律 throw:呼叫端會把它收進 failures 讓部署誠實標紅,而不是
|
||||
// 「語意搜尋開起來了、但全盲」這種最貴的假綠(mindset §7 禁假綠)。
|
||||
const listRes = await fetch(`${base}/metadata_index/list`, { headers: auth, signal: AbortSignal.timeout(60_000) });
|
||||
if (!listRes.ok) {
|
||||
throw new Error(`metadata_index 複驗失敗:list HTTP ${listRes.status}(無法確認 index 是否真的建起來,不當作成功)`);
|
||||
}
|
||||
const listJson = (await listRes.json().catch(() => null)) as
|
||||
| { result?: { metadataIndexes?: Array<{ propertyName?: string }> } }
|
||||
| null;
|
||||
const present = new Set(
|
||||
(listJson?.result?.metadataIndexes ?? []).map(m => String(m.propertyName ?? '')),
|
||||
);
|
||||
const missing = KBDB_VECTORIZE_META_FIELDS.filter(f => !present.has(f));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`metadata_index 複驗不通過:${missing.join('/')} 不在 ${KBDB_VECTORIZE_INDEX} 上。` +
|
||||
'沒有這些 index,帶 owner_id/library 等條件的語意查詢會一律回 0 命中(不會報錯,只是全盲)。',
|
||||
);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** 下載 Gitea archive tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -902,8 +902,17 @@ portalRouter.get('/portal/password/reset-link', (c) => {
|
||||
// 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);
|
||||
@@ -914,8 +923,9 @@ portalRouter.post('/portal/password/forgot', (c) =>
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'這台實例還沒有設定寄信服務,「忘記密碼」的連結寄不出去。' +
|
||||
'請重新執行安裝/更新讓它就緒,或請管理員直接幫你改密碼。',
|
||||
'寄信功能還沒接上,所以「忘記密碼」的信寄不出去。' +
|
||||
'請照當初收到的安裝網址,重新執行一次安裝(選同一個 Cloudflare 帳號)——' +
|
||||
'完成後這個功能就會自動接上,不需要自己設定任何東西,也不用找任何人幫忙。',
|
||||
code: 'mail_relay_not_configured',
|
||||
},
|
||||
503,
|
||||
@@ -924,7 +934,7 @@ portalRouter.post('/portal/password/forgot', (c) =>
|
||||
|
||||
const generic = {
|
||||
success: true,
|
||||
message: '如果這個 email 在這台實例上有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
|
||||
message: '如果這個 email 有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
|
||||
};
|
||||
|
||||
// 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
|
||||
@@ -1850,7 +1860,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
||||
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
|
||||
| null;
|
||||
const selftestBody = (await selftestRes.json().catch(() => null)) as
|
||||
| { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; note?: string }
|
||||
| { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; filter_blind?: boolean | null; note?: string }
|
||||
| null;
|
||||
embedding = {
|
||||
checked: true,
|
||||
@@ -1861,6 +1871,13 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
||||
ran: selftestBody?.tested ?? false,
|
||||
// 三態:true=能搜到自己 / false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開
|
||||
found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null,
|
||||
// 🔴 2026-08-11(Arcrun#85 D70):found_itself:false 有**兩種處方相反**的成因,
|
||||
// 光看布林分不出來,而 leo21c 就是照著錯的處方(只 reindex)永遠修不好。
|
||||
// true =不帶條件搜得到、一帶歸屬條件就搜不到 ⇒ Vectorize metadata 過濾是死的
|
||||
// (該 index 上沒建 metadata index)⇒ **先建 index 再 reindex**
|
||||
// false=怎麼查都搜不到 ⇒ 向量不在現役 index ⇒ reindex 才對
|
||||
// 讓機器能直接分支,不必去解析 note 的文字。
|
||||
filter_blind: selftestBody?.tested ? (selftestBody?.filter_blind ?? null) : null,
|
||||
note: selftestBody?.note ?? '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -257,6 +257,209 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
|
||||
};
|
||||
}
|
||||
|
||||
// ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ─────────────────────────
|
||||
//
|
||||
// 病徵(2026-08-10 總管在 leo21c 上實測,有對照組):
|
||||
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||
// ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。
|
||||
// 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一
|
||||
// `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。
|
||||
//
|
||||
// 為什麼這是**結構性**故障、不是準度問題:
|
||||
// **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰
|
||||
// ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」——
|
||||
// 接上了也還是瞎的,因為接上之後查什麼都沒有。
|
||||
// (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。)
|
||||
//
|
||||
// 這件 47c6aae(2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事;
|
||||
// 本次只動**查詢端**,buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。
|
||||
//
|
||||
// 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。
|
||||
// · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**,
|
||||
// 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。
|
||||
// · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛
|
||||
// ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2)
|
||||
// ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。
|
||||
// · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一,
|
||||
// `local arcrun` 那 5 筆不會被稀釋掉。
|
||||
// · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套):
|
||||
// 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。
|
||||
//
|
||||
// 回歸保證(不是靠測試碰運氣,是靠構造):
|
||||
// · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern),
|
||||
// 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。
|
||||
// · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞),
|
||||
// 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。
|
||||
//
|
||||
// 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段
|
||||
// +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、
|
||||
// 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。
|
||||
// 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到
|
||||
// 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。
|
||||
const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數)
|
||||
const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號
|
||||
// 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照):
|
||||
// 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾);
|
||||
// 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響
|
||||
// ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」,
|
||||
// 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。
|
||||
const KEYWORD_RELATIVE_CUT = 0.6;
|
||||
|
||||
// CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。**
|
||||
//
|
||||
// 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果
|
||||
// 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。
|
||||
// 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都
|
||||
// 同時是虛詞與實詞的組成部分)。
|
||||
// ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。
|
||||
// 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。
|
||||
//
|
||||
// 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額),
|
||||
// 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡
|
||||
// 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。
|
||||
const CJK_STOP_CHARS = new Set(
|
||||
'的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''),
|
||||
);
|
||||
|
||||
// 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。
|
||||
const ASCII_STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were',
|
||||
'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with',
|
||||
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would',
|
||||
'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they',
|
||||
]);
|
||||
|
||||
const isCjkChar = (ch: string): boolean => /[-ヿ㐀-䶿一-鿿豈-]/.test(ch);
|
||||
const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch);
|
||||
|
||||
/** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */
|
||||
export function splitRuns(q: string): { text: string; cjk: boolean }[] {
|
||||
const runs: { text: string; cjk: boolean }[] = [];
|
||||
let cur = ''; let curCjk = false;
|
||||
const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; };
|
||||
for (const ch of q) {
|
||||
const cjk = isCjkChar(ch);
|
||||
if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔
|
||||
if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意)
|
||||
cur += ch; curCjk = cjk;
|
||||
}
|
||||
flush();
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */
|
||||
function contentBigrams(run: string): string[] {
|
||||
const chars = [...run];
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i + 1 < chars.length; i++) {
|
||||
if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue;
|
||||
out.push(chars[i] + chars[i + 1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SearchTerm { term: string; weight: number }
|
||||
|
||||
/**
|
||||
* 把查詢句拆成帶份量的查詢詞(純函式,單測用 export)。
|
||||
* 份量=字數(上限 MAX_TERM_WEIGHT);愈長愈專指 ⇒ 排序時壓過泛詞。
|
||||
* 依份量由大到小截斷到 MAX_SEARCH_TERMS,確保被砍掉的是最泛的那幾個。
|
||||
*/
|
||||
export function tokenizeQuery(q: string): SearchTerm[] {
|
||||
const found = new Map<string, number>();
|
||||
const add = (t: string, w: number) => {
|
||||
for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束
|
||||
if (!piece) continue;
|
||||
found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT)));
|
||||
}
|
||||
};
|
||||
|
||||
const runs = splitRuns(q);
|
||||
// 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同:
|
||||
// · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。
|
||||
// · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。
|
||||
// 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有
|
||||
// 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的
|
||||
// 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。
|
||||
const isQuestion = runs.length > 1;
|
||||
|
||||
for (const run of runs) {
|
||||
if (!run.cjk) {
|
||||
const w = run.text.toLowerCase();
|
||||
if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length);
|
||||
continue;
|
||||
}
|
||||
const chars = [...run.text];
|
||||
// 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。
|
||||
if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length);
|
||||
// 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。
|
||||
// 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。
|
||||
if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2);
|
||||
}
|
||||
|
||||
return [...found.entries()]
|
||||
.map(([term, weight]) => ({ term, weight }))
|
||||
.sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
|
||||
.slice(0, MAX_SEARCH_TERMS);
|
||||
}
|
||||
|
||||
export interface SearchScorePlan {
|
||||
/** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */
|
||||
scoreExpr: string;
|
||||
scoreParams: string[];
|
||||
terms: SearchTerm[];
|
||||
/** true = 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */
|
||||
legacyShape: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生「覆蓋率分數」的 SQL 表達式(純函式,單測用 export)。
|
||||
*
|
||||
* 整句相鄰另給一份重賞(=所有詞份量總和),確保**舊行為排最前**:
|
||||
* 含整句的內容分數必然高於只含零散詞的,`local arcrun` 那 5 筆永遠在最上面。
|
||||
*/
|
||||
export function buildSearchScore(q: string): SearchScorePlan {
|
||||
const trimmed = q.trim();
|
||||
const terms = tokenizeQuery(trimmed);
|
||||
|
||||
// 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。
|
||||
if (terms.length === 0) {
|
||||
const m = buildContentLike(trimmed);
|
||||
return {
|
||||
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
|
||||
scoreParams: m.params,
|
||||
terms: [],
|
||||
legacyShape: true,
|
||||
};
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const params: string[] = [];
|
||||
for (const { term, weight } of terms) {
|
||||
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
||||
params.push(`%${term}%`);
|
||||
}
|
||||
|
||||
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
|
||||
params.push(`%${trimmed}%`);
|
||||
}
|
||||
|
||||
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||
}
|
||||
|
||||
/** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */
|
||||
export function applyRelativeCut<T extends { match_score: number }>(rows: T[]): T[] {
|
||||
if (rows.length <= 1) return rows;
|
||||
const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT;
|
||||
return rows.filter((r) => r.match_score >= cut);
|
||||
}
|
||||
|
||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||
@@ -309,6 +512,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||
export async function searchEntries(
|
||||
db: D1Database,
|
||||
q: string,
|
||||
@@ -318,18 +524,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 ?? []);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+67
-12
@@ -288,6 +288,15 @@ export interface SelfTestResult {
|
||||
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
|
||||
passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測)
|
||||
note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用
|
||||
// 🔴 2026-08-11 新增(Arcrun#85 D70 leo21c 全盲事件):分辨**兩種處方相反**的故障。
|
||||
// null=沒測到這一層(模組未開/沒帶 owner_id/或帶 filter 就通過了,不必再探)
|
||||
// true =不帶 filter 搜得到,帶 filter 搜不到 ⇒ **Vectorize metadata filter 失效**
|
||||
// false=連不帶 filter 都搜不到 ⇒ 向量根本不在現役 index 裡
|
||||
//
|
||||
// 為什麼非分不可:舊版兩種病都只回一句「需要重新 reindex」。但 metadata index
|
||||
// **不存在**時,Vectorize 不會索引該欄位,reindex 重推幾萬筆也不會生效
|
||||
// ——leo21c 就是照著這個處方修不好。錯的處方比沒有處方更貴。
|
||||
filter_blind: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,7 +316,7 @@ export async function embedSelfTest(
|
||||
opts: { owner_id?: string } = {},
|
||||
): Promise<SelfTestResult> {
|
||||
if (!embedEnabled(env)) {
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
|
||||
return { enabled: false, tested: false, passed: null, filter_blind: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
|
||||
}
|
||||
const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
|
||||
const params: unknown[] = [];
|
||||
@@ -318,34 +327,80 @@ export async function embedSelfTest(
|
||||
.bind(...params)
|
||||
.first<Entry>();
|
||||
if (!row) {
|
||||
return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
|
||||
return { enabled: true, tested: false, passed: null, filter_blind: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
|
||||
}
|
||||
const sample = (row.content ?? '').trim().slice(0, 200);
|
||||
if (!sample) {
|
||||
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' };
|
||||
return { enabled: true, tested: false, passed: null, filter_blind: null, note: '取樣卡片內容為空,跳過自我檢查' };
|
||||
}
|
||||
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
|
||||
// 第一段=**使用者真正走的那條路**(帶 owner_id filter),先測它;通了就不必多花第二次查詢。
|
||||
const probe = async (o: { owner_id?: string }) =>
|
||||
semanticSearch(env, sample, { ...o, topK: 10, min_score: 0 });
|
||||
let hits: SemanticHit[] | null;
|
||||
try {
|
||||
hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 });
|
||||
hits = await probe({ owner_id: opts.owner_id });
|
||||
} catch (e) {
|
||||
if (e instanceof EmbedQueryFailedError) {
|
||||
// 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。
|
||||
return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` };
|
||||
return { enabled: true, tested: false, passed: null, filter_blind: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (hits === null) {
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
|
||||
return { enabled: false, tested: false, passed: null, filter_blind: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
|
||||
}
|
||||
const passed = hits.some((h) => h.id === row.id);
|
||||
if (passed) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: true, filter_blind: null,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的',
|
||||
};
|
||||
}
|
||||
|
||||
// ── 沒搜到自己:第二段,判斷是「向量不在 index」還是「filter 失效」───────────────
|
||||
// 🔴 2026-08-11(Arcrun#85 D70,leo21c 實撞):這兩種病的處方**相反**,不能都叫人 reindex。
|
||||
// 自己查自己相似度接近 1.0,所以「搜不到自己」絕不是分數問題(MIN_SCORE_ABS_FLOOR 也被
|
||||
// min_score:0 關掉了)。剩下兩種可能,用「拿掉 filter 再查一次」一刀切開:
|
||||
// 拿掉 filter 就找得到 → 向量在 index 裡,是 **metadata filter 死的**
|
||||
// (metadata index 沒建,或向量早於該 index 建立時間)
|
||||
// ⇒ 修法是**先建 metadata index,再 reindex**;只 reindex 沒用
|
||||
// 拿掉 filter 還是找不到 → 向量真的不在現役 index(常見:換 index 世代後沒重嵌)
|
||||
// ⇒ 修法才是 reindex
|
||||
// 只有在「有帶 owner_id」時第二段才有意義(沒帶 filter 的查詢,兩段是同一件事)。
|
||||
if (!opts.owner_id) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: false,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0',
|
||||
};
|
||||
}
|
||||
let unfiltered: SemanticHit[] | null = null;
|
||||
try {
|
||||
unfiltered = await probe({});
|
||||
} catch (e) {
|
||||
if (!(e instanceof EmbedQueryFailedError)) throw e;
|
||||
// 第二段查詢自己壞了 → 不硬猜,誠實回「分不出是哪一種」。
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: null,
|
||||
note: `拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己;追查用的第二次查詢也失敗(${e.message}),無法判斷是索引沒收錄還是過濾條件失效`,
|
||||
};
|
||||
}
|
||||
const foundWithoutFilter = (unfiltered ?? []).some((h) => h.id === row.id);
|
||||
if (foundWithoutFilter) {
|
||||
return {
|
||||
enabled: true, tested: true, passed: false, filter_blind: true,
|
||||
note:
|
||||
'拿一張已標記「已嵌入」的卡片自我查詢:**不帶歸屬條件搜得到、一帶上去就搜不到** ⇒ ' +
|
||||
'向量在索引裡,壞的是 Vectorize 的 metadata 過濾(該欄位的 metadata index 沒建,' +
|
||||
'或這些向量是在該 index 建立之前寫進去的)。所有真實查詢都會帶歸屬條件做租戶隔離,' +
|
||||
'所以語意搜尋等於全盲。修法有先後:**先**建 metadata index' +
|
||||
'(owner_id/entry_type/source/library),**再** POST /embed/backfill {"reindex":true}' +
|
||||
'——順序反了或只做 reindex 都不會生效。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
tested: true,
|
||||
passed,
|
||||
note: passed
|
||||
? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的'
|
||||
: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)',
|
||||
enabled: true, tested: true, passed: false, filter_blind: false,
|
||||
note: '拿一張已標記「已嵌入」的卡片自我查詢,不論帶不帶歸屬條件都搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,41 @@ function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknow
|
||||
else void p.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 「這次零命中,是不是因為 Vectorize 的 metadata 過濾整個是死的?」
|
||||
*
|
||||
* 🔴 2026-08-11 立(Arcrun#85 D70,leo21c 實撞):那台實例的現役 index
|
||||
* `arcrun-kbdb-embed-m3` 上 **一個 metadata index 都沒有**(換代時漏建),於是
|
||||
* Vectorize 對 owner_id/source/entry_type/library 下任何 filter 都回 0 筆。
|
||||
* 而**每一條真實使用者路徑都會帶 owner_id 做租戶隔離**(portal、MCP、workflow 搜尋皆然)
|
||||
* ⇒ 語意搜尋 100% 全盲,但系統只會回「沒有找到符合的內容,換個說法再試試看」。
|
||||
*
|
||||
* 判法不靠猜、也不查 Cloudflare 設定(KBDB 這面牆內打不到那支 API):
|
||||
* **同一句查詢,把 metadata filter 全部拿掉再打一次**。
|
||||
* 有命中 → 向量在 index 裡,死的是 filter(回 true)
|
||||
* 仍零命中 → 就是這次查詢真的沒撞到東西(回 false,維持 no_match)
|
||||
*
|
||||
* 成本紀律:只在「已有嵌入資料卻零命中」這個**本來就已經降級**的分支才會被呼叫,
|
||||
* 正常有結果的查詢一次都不會多花。多的是一次 AI.run + 一次 Vectorize query。
|
||||
* 沒帶任何 filter 的查詢直接回 false(沒有 filter 可以怪,也不必多打一次)。
|
||||
* 探針自己出錯一律回 false——診斷絕不能把查詢本身弄壞(誠實限制,mindset §7)。
|
||||
*/
|
||||
async function filterIsBlind(
|
||||
env: Bindings,
|
||||
q: string,
|
||||
f: { owner_id?: string; source?: string; entry_type?: string; library?: string[] },
|
||||
): Promise<boolean> {
|
||||
const hasFilter = !!(f.owner_id || f.source || f.entry_type || (f.library && f.library.length > 0));
|
||||
if (!hasFilter) return false;
|
||||
try {
|
||||
// min_score:0 + 小 topK:只問「拿掉 filter 到底有沒有東西」,不問品質。
|
||||
const probe = await semanticSearch(env, q, { topK: 5, min_score: 0 });
|
||||
return (probe ?? []).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// library 多值參數(逗號分隔,portal-auth P1,design §3.3)。空值/全空白 → undefined(=不過濾,
|
||||
// 行為與未帶參數一字不變——向後相容硬驗收)。
|
||||
function parseLibraryParam(raw: string | undefined): string[] | undefined {
|
||||
@@ -289,7 +324,7 @@ entryRoutes.get('/search', async (c) => {
|
||||
// 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。
|
||||
// 正常有結果(entries.length>0)完全不受影響,回應形狀不變。
|
||||
if (entries.length === 0) {
|
||||
let empty_reason: 'no_index' | 'no_match' | 'stale_index';
|
||||
let empty_reason: 'no_index' | 'no_match' | 'stale_index' | 'filter_blind';
|
||||
let capability_hint: string;
|
||||
let admin_hint: string;
|
||||
if (hits.length === 0) {
|
||||
@@ -308,6 +343,26 @@ entryRoutes.get('/search', async (c) => {
|
||||
capability_hint =
|
||||
'這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。';
|
||||
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`;
|
||||
} else if (await filterIsBlind(c.env, q, { owner_id, source, entry_type, library })) {
|
||||
// 🔴 2026-08-11(Arcrun#85 D70,leo21c 實撞,三小時才挖出來的那個病):
|
||||
// 「有 N 筆嵌入資料卻零命中」在這裡曾一律被歸成 no_match,回給使用者
|
||||
// 「換個說法再試試看」——但那台實例的真相是 **Vectorize 的 metadata index
|
||||
// 一個都沒建**(換 index 世代時漏了),所以**每一次**帶 owner_id 的語意查詢
|
||||
// 都回 0,換幾種說法都一樣。把系統故障說成使用者的問題,正是 leo 08-09
|
||||
// 直令禁止的那件事;而且它是靜默的——沒人會因為「搜不到」去查 Vectorize 設定。
|
||||
// 判法不靠猜:**同一句查詢拿掉 metadata filter 再打一次**,有命中就證明
|
||||
// 向量在索引裡、死的是 filter(見 filterIsBlind)。
|
||||
empty_reason = 'filter_blind';
|
||||
capability_hint =
|
||||
'語意搜尋目前故障——你的資料都在,是我們的索引設定壞了,所以每一次語意搜尋都會空手而回。' +
|
||||
'這不是你打的字有問題,換個說法也不會有用。請先用關鍵字搜尋,我們會修好它。';
|
||||
admin_hint =
|
||||
`owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料;帶 metadata filter 零命中,` +
|
||||
'但同一句查詢拿掉 filter 後有命中 ⇒ 向量在 index 裡,死的是 Vectorize metadata 過濾。' +
|
||||
'成因:該 index 上沒有對應的 metadata index(換 index 世代/改名時最常漏),' +
|
||||
'或既有向量早於 metadata index 的建立時間。修法有先後:**先**建 metadata index' +
|
||||
'(owner_id/entry_type/source/library,acr 的 ensureVectorizeMetadataIndexes 會冪等建),' +
|
||||
'**再** POST /embed/backfill {"reindex":true} 重推到 remaining=0。只做 reindex 不會生效。';
|
||||
} else {
|
||||
empty_reason = 'no_match';
|
||||
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
|
||||
|
||||
@@ -113,6 +113,70 @@ describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的
|
||||
expect(r.passed).toBeNull();
|
||||
});
|
||||
|
||||
// ── Arcrun#85 D70(2026-08-11 leo21c 全盲事故)──────────────────────────────
|
||||
// 兩種故障的**處方相反**,舊版都只回一句「需要重新 reindex」:
|
||||
// ① metadata filter 死掉(metadata index 沒建)→ 先建 index 再 reindex;只 reindex 無效
|
||||
// ② 向量不在現役 index(換世代沒重嵌) → reindex 才是對的
|
||||
// 判法=拿掉 filter 再查一次。下面的假 VECTORIZE 依「有沒有帶 filter」回不同結果,
|
||||
// 精確重現 leo21c 的現場(不帶 filter score 0.8957 命中、帶 owner_id 0 命中)。
|
||||
function makeFilterAwareEnv(
|
||||
store: Entry[],
|
||||
opts: { unfilteredMatches: { id: string; score: number }[]; filteredMatches: { id: string; score: number }[] },
|
||||
): Bindings {
|
||||
return {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: {
|
||||
async query(_v: number[], o?: { filter?: Record<string, unknown> }) {
|
||||
const filtered = !!(o?.filter && Object.keys(o.filter).length > 0);
|
||||
return { matches: filtered ? opts.filteredMatches : opts.unfilteredMatches };
|
||||
},
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
it('不帶 filter 搜得到、帶 owner_id 搜不到 → filter_blind:true,處方是「先建 metadata index 再 reindex」', async () => {
|
||||
const store = [mkEntry('e1', '淡水河口的黑面琵鷺在退潮時會集體覓食', 'bfezv28v')];
|
||||
const env = makeFilterAwareEnv(store, {
|
||||
unfilteredMatches: [{ id: 'e1', score: 0.8957 }], // leo21c 實測分數
|
||||
filteredMatches: [],
|
||||
});
|
||||
const r = await embedSelfTest(env, { owner_id: 'bfezv28v' });
|
||||
expect(r.tested).toBe(true);
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.filter_blind).toBe(true);
|
||||
expect(r.note).toContain('metadata');
|
||||
// 🔴 處方順序必須寫出來——只叫人 reindex 正是 leo21c 修不好的原因
|
||||
expect(r.note).toContain('reindex');
|
||||
expect(r.note).toMatch(/先.*建.*再/s);
|
||||
});
|
||||
|
||||
it('帶不帶 filter 都搜不到 → filter_blind:false,處方才是 reindex', async () => {
|
||||
const store = [mkEntry('e1', 'content', 'o1')];
|
||||
const env = makeFilterAwareEnv(store, { unfilteredMatches: [], filteredMatches: [] });
|
||||
const r = await embedSelfTest(env, { owner_id: 'o1' });
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.filter_blind).toBe(false);
|
||||
expect(r.note).toContain('reindex');
|
||||
expect(r.note).not.toContain('metadata index 沒建');
|
||||
});
|
||||
|
||||
it('帶 filter 就搜得到 → passed:true、filter_blind:null,且不多花第二次查詢', async () => {
|
||||
const store = [mkEntry('e1', 'content', 'o1')];
|
||||
let queries = 0;
|
||||
const env = {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { queries++; return { matches: [{ id: 'e1', score: 0.9 }] }; } },
|
||||
} as unknown as Bindings;
|
||||
const r = await embedSelfTest(env, { owner_id: 'o1' });
|
||||
expect(r.passed).toBe(true);
|
||||
expect(r.filter_blind).toBeNull();
|
||||
expect(queries).toBe(1); // 健康的情況不該多打一次(成本紀律)
|
||||
});
|
||||
|
||||
it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => {
|
||||
const store = [mkEntry('e1', 'this is the secret card body, must never leak')];
|
||||
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -107,3 +107,80 @@ describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼
|
||||
expect(body.capability_hint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 第四態 filter_blind(Arcrun#85 D70,2026-08-11 leo21c 實撞)─────────────────
|
||||
//
|
||||
// 現場:現役 Vectorize index `arcrun-kbdb-embed-m3` 上**一個 metadata index 都沒有**
|
||||
// (真兇=arcrun-rag 安裝器把端點寫成 `metadata-index/create`,連字號版 CF 回 404,
|
||||
// 底線 `metadata_index/create` 才是對的;而該安裝器把失敗降級成一行 ⚠ 就宣告成功)。
|
||||
// ⇒ Vectorize 對 owner_id 下 filter 一律回 0 筆,而**每一條真實使用者路徑都帶 owner_id**
|
||||
// 做租戶隔離 ⇒ 語意搜尋 100% 全盲。
|
||||
// 實測(leo21c,同一句查詢):不帶 filter → 1 命中 score 0.8957;帶 owner_id → 0 命中。
|
||||
//
|
||||
// 舊行為把這個歸成 no_match,回「換個說法或更具體的關鍵字再試試看」
|
||||
// =**把系統故障說成使用者的問題**,正是 leo 2026-08-09 直令禁止的那件事,
|
||||
// 而且沒有人會因為「搜不到」去翻 Cloudflare 的 Vectorize 設定。
|
||||
function makeFilterAwareEnv(
|
||||
dbOpts: Parameters<typeof makeFakeDB>[0],
|
||||
opts: { unfiltered: { id: string; score: number }[]; filtered: { id: string; score: number }[] },
|
||||
): Bindings {
|
||||
return {
|
||||
DB: makeFakeDB(dbOpts),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: {
|
||||
async query(_v: number[], o?: { filter?: Record<string, unknown> }) {
|
||||
const filtered = !!(o?.filter && Object.keys(o.filter).length > 0);
|
||||
return { matches: filtered ? opts.filtered : opts.unfiltered };
|
||||
},
|
||||
},
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
describe('empty_reason=filter_blind — Vectorize metadata 過濾整個是死的', () => {
|
||||
it('帶 owner_id 零命中、拿掉 filter 有命中 → filter_blind,且照實說是我們的故障', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeFilterAwareEnv(
|
||||
{ embeddedCount: 805, hydrateEntry: mkEntry('e1') },
|
||||
{ unfiltered: [{ id: 'e1', score: 0.8957 }], filtered: [] },
|
||||
);
|
||||
const res = await app.request('/entries/search?q=黑面琵鷺&mode=semantic&owner_id=bfezv28v', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.count).toBe(0);
|
||||
expect(body.empty_reason).toBe('filter_blind');
|
||||
const hint = body.capability_hint as string;
|
||||
// 🔴 誠實鐵律:是故障、不是使用者的錯,且**明說換個說法沒有用**
|
||||
expect(hint).toContain('故障');
|
||||
expect(hint).toContain('不是你');
|
||||
expect(/換個說法也不會有用/.test(hint)).toBe(true);
|
||||
// 🔴 人話紅線:不准把 Vectorize/owner_id 這類內部詞漏給使用者
|
||||
expect(/vectorize|owner_id|metadata|index/i.test(hint)).toBe(false);
|
||||
// 技術細節與**處方順序**留給維運者
|
||||
const admin = body.admin_hint as string;
|
||||
expect(admin).toContain('metadata index');
|
||||
expect(admin).toContain('reindex');
|
||||
});
|
||||
|
||||
it('沒帶任何 filter 的查詢不做探針,維持 no_match(不多花一次查詢)', async () => {
|
||||
const app = makeApp();
|
||||
let queries = 0;
|
||||
const env = {
|
||||
DB: makeFakeDB({ embeddedCount: 42 }),
|
||||
ENVIRONMENT: 'test',
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { queries++; return { matches: [] }; } },
|
||||
} as unknown as Bindings;
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.empty_reason).toBe('no_match');
|
||||
expect(queries).toBe(1);
|
||||
});
|
||||
|
||||
it('帶 filter 但拿掉 filter 也零命中 → 仍是 no_match(別把正常的找不到誣賴成故障)', async () => {
|
||||
const app = makeApp();
|
||||
const env = makeFilterAwareEnv({ embeddedCount: 42 }, { unfiltered: [], filtered: [] });
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.empty_reason).toBe('no_match');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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