Compare commits

..

3 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
18 changed files with 685 additions and 198 deletions
+60 -11
View File
@@ -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 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)。
* 冪等:已存在(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 路徑。
+3 -26
View File
@@ -1,6 +1,6 @@
import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
import { wasmWorkerUrl } from '../lib/component-loader';
import { resolveRecipe } from '../routes/recipes';
import type { RecipeDefinition } from '../routes/recipes';
import { branchHintFor } from '../lib/branch-hints';
@@ -44,12 +44,8 @@ export type NodeInfo = {
status: NodeStatus;
componentId?: string;
type: NodeRole;
/**
* found 時標來源庫:零件 registrycomponent)、recipe 庫(recipe),
* 或 cypher-executor 自帶、無須查 registry 即保證解析得動的執行期原生零件(builtin,
* Arcrun#88——component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS)。
*/
source?: 'component' | 'recipe' | 'builtin';
/** found 時標來源庫:零件 registrycomponent)或 recipe 庫(recipe)。 */
source?: 'component' | 'recipe';
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
input_schema?: unknown;
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
@@ -216,25 +212,6 @@ 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,33 +88,6 @@ 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> => {
+8 -1
View File
@@ -1860,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,
@@ -1871,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-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 ?? '',
},
};
@@ -1,116 +0,0 @@
/**
* 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');
});
});
+15 -3
View File
@@ -330,6 +330,15 @@ type LibraryNameSet = Set<string>;
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
// 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(
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
View File
@@ -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-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 {
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',
};
}
+56 -1
View File
@@ -35,6 +35,41 @@ function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknow
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(=不過濾,
// 行為與未帶參數一字不變——向後相容硬驗收)。
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-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 {
empty_reason = 'no_match';
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
+64
View File
@@ -113,6 +113,70 @@ describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的
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 () => {
const store = [mkEntry('e1', 'this is the secret card body, must never leak')];
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
+34 -1
View File
@@ -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 原本不濾 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 () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
@@ -107,3 +107,80 @@ describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼
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');
});
});
+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 全綠)。
tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
`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 後再清。