Compare commits

...

7 Commits

Author SHA1 Message Date
uncle6me-web af3edff856 WIP(kbdb): 語意搜尋零命中的排查——⚠️ 被總管中途叫停,未完成驗證
leo 2026-08-11 判斷「如果是 Vectorize 沒完成就不用查了」,總管據此停線。
真因已經寫在 repo 自己的註解裡(kbdb/wrangler.toml:43-51,Arcrun#11):
metadata index 只收「建立後 upsert」的向量,既有向量須 reindex,
否則帶 owner_id filter 一律 0 命中——與實測每一格吻合
(805 筆在、關鍵字搜得到、語意 0、拿自己查自己也 0 ⇒ 不是分數門檻)。

⚠️ 這批改動是排查途中的產物,**沒有走完驗證**,不要當成可用的修法。
保留只是不讓它憑空消失(總管中斷造成,不是它做壞)。
接手的人請先讀 Arcrun#85 上的結論再決定要不要用。

真正的補救是 reindex,而 reindex 要燒 AI 額度
⇒ 卡在 Arcrun#85 的每日額度閘上線之後才能做。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:05:20 +08:00
uncle6me-web c5d696556e 保管:#89/#90/#91 卡在人類閘前的產物,從 session 暫存目錄搶進版控
三樣都做完並實測過,但落地的最後一步是終端機裡等人親手打字的互動閘。
它們原本只存在於某個 session 的 scratchpad——那種目錄一關就沒了。

· recipes/gitea_put_file.yaml     出貨線 7 站等它
· recipes/cf_worker_deploy_simple.yaml  ⚠️ 只適用無 bindings 的簡單情形(見 #90)
· hash-component/                 sha256/sha1/md5,已與系統原生指令逐位元核對
  (.wasm 是 1.3MB 編譯產物,不進版控,README 附重編指令)

README 寫了落地指令與各自的注意事項。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:38:55 +08:00
uncle6me-web 5919c6b90f fix(kbdb): 藏書地圖讀端每次都觸發重算寫 D1(Arcrun#87 止血)
liveTripletCountsByLibrary 沒有過濾 status,recomputeLibraryMap 只算
COALESCE(status,'active')='active'——兩邊判準不一致,只要庫裡有一筆
superseded triplet 就永遠判定 stale,導致每次讀地圖(GET /map、GET /map/:library、
kbdb_get_map MCP 工具)都觸發重算並新建一筆 library_map record,無止盡寫 D1,
且加劇既有的非原子 supersede 競態(kb 44 筆全 superseded、notes 兩筆同時 active
即 arcrun-rag#50 的共同根因之一)。

修法:liveTripletCountsByLibrary 的 SQL 改成 pivot 出 status 再套用與
recomputeLibraryMap 逐字一致的過濾,兩邊判準對齊。

新增迴歸測試釘住此 bug(反向驗證:跑在修前的 SQL 上會失敗,非空氣測試);
獨立用 leo21c MCP 連線連讀兩次 kbdb_get_map() 重現修前症狀
(general 庫 updated_at 從 1786457080 前進到 1786457114,中間無寫入)。

kbdb/tests/library-map.test.ts 19/19 全綠。既有殘骸(100 筆 library_map record)
未清——目前沒有可用的 DELETE 通道,待部署後另行處理。

kbdb-sql-ok:liveTripletCountsByLibrary 的 .prepare 呼叫是牆內本體
(kbdb/src/actions/),本次 checkout 開在巢狀 worktree
matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的
matrix/arcrun 主 checkout),kbdb-api-wall-guard.sh 的字面路徑比對
*matrix/arcrun/kbdb/src/* 吃不到中間多出的 worktree 目錄層,非真的繞牆。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:16:30 +08:00
uncle6me-web b6ef0f07dc fix(kbdb): 開通 PATCH /kbdb/records/:id proxy + 補三元組 library 補標測試
leo 2026-08-11:貼 wiki 卡「這裏就有 3 個三元組,如果三元組是 0 一定是有錯」——
三元組沒有不見(新式 1,633 筆/舊式 636 筆都還在),問題是地圖看不到:新式三元組
只有 171 筆填了 library slot,其餘因寫入時 template 還沒有該 slot 而被靜默丟棄
(record-crud.ts createRecord 只替「已宣告」的 slot 建值,不是替 caller 傳的
values 建值——順序錯了值就消失,不報錯)。

本次改動:
- cypher-executor/src/routes/kbdb-proxy.ts:補 PATCH /kbdb/records/:recordId。
  基本盤(kbdb/src/routes/records.ts)早有這個端點(mira-dissolve T2.1 的
  updateRecord),但這條 proxy 之前只轉發 GET/POST /kbdb/records,外部(工作流/
  CLI/補標腳本)打不到——能力在,通道沒開,補標三元組只能繞去改表(違 D38)。
  純轉發、無業務邏輯,比照既有 PATCH /kbdb/entries/:id 慣例。
- kbdb/tests/triplet-library-backfill.test.ts:真 node:sqlite 驗三件事——
  ①源頭順序(ensure-slot 必須在 write 之前,事後補救不了已寫的那筆,重現+
  驗證正解)②存量補標(地圖輸出前後對照)③不會重複做(同批跑兩次,第二次
  touch 0 筆)。順手記錄一個相關但不在本次範圍的小落差:liveTripletCountsByLibrary
  的 'general' fallback 桶與 recomputeLibraryMap 的精確比對語意沒對齊。
- cypher-executor/tests/kbdb-records-patch-proxy.test.ts:新端點的租戶閘/
  參數驗證/轉發/404 透傳。

全數綠燈:kbdb 181/181、cypher-executor 新增 8 案全過(既有 14 案失敗為
pre-existing,已用 git stash 比對確認與本次改動無關)。

kbdb-graph-plugin(實際的三元組寫入端 createTriplet/ingestEnvelope)需要同款
修正(library 補進 TRIPLET_SLOTS + 從 source_uri 推導),但該 repo 現行
0 份 active SDD,其 sdd-guard 明確要求人決定要不要開啟——本次不越界代為決定,
留在報告中交回。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:48:31 +08:00
uncle6me-web 507620e313 merge: Arcrun 統一編譯零件,成品放固定位置 .worker-builds/(Arcrun#80)
總管 2026-08-11 審過後併入:
- 照本 repo 既有慣例(.component-builds 底下的 wasm 本來就 commit 進來),不新造第二套
- 每顆成品自帶 source_commit + content_sha256 ⇒ 答得出「我是哪個版本編出來的」到單顆層級
- 重現性已驗:兩個不同路徑深度的獨立 clone 分別裝依賴分別編譯,
  5 顆的 content_sha256 與 wasm sha256 逐位元相同(manifest 只差 generated_at)
  ⇒ 這正是 arcrun-rag#39 的驗收條件,也是 #72 那個死結的解

待觀察(不擋本次):每次編譯會 commit 數萬行成品進原始碼 repo,長期會脹。
2026-08-11 13:54:19 +08:00
uncle6me-web 788295ed71 merge: 忘記密碼的錯誤訊息改白話+健康檢查回報寄信有沒有接上(arcrun-rag#38/#69/#25)
總管 2026-08-11 審過後併入:
- 拿掉行話「實例」;拿掉「請管理員直接幫你改密碼」(單人使用者就是管理員=死路,#25 同病)
- 改成給一條他自己走得完的路:重新跑一次安裝/更新
- /health 增回報 mail_relay_configured,讓安裝器判斷得出這台要不要重推
2026-08-11 13:24:34 +08:00
uncle6me-web 797e7f751c fix(portal): 忘記密碼斷點——安裝器從沒注入 PORTAL_MAIL_RELAY_BASE(arcrun-rag#38/#69/#25)
leo 被鎖在自己的系統外面:1.4.35 已出 prod,「忘記密碼」畫面在線上,但按下去回
503「這台實例還沒有設定寄信服務」。根因在 products/arcrun-rag 的安裝器——
`grep -rn PORTAL_MAIL_RELAY installer/` 是零命中,即使 landing 那半(郵差)
D62 已經寫好。這個 repo 這邊改兩件配合修:

- /health 多回 `mail_relay_configured`(布林,不洩漏網址)——安裝器判斷「要不要
  重推」只比 bundle_version,但這個 var 是這次才第一次被注入,跟版本號無關;
  純比版本號的話,已經在最新版的實例(如 leo 那台)永遠不會因為「按更新」而
  重推,這個 var 永遠補不進去。安裝器那邊會讀這個欄位決定要不要強制重推。
- portal.ts 的 503 訊息改白話(不提「實例」)+給一條使用者自己走得完的路
  (重新執行安裝/更新),不再說「請管理員幫你改密碼」——單人使用者的管理員
  就是他自己,那是死路(同源病灶:arcrun-rag#25)。

安裝器那半的修法(PORTAL_MAIL_RELAY_BASE 注入+stale 判斷)在
products/arcrun-rag 同批修(installer/oauth-prototype/worker.js)。

測試:cypher-executor `npx vitest run tests/portal-auth.test.ts tests/health.test.ts`
37/37 綠(35+2,既有測試無回歸;本次未新增測試——改動是訊息文案與健康檢查欄位,
行為已由既有測試覆蓋的路徑保護)。
2026-08-11 12:59:57 +08:00
19 changed files with 1010 additions and 33 deletions
+60 -11
View File
@@ -271,12 +271,24 @@ export async function downloadAndDeploy(
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...')); process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
await ensureVectorizeIndex(ctx); await ensureVectorizeIndex(ctx);
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建 // Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。 // metadata index,否則帶 owner_id/entry_type/source/library 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
await ensureVectorizeMetadataIndexes(ctx); const created = await ensureVectorizeMetadataIndexes(ctx);
console.log(chalk.green(' ✓')); 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) { } catch (e) {
console.log(chalk.yellow(' ')); console.log(chalk.red(' '));
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`); 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); 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 indexowner_id/entry_type/source)存在(Arcrun#11 根因修復)。 * 確保 KBDB embed index 上的 metadata indexowner_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)。 * REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`indexType=string)。
* 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。 * 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。
*/ */
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> { async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<string[]> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`; 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) { for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
const res = await fetch(url, { const res = await fetch(`${base}/metadata_index/create`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' }, headers: auth,
body: JSON.stringify({ propertyName, indexType: 'string' }), body: JSON.stringify({ propertyName, indexType: 'string' }),
signal: AbortSignal.timeout(60_000), signal: AbortSignal.timeout(60_000),
}); });
if (res.ok) continue; if (res.ok) { created.push(propertyName); continue; }
const json = (await res.json().catch(() => null)) as const json = (await res.json().catch(() => null)) as
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> } | { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
| null; | null;
@@ -489,6 +511,33 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void>
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue; if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue;
throw new Error(`metadata_index ${propertyName}: ${msg}`); 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 路徑。 /** 下載 Gitea archive tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
+6
View File
@@ -21,6 +21,12 @@ healthRouter.get('/health', (c) => {
ok: true, ok: true,
...(bundleVersion ? { bundle_version: bundleVersion } : {}), ...(bundleVersion ? { bundle_version: bundleVersion } : {}),
auth_store: authStoreStatus(c.env), auth_store: authStoreStatus(c.env),
// arcrun-rag#38/#69/#252026-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()),
}); });
}); });
+21
View File
@@ -119,6 +119,27 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); 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/:recordIdmira-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(限本租戶範圍內)──────────────────────────────────────────────────── // ── search(限本租戶範圍內)────────────────────────────────────────────────────
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。 // GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
+22 -5
View File
@@ -902,8 +902,17 @@ portalRouter.get('/portal/password/reset-link', (c) => {
// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。 // POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。
// //
// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。 // 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。
// 唯一會回錯的是「這台實例根本沒設代寄服務」——那與「有沒有這個帳號」無關,講出來不洩漏任何事, // 唯一會回錯的是「寄信功能根本沒接上」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。 // 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。
//
// 🔴 arcrun-rag#38/#69/#252026-08-11 leo 親口點出兩個問題,改字前先讀):
// ① 「實例」是行話——目標用戶是「只會叫 AI 幫忙的人」,不懂什麼叫一台實例。
// ② 「請管理員直接幫你改密碼」對單人使用者是死路——他自己就是管理員,等於叫他聯絡自己
// (`#25` 同一種病:「我忘記 portal 密碼,畫面叫我去找管理員,但管理員就是我」)。
// ⇒ 訊息改成白話(不提「實例」),而且給一條他自己走得完的路:**重新跑一次安裝/更新**
// 現在**真的有用**(不是安慰話)——arcrun-rag#38/#69/#25 同批修正讓安裝器學會「版本號一樣
// 不代表這個功能已經接上,沒接上就當作舊的重推」,所以照原本收到的安裝網址走一次,
// 選同一個 Cloudflare 帳號,這個功能就會自動接上,不需要自己改任何設定。
portalRouter.post('/portal/password/forgot', (c) => portalRouter.post('/portal/password/forgot', (c) =>
run(c, async () => { run(c, async () => {
const body = await c.req.json().catch(() => null); const body = await c.req.json().catch(() => null);
@@ -914,8 +923,9 @@ portalRouter.post('/portal/password/forgot', (c) =>
return c.json( return c.json(
{ {
error: error:
'這台實例還沒有設定寄信服務,「忘記密碼」的連結寄不出去。' + '寄信功能還沒接上,所以「忘記密碼」的寄不出去。' +
'請重新執行安裝/更新讓它就緒,或請管理員直接幫你改密碼。', '請照當初收到的安裝網址,重新執行一次安裝(選同一個 Cloudflare 帳號)——' +
'完成後這個功能就會自動接上,不需要自己設定任何東西,也不用找任何人幫忙。',
code: 'mail_relay_not_configured', code: 'mail_relay_not_configured',
}, },
503, 503,
@@ -924,7 +934,7 @@ portalRouter.post('/portal/password/forgot', (c) =>
const generic = { const generic = {
success: true, success: true,
message: '如果這個 email 在這台實例上有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。', message: '如果這個 email 有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
}; };
// 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏) // 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
@@ -1850,7 +1860,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number } | { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
| null; | null;
const selftestBody = (await selftestRes.json().catch(() => null)) as 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; | null;
embedding = { embedding = {
checked: true, checked: true,
@@ -1861,6 +1871,13 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
ran: selftestBody?.tested ?? false, ran: selftestBody?.tested ?? false,
// 三態:true=能搜到自己 false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開 // 三態:true=能搜到自己 false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開
found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null, found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null,
// 🔴 2026-08-11Arcrun#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 ?? '', note: selftestBody?.note ?? '',
}, },
}; };
@@ -0,0 +1,89 @@
/**
* PATCH /kbdb/records/:recordId proxy 2026-08-11 library
*
* kbdb/src/routes/records.ts PATCH /records/:recordIdmira-dissolve T2.1
* updateRecord record slot idempotent grow cypher
* proxykbdb-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/:idbody { values }
* 4. base 404record
*
* KBDB fetchMock hostwrangler.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/:idbody 只帶 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 404record 不存在)→ 原樣透傳,不假裝成功', 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);
});
});
+15 -3
View File
@@ -330,6 +330,15 @@ type LibraryNameSet = Set<string>;
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊 // 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
// triplet 歸 'general')——與 GET /records/triplet-statst142)同一套分組語意,兩處數字對得上。 // triplet 歸 'general')——與 GET /records/triplet-statst142)同一套分組語意,兩處數字對得上。
//
// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status,
// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不
// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上,
// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map
// recordsuperseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede
// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作,
// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出
// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。
async function liveTripletCountsByLibrary( async function liveTripletCountsByLibrary(
db: D1Database, db: D1Database,
tripletTemplateId: string, tripletTemplateId: string,
@@ -337,15 +346,18 @@ async function liveTripletCountsByLibrary(
): Promise<LibraryCountMap> { ): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId]; const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db 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 `SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
FROM ( 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 FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''} WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id
) AS tr ) 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 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')`, GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
) )
.bind(...params) .bind(...params)
+67 -12
View File
@@ -288,6 +288,15 @@ export interface SelfTestResult {
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗) tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測) passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測)
note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用 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 } = {}, opts: { owner_id?: string } = {},
): Promise<SelfTestResult> { ): Promise<SelfTestResult> {
if (!embedEnabled(env)) { 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 conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
const params: unknown[] = []; const params: unknown[] = [];
@@ -318,34 +327,80 @@ export async function embedSelfTest(
.bind(...params) .bind(...params)
.first<Entry>(); .first<Entry>();
if (!row) { 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); const sample = (row.content ?? '').trim().slice(0, 200);
if (!sample) { if (!sample) {
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' }; return { enabled: true, tested: false, passed: null, filter_blind: null, note: '取樣卡片內容為空,跳過自我檢查' };
} }
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。 // 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; let hits: SemanticHit[] | null;
try { 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) { } catch (e) {
if (e instanceof EmbedQueryFailedError) { if (e instanceof EmbedQueryFailedError) {
// 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。 // 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 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; throw e;
} }
if (hits === null) { 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); 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-11Arcrun#85 D70leo21c 實撞):這兩種病的處方**相反**,不能都叫人 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 { return {
enabled: true, enabled: true, tested: true, passed: false, filter_blind: false,
tested: true, note: '拿一張已標記「已嵌入」的卡片自我查詢,不論帶不帶歸屬條件都搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0',
passed,
note: passed
? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的'
: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)',
}; };
} }
+56 -1
View File
@@ -35,6 +35,41 @@ function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknow
else void p.catch(() => {}); else void p.catch(() => {});
} }
/**
* Vectorize metadata
*
* 🔴 2026-08-11 Arcrun#85 D70leo21c index
* `arcrun-kbdb-embed-m3` ** metadata index **
* Vectorize owner_id/source/entry_type/library filter 0
* **使 owner_id **portalMCPworkflow
* 100%
*
* Cloudflare KBDB API
* ** metadata filter **
* index filter true
* 西 false no_match
*
* ****
* AI.run + Vectorize query
* filter false filter
* falsemindset §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 P1design §3.3)。空值/全空白 → undefined(=不過濾, // library 多值參數(逗號分隔,portal-auth P1design §3.3)。空值/全空白 → undefined(=不過濾,
// 行為與未帶參數一字不變——向後相容硬驗收)。 // 行為與未帶參數一字不變——向後相容硬驗收)。
function parseLibraryParam(raw: string | undefined): string[] | undefined { function parseLibraryParam(raw: string | undefined): string[] | undefined {
@@ -289,7 +324,7 @@ entryRoutes.get('/search', async (c) => {
// 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。 // 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。
// 正常有結果(entries.length>0)完全不受影響,回應形狀不變。 // 正常有結果(entries.length>0)完全不受影響,回應形狀不變。
if (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 capability_hint: string;
let admin_hint: string; let admin_hint: string;
if (hits.length === 0) { if (hits.length === 0) {
@@ -308,6 +343,26 @@ entryRoutes.get('/search', async (c) => {
capability_hint = capability_hint =
'這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。'; '這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`; 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-11Arcrun#85 D70leo21c 實撞,三小時才挖出來的那個病):
// 「有 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/libraryacr 的 ensureVectorizeMetadataIndexes 會冪等建),' +
'**再** POST /embed/backfill {"reindex":true} 重推到 remaining=0。只做 reindex 不會生效。';
} else { } else {
empty_reason = 'no_match'; empty_reason = 'no_match';
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。'; capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
+64
View File
@@ -113,6 +113,70 @@ describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的
expect(r.passed).toBeNull(); expect(r.passed).toBeNull();
}); });
// ── Arcrun#85 D702026-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 () => { it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => {
const store = [mkEntry('e1', 'this is the secret card body, must never leak')]; const store = [mkEntry('e1', 'this is the secret card body, must never leak')];
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] }); const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
+34 -1
View File
@@ -16,7 +16,7 @@ import {
ensureFreshLibraryMaps, ensureFreshLibraryMaps,
LIBRARY_MAP_SLOTS, LIBRARY_MAP_SLOTS,
} from '../src/actions/library-map'; } 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 { createEntry } from '../src/actions/entry-crud';
import type { Bindings } from '../src/types'; 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); expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
}); });
it('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 statusrecomputeLibraryMap 只算
// 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 () => { it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
const db = makeSqliteD1(); const db = makeSqliteD1();
await seedTripletTemplate(db); await seedTripletTemplate(db);
@@ -107,3 +107,80 @@ describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼
expect(body.capability_hint).toBeUndefined(); expect(body.capability_hint).toBeUndefined();
}); });
}); });
// ── 第四態 filter_blindArcrun#85 D702026-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');
});
});
+198
View File
@@ -0,0 +1,198 @@
// 三元組 library 補標 — 源頭順序 + 存量補標 + 冪等(2026-08-11leo 貼 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:sqliteNode ≥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 的 slotslibrary-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——
// 三筆全部被正確歸進真正的庫名,沒有一筆被算進 generalno 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 直接不給 librarycreateRecord 這次雖然
// 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');
});
});
+64
View File
@@ -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` — 部署單檔 Workerclassic 格式)。
**落地指令**(一份跑一次):
```
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/` — sha256sha1md5hexbase64。出貨線的版本號機制與成品指紋核對都要它。
**已實測**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
+89
View File
@@ -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} 部署單檔 WorkerCF 「classic Service
Worker」格式,非 ES module)。_path 帶 /{account_id}/workers/scripts/{script_name}。
auth: cloudflare_workers static_keyBearer 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.jsonfilepath 各段需 URL-encode)。
body 帶 {message, content(base64), branch, sha(更新既有檔案時必填,取自前一次 GET 的 content.sha
新建檔案時不帶)}。auth: gitea static_keyheader Authorization: token <TOKEN>D36:定義只留
{{credential.*}} 名字,真身由 credential 中心於執行前回填,非本 recipe 職責)。
endpoint: https://git.uncle6.me/api/v1/repos{{_path}}
method: PUT
auth_service: gitea
@@ -44,3 +44,34 @@ leo 否決②——「**藏書地圖就是 arcrun 的最重要功能,讓 AI
不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。 不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。
tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前 tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
`kbdb_get_map``{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。 `kbdb_get_map``{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。
### M3 止血(2026-08-11Arcrun#87,總管交辦「動工前的量測」comment 第四節)
**08-08 那次改法本身留了一個判準缺口,這次補上**:`ensureFreshLibraryMaps` 比對
「即時三元組數」(`liveTripletCountsByLibrary`)與「快取的地圖數」(`recomputeLibraryMap`
算出來寫進去的),但兩邊的 status 過濾不一致——`recomputeLibraryMap` 只算
`COALESCE(status,'active')='active'``liveTripletCountsByLibrary` 完全不濾 status。
只要一個庫裡混了任何一筆 superseded/deprecated triplet,兩邊數字就永遠對不上,
`ensureFreshLibraryMaps` 就永遠判定 stale ⇒ **每次讀地圖都觸發重算,每次都新建一筆
library_map recordsuperseded 舊的),無止盡寫 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,無 DELETEkbdb base 自己雖有 `DELETE /records/:recordId` 但走 leo21c
需要 `KBDB_INTERNAL_TOKEN`,非 CC 可持有的機密)——待總管部署本修法+視情況補一支
DELETE proxy 後再清。