Compare commits

...

5 Commits

Author SHA1 Message Date
uncle6me-web 525faaf5d0 fix(cypher-executor): /cypher/search 不再誤報執行期原生零件 not_found(Arcrun#88)
病因:/cypher/search 只查 component registry(SUBMISSIONS_KV,經 submitComponent/
index-only 才有記錄);而 component-loader.ts 能直接解析、從不查 registry 的一整類
零件(trigger_workflow/BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS,
如 if_control/http_request/switch)從未被 submit 過。leo21c 實例實測:
GET /components/catalog 回 404「零件 catalog 不存在」,search 因此對這些零件誠實地
回「兩庫都查過沒有」——但它們其實跑得動(leo 08-11 探測工作流已證)。

修法:從 component-loader.ts 匯出 RUNTIME_NATIVE_COMPONENT_IDS(既有三份執行期
解析白名單的聯集,非新清單),search-nodes.ts 在查 registry 之前先比對,
不受 registry 是否可達/是否已 backfill 影響。真正不存在的零件仍誠實回
not_found/unknown,not_found 的分型建議與相似候選機制不變。

刻意不做:不掃描 registry/components/* 目錄當清單來源——那含已標記待刪的死碼
(km_writer/kbdb_upsert_block),07-30 曾把這類死碼誤灌進 registry;也不投資
SUBMISSIONS_KV 的 backfill 腳本——decisions-summary.md D29 已定調 SUBMISSIONS_KV
併入「KV 退休戰」,不宜再加投資。

測試:cypher-executor/tests/search-nodes-runtime-native.test.ts 7 case 全綠,
複現 registry unreachable/registry 可達但目錄空(leo21c 實例的真實症狀)兩種情境。
全 suite 迴歸:357 pass(較修前 350 pass 多 7 個新測試),既有 14 個失敗與修前
數量、內容完全相同(pre-existing,與本次改動無關)。
2026-08-11 21:51:21 +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
8 changed files with 497 additions and 7 deletions
+26 -3
View File
@@ -1,6 +1,6 @@
import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
import { wasmWorkerUrl } from '../lib/component-loader';
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
import { resolveRecipe } from '../routes/recipes';
import type { RecipeDefinition } from '../routes/recipes';
import { branchHintFor } from '../lib/branch-hints';
@@ -44,8 +44,12 @@ export type NodeInfo = {
status: NodeStatus;
componentId?: string;
type: NodeRole;
/** found 時標來源庫:零件 registrycomponent)或 recipe 庫(recipe)。 */
source?: 'component' | 'recipe';
/**
* found 時標來源庫:零件 registrycomponent)、recipe 庫(recipe),
* 或 cypher-executor 自帶、無須查 registry 即保證解析得動的執行期原生零件(builtin,
* Arcrun#88——component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS)。
*/
source?: 'component' | 'recipe' | 'builtin';
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
input_schema?: unknown;
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
@@ -212,6 +216,25 @@ export async function searchNodes(
continue;
}
// ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ──────────────────
// component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDStrigger_workflow
// BUILTIN_COMPONENTSLOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS 的聯集——
// 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。
// 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control
// http_requestswitch…)會被下面「兩庫都查過沒有」誤判成 not_found——
// 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。
// target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
source: 'builtin',
branch_hint: branchHintFor(componentId),
};
continue;
}
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
@@ -88,6 +88,33 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
};
/**
* 「查得到 vs 真的有」的單一真相源(Arcrun#882026-08-11)。
*
* 病因:`/cypher/search``search-nodes.ts`)只查 component registry`SUBMISSIONS_KV`
* 經 `submitComponent`/`index-only` 才會有記錄);而本檔 0/1/5/7 四步驟能直接解析、
* **完全不查 registry** 的一整類零件(trigger_workflow、BUILTIN_COMPONENTS、
* LOGIC_BINDING_MAP、WASM_HTTP_RUNNER_IDS)從未被 submit 過(也不需要——它們是
* cypher-executor 自帶的,不是投稿存量)。實測 leo21c 實例:`/components/catalog`
* 404registry 是舊版沒這端點/索引空),search 因此對 `if_control``http_request`
* 誠實地回「兩庫都查過沒有」——但這兩顆其實跑得動(leo 08-11 探測工作流已證)。
*
* 修法:把「執行期真的解析得動」的這份清單匯出給 search-nodes.ts,在查 registry
* **之前**先比對——讓「查得到」不受 registry 是否可達/是否已 backfill 影響。
*
* 刻意不做的事:不去掃 `registry/components/*` 目錄當清單來源——那是零件原始碼
* 存放處,含已標記待刪的死碼(`km_writer``kbdb_upsert_block`,見
* `system-dev/docs/3-specs/arcrun-usable/cleanup-dead-code.md`);07-30 曾把這類死碼
* 誤灌進 registry(leo 點名的錯)。這裡改用**執行期真正拿去 resolve 的白名單本身**
* (本檔 1/5/7 步驟既有的三份清單)——精確等於「解析得動」,不會多一顆、不會少一顆。
*/
export const RUNTIME_NATIVE_COMPONENT_IDS: ReadonlySet<string> = new Set([
'trigger_workflow',
...BUILTIN_COMPONENTS.keys(),
...Object.keys(LOGIC_BINDING_MAP),
...WASM_HTTP_RUNNER_IDS,
]);
export function createComponentLoader(env: Bindings) {
return async (componentId: string): Promise<ComponentRunner> => {
+6
View File
@@ -21,6 +21,12 @@ healthRouter.get('/health', (c) => {
ok: true,
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
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' } });
});
// 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(限本租戶範圍內)────────────────────────────────────────────────────
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
+14 -4
View File
@@ -902,8 +902,17 @@ portalRouter.get('/portal/password/reset-link', (c) => {
// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。
//
// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。
// 唯一會回錯的是「這台實例根本沒設代寄服務」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
// 唯一會回錯的是「寄信功能根本沒接上」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。
//
// 🔴 arcrun-rag#38/#69/#252026-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 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
@@ -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);
});
});
@@ -0,0 +1,116 @@
/**
* Arcrun#88
*
* leo21c 2026-08-11
* `/cypher/search` `if_control``http_request` `not_found`
* component-loader.ts LOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS
* registryregistry catalog 404`GET /components/catalog`
* `{"success":false,"error":"零件 catalog 不存在"}`search
*
* wrangler.test.toml WORKER_SUBDOMAIN registryBase
* undefined catalog.status='unreachable'registry
* RUNTIME_NATIVE_COMPONENT_IDS registry **** found
* registry registry
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { parseTriplets } from '../src/actions/triplet-parser';
import { searchNodes } from '../src/actions/search-nodes';
afterEach(() => {
vi.unstubAllGlobals();
});
const IF_CONTROL_TRIPLETS = [
'input >> ON_SUCCESS >> if_control',
];
const HTTP_REQUEST_TRIPLETS = [
'input >> ON_SUCCESS >> http_request',
];
const MULTI_BUILTIN_TRIPLETS = [
'input >> ON_SUCCESS >> switch',
'input >> ON_SUCCESS >> filter',
'input >> ON_SUCCESS >> code',
];
const FAKE_COMPONENT_TRIPLETS = [
'input >> ON_SUCCESS >> totally_made_up_component_xyz',
];
describe('Arcrun#88:執行期原生零件不受 registry 健康狀態影響', () => {
it('if_controlLOGIC_BINDING_MAP 成員)在 registry 不可達時仍回 found', async () => {
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
expect(parsed).not.toBeNull();
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
// 無 WORKER_SUBDOMAINREGISTRY_BASE_URL → registryBase undefined → catalog unreachable
});
expect(nodeResults.if_control.status).toBe('found');
expect(nodeResults.if_control.source).toBe('builtin');
// if_control 會分岔,branch_hint 應隨 found 一併附上(不必逐顆再查一次)
expect(nodeResults.if_control.branch_hint?.edge_types).toEqual(['ON_TRUE', 'ON_FALSE']);
expect(missingNodes).not.toContain('if_control');
});
it('http_requestWASM_HTTP_RUNNER_IDS 成員)在 registry 不可達時仍回 found', async () => {
const parsed = parseTriplets(HTTP_REQUEST_TRIPLETS);
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.http_request.status).toBe('found');
expect(nodeResults.http_request.source).toBe('builtin');
expect(missingNodes).not.toContain('http_request');
});
it('switchfiltercode(同一批白名單的其他成員)也回 found,不逐一漏網', async () => {
const parsed = parseTriplets(MULTI_BUILTIN_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.switch.status).toBe('found');
expect(nodeResults.filter.status).toBe('found');
expect(nodeResults.code.status).toBe('found');
});
it('registry 完全連不到時,真正不存在的名字誠實回 unknown(不敢空口說沒有——既有行為,修法沒有動它)', async () => {
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.totally_made_up_component_xyz.status).toBe('unknown');
});
it('registry 查得到但目錄是空的(複現 leo21c 實例 catalog 404 的真實症狀):真正不存在的名字回 not_found', async () => {
// 複現生產實測:GET /components/catalog → HTTP 200 空陣列(本測試模擬「registry 活著但沒東西」,
// 與 leo21c 實例的「404 零件 catalog 不存在」殊途同歸——都會落到「查得到、目錄無此零件」)。
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
));
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
WORKER_SUBDOMAIN: 'test-sub',
});
expect(nodeResults.totally_made_up_component_xyz.status).toBe('not_found');
expect(missingNodes).toContain('totally_made_up_component_xyz');
});
it('registry 目錄是空的(catalog 通但無資料)時,執行期原生零件依然 found——這才是 Arcrun#88 的核心場景', async () => {
// 這就是 leo21c 實例的真實狀態:registry 活著、目錄卻沒有任何一顆執行期原生零件的記錄
// SUBMISSIONS_KV 從未收到 if_control/http_request 的 submit)。若沒有本次修法,
// 這裡會落到「兩庫都查過沒有」→ not_found,正是 Arcrun#88 回報的病徵。
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
));
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, { WORKER_SUBDOMAIN: 'test-sub' });
expect(nodeResults.if_control.status).toBe('found');
expect(nodeResults.if_control.source).toBe('builtin');
});
it('target=recipe 明確只查 recipe 庫時,執行期原生零件不搶答 found(尊重使用者明確限庫)', async () => {
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {}, 'discover', 'recipe');
// if_control 從來不是 recipetarget=recipe 下不該被 builtin 短路成 found
expect(nodeResults.if_control.status).not.toBe('found');
});
});
+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');
});
});