diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 2b276d5..23e1f5d 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -23,7 +23,7 @@ import { Hono } from 'hono'; import type { Context } from 'hono'; import type { Bindings } from '../types'; -import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, listRecordsByTemplate, LIBRARY_TEMPLATE } from './portal'; +import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal'; import { graphBase } from './kbdb-proxy'; import { executeWebhookGraph } from '../actions/webhook-handlers'; @@ -589,147 +589,31 @@ portalDataRouter.get('/portal/data/workflows', (c) => // // 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」 // -// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。leo 把檔轉給 -// 我方時,我方要能只靠這個檔判斷病因,不必再回頭問封測者任何問題。 +// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。 // -// 🔴 兩條紅線(規格原文): -// ① 不准把內部概念暴露給用戶——本端點只回統計/狀態,前端按鈕文案不提 KBDB/Vectorize/ -// owner_id 這類詞。 -// ② 不准洩漏知識卡內容本體——以下每一個欄位都只挑「數字」或「布林」,即使背後的 KBDB -// 端點回應含 content(如 /map 的 top_entities、triplet 的 subject/object 名稱), -// 本端點一律只讀出用得到的數字後就丟掉那個回應,不把原始內容往前端送。 +// 🔴 t213(2026-08-08,InkStoneCo 總管交辦):leo 實測拿真檔驗四個真實問題,只答得出一題 +// (雲端這半的 bundle_version)——其餘三題(本機檔案總量、失敗分類統計、daemon 版本/ +// 自我更新狀態)需要本機資料,雲端這支端點天生構不到(封測者的瀏覽器與他電腦上的 +// daemon 是兩個獨立行程)。核准方案:本機那半改由 arcrun-app(daemon 桌面殼)匯出時 +// 直接讀本機檔案,並改打**新增的** `GET /portal/daemon/diagnostics`(X-Arcrun-API-Key +// 認證,免帳密)取雲端這半,兩者合併成一份完整診斷檔——arcrun-app 那半見 +// products/arcrun-rag repo t213 phase 2。本端點(portal 網頁版)保留當退路(daemon +// 完全掛掉時仍按得到),文案需誠實講清楚自己只有一半,完整診斷請去 daemon 匯出 +// (portal 前端文案改動不在本次 matrix/arcrun 範圍內,由 arcrun-rag 那邊處理)。 // -// 涵蓋「這次一定要涵蓋」的向量/embedding 健康狀態:embed 模組是否開(index 存在的前提)、 -// 已嵌入/待嵌入卡片數、以及 embedSelfTest(KBDB #12)—— 這是唯一能分辨「從沒嵌過」與 -// 「嵌了但 index 查不到自己」兩種故障模式的方法(Arcrun#11 的真實案例正是後者,光看 -// 計數看不出來)。 -// -// 認證:與其餘 /portal/data/* 同一道 requirePortalUser session 閘(不開放無登入存取—— -// 統計數字仍是這個實例的營運資訊,不對外公開)。 +// 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變,核心邏輯已抽成 buildDiagnostics() +// (portal.ts)——與新的 daemon 版共用同一份查詢邏輯(薄殼原則)。 portalDataRouter.get('/portal/data/diagnostics', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const tenant = portalTenant(c.env); - const notes: string[] = []; - - // ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。 - let embedding: Record = { checked: false }; - try { - const [statusRes, selftestRes] = await Promise.all([ - kbdbFetch(c.env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`), - kbdbFetch(c.env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`), - ]); - const statusBody = (await statusRes.json().catch(() => null)) as - | { 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 } - | null; - embedding = { - checked: true, - module_enabled: statusBody?.enabled ?? false, // Vectorize+AI binding 都在,才有「index」這回事 - cards_embedded: statusBody?.embedded ?? 0, - cards_pending: statusBody?.pending ?? 0, - self_test: { - ran: selftestBody?.tested ?? false, - // 三態:true=能搜到自己 / false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開 - found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null, - note: selftestBody?.note ?? '', - }, - }; - } catch (e) { - notes.push(`embed 健康狀態查詢失敗:${e instanceof Error ? e.message : String(e)}`); - } - - // ② 卡片與知識圖譜規模(只取數字,不取內容欄位)。 - // - // 🔴 2026-08-08 修正(leo 實測抓到:3 庫、大量 triplets,診斷檔卻回 0/0): - // 原本讀 GET /map,那是 library_map**快取 block**(kbdb/src/actions/library-map.ts), - // 只能靠 POST /map/recompute 產生。核實過:整個 repo 沒有任何呼叫點會打 - // /map/recompute(`grep -rn "map/recompute" --include=*.ts` 只中 map.ts 自己的路由定義與 - // mcp 的說明字串)——library-map SDD(system-dev/docs/3-specs/library-map/design.md, - // status: draft)的「ingest 尾端自動重算(M3)」從沒接上。⇒ /map 的 libraries 對**任何 - // 租戶**恆是空陣列,library_count/triplet_count 恆為 0,與實際資料量無關,是全租戶通病, - // 不是 leo 這個實例特有。 - // - // 改走 GET /portal/admin/libraries(本檔以外、portal.ts 973 行)驗證過在用的**即時查詢** - // 同一套組合,不依賴任何快取: - // - listRecordsByTemplate(portal_library):已登記的庫(t159) - // - GET /entries/libraries:資料裡實際蓋章出現過的庫,登記與否都算(t52, - // 「蓋章即現身」);'general' 是未標庫的系統 fallback 桶,不算使用者眼中的一個庫, - // 與 admin/libraries 同慣例排除。 - // - GET /records/triplet-stats:per-library 即時聚合 SQL(t142,COUNT,非快取)。 - let library_count = 0; - let triplet_count = 0; - const ownerParam = new URLSearchParams({ owner_id: tenant }).toString(); - try { - const [registeredLibs, autoRes, tripletRes] = await Promise.all([ - listRecordsByTemplate(c.env, LIBRARY_TEMPLATE).catch(() => []), - kbdbFetch(c.env, `/entries/libraries?${ownerParam}`), - kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`), - ]); - const knownLibs = new Set( - registeredLibs.map((r) => (r.values.name ?? '').trim()).filter((n): n is string => !!n), - ); - const autoBody = (await autoRes.json().catch(() => null)) as { success?: boolean; libraries?: string[] } | null; - for (const name of autoBody?.libraries ?? []) { - const n = String(name ?? '').trim(); - if (n && n !== 'general') knownLibs.add(n); - } - library_count = knownLibs.size; - - const tripletBody = (await tripletRes.json().catch(() => null)) as - | { success?: boolean; stats?: { library: string; triplet_count?: number }[] } - | null; - triplet_count = (tripletBody?.stats ?? []).reduce((sum, s) => sum + (Number(s.triplet_count) || 0), 0); - } catch (e) { - notes.push(`知識庫規模查詢失敗:${e instanceof Error ? e.message : String(e)}`); - } - - // ②.5 統計自我檢查(呼應下面 embedding.self_test 的精神——leo 直接指令:「不要讓『查不到』 - // 和『沒有』長得一樣」)。library_count/triplet_count 兩者都是 0 時,才另外花一次查詢, - // 用完全不同的路徑(不分庫、不分模板,只問「這個 owner_id 底下到底有沒有任何 entries」) - // 做交叉驗證——如果探測到有資料,代表問題出在查詢方式或 owner_id 對不上(2026-08-01 - // t161 前科:手動補的 record owner_id 存成 None,kbdb_query 全量查得到、按 owner_id 過濾 - // 的畫面永遠空,比真的沒資料更難查);如果探測也是空,才比較像真的是空庫。 - let library_scope_check: Record = { ran: false }; - if (library_count === 0 && triplet_count === 0) { - try { - const probeRes = await kbdbFetch(c.env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`); - const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null; - const total = probeBody?.total ?? 0; - library_scope_check = { - ran: true, - any_entries_found: total > 0, - note: - total > 0 - ? `這個租戶底下查得到其他資料(entries 共 ${total} 筆),但庫/三元組統計仍回 0——像是查詢方式或租戶對不上,不像真的沒資料,需要人再查一次` - : '這個租戶底下完全查不到任何資料——比較像是真的還沒有資料,不是查詢方式錯了', - }; - } catch (e) { - library_scope_check = { - ran: true, - any_entries_found: null, - note: `自我探測查詢本身失敗:${e instanceof Error ? e.message : String(e)}`, - }; - } - } - - // ③ 最近一次萃取(daemon → /portal/daemon/extract)成功與否:目前沒有雲端側的失敗歷史 - // 記錄可讀(該端點是同步請求/回應,失敗只回給呼叫當下的 daemon,雲端不落地保存)—— - // 誠實列出這個缺口,不假裝有數字(mindset §7 禁假綠)。 - notes.push('目前雲端沒有保存「萃取/上傳失敗」的歷史紀錄,只能看到目前的聚合計數(上面 cards_pending/cards_embedded);若要查某一次失敗的當下原因,需在失敗當下由封測者截圖同步小幫手視窗。'); - + const core = await buildDiagnostics(c.env, tenant); return c.json({ generated_at: new Date().toISOString(), instance_url: new URL(c.req.url).origin, bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null, - library_count, - triplet_count, - library_scope_check, - embedding, - notes, + ...core, }); }), ); diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 75e6e87..ab30f57 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -1261,3 +1261,167 @@ portalRouter.delete('/portal/admin/libraries/:id', (c) => }); }), ); + +// ── 檢修孔核心邏輯(t213,InkStoneCo 總管交辦,2026-08-08)────────────────────── +// +// buildDiagnostics 是 GET /portal/data/diagnostics(P3 session 版,portal 網頁「疑難排解」 +// 按鈕)與 GET /portal/daemon/diagnostics(下方新增,daemon 免帳密版)共用的**唯一**實作 +// (薄殼原則 rule 07:能力只放一處)。原本整段邏輯躺在 portal-data.ts 的 handler 裡; +// daemon 是背景常駐行程、沒有 portal session(密碼只在連線精靈當下用過就丟,見 +// connect.go 註解),構不到 session 版端點,需要一支 X-Arcrun-API-Key 版本——抽出來讓 +// 兩條路由共用同一份查詢邏輯,不是各寫一份、日後各自漂移。 +// +// 涵蓋「這次一定要涵蓋」的向量/embedding 健康狀態:embed 模組是否開(index 存在的前提)、 +// 已嵌入/待嵌入卡片數、以及 embedSelfTest(KBDB #12)——這是唯一能分辨「從沒嵌過」與 +// 「嵌了但 index 查不到自己」兩種故障模式的方法(Arcrun#11 的真實案例正是後者,光看 +// 計數看不出來)。 +// +// 🔴 兩條紅線(規格原文,2026-08-07 leo 直接指令): +// ① 不准把內部概念暴露給用戶——本函式只回統計/狀態,呼叫端文案不提 KBDB/Vectorize/ +// owner_id 這類詞。 +// ② 不准洩漏知識卡內容本體——以下每一個欄位都只挑「數字」或「布林」,即使背後的 KBDB +// 端點回應含 content(如 triplet 的 subject/object 名稱),本函式一律只讀出用得到 +// 的數字後就丟掉那個回應,不把原始內容往呼叫端送。 +export interface DiagnosticsCore { + library_count: number; + triplet_count: number; + library_scope_check: Record; + embedding: Record; + notes: string[]; +} + +/** tenant=owner_id(session 版傳 portalTenant(env);daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */ +export async function buildDiagnostics(env: Bindings, tenant: string): Promise { + const notes: string[] = []; + + // ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。 + let embedding: Record = { checked: false }; + try { + const [statusRes, selftestRes] = await Promise.all([ + kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`), + kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`), + ]); + const statusBody = (await statusRes.json().catch(() => null)) as + | { 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 } + | null; + embedding = { + checked: true, + module_enabled: statusBody?.enabled ?? false, // Vectorize+AI binding 都在,才有「index」這回事 + cards_embedded: statusBody?.embedded ?? 0, + cards_pending: statusBody?.pending ?? 0, + self_test: { + ran: selftestBody?.tested ?? false, + // 三態:true=能搜到自己 / false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開 + found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null, + note: selftestBody?.note ?? '', + }, + }; + } catch (e) { + notes.push(`embed 健康狀態查詢失敗:${e instanceof Error ? e.message : String(e)}`); + } + + // ② 卡片與知識圖譜規模(只取數字,不取內容欄位)。 + // + // 改走與 /portal/admin/libraries 相同、驗證過在用的**即時查詢**組合(不依賴 library_map + // 快取——2026-08-08 曾實測快取恆回 0,根因與修正過程見 commit 7dbd4f5,此處不重貼一次 + // 避免兩處各改各的漂移): + // - listRecordsByTemplate(portal_library):已登記的庫(t159) + // - GET /entries/libraries:資料裡實際蓋章出現過的庫,登記與否都算(t52, + // 「蓋章即現身」);'general' 是未標庫的系統 fallback 桶,不算使用者眼中的一個庫, + // 與 admin/libraries 同慣例排除。 + // - GET /records/triplet-stats:per-library 即時聚合 SQL(t142,COUNT,非快取)。 + let library_count = 0; + let triplet_count = 0; + const ownerParam = new URLSearchParams({ owner_id: tenant }).toString(); + try { + const [registeredLibs, autoRes, tripletRes] = await Promise.all([ + listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []), + kbdbFetch(env, `/entries/libraries?${ownerParam}`), + kbdbFetch(env, `/records/triplet-stats?${ownerParam}`), + ]); + const knownLibs = new Set( + registeredLibs.map((r) => (r.values.name ?? '').trim()).filter((n): n is string => !!n), + ); + const autoBody = (await autoRes.json().catch(() => null)) as { success?: boolean; libraries?: string[] } | null; + for (const name of autoBody?.libraries ?? []) { + const n = String(name ?? '').trim(); + if (n && n !== 'general') knownLibs.add(n); + } + library_count = knownLibs.size; + + const tripletBody = (await tripletRes.json().catch(() => null)) as + | { success?: boolean; stats?: { library: string; triplet_count?: number }[] } + | null; + triplet_count = (tripletBody?.stats ?? []).reduce((sum, s) => sum + (Number(s.triplet_count) || 0), 0); + } catch (e) { + notes.push(`知識庫規模查詢失敗:${e instanceof Error ? e.message : String(e)}`); + } + + // ②.5 統計自我檢查(呼應上面 embedding.self_test 的精神——leo 直接指令:「不要讓『查不到』 + // 和『沒有』長得一樣」)。library_count/triplet_count 兩者都是 0 時,才另外花一次查詢, + // 用完全不同的路徑(不分庫、不分模板,只問「這個 owner_id 底下到底有沒有任何 entries」) + // 做交叉驗證——如果探測到有資料,代表問題出在查詢方式或 owner_id 對不上(2026-08-01 + // t161 前科:手動補的 record owner_id 存成 None,kbdb_query 全量查得到、按 owner_id 過濾 + // 的畫面永遠空,比真的沒資料更難查);如果探測也是空,才比較像真的是空庫。 + let library_scope_check: Record = { ran: false }; + if (library_count === 0 && triplet_count === 0) { + try { + const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`); + const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null; + const total = probeBody?.total ?? 0; + library_scope_check = { + ran: true, + any_entries_found: total > 0, + note: + total > 0 + ? `這個租戶底下查得到其他資料(entries 共 ${total} 筆),但庫/三元組統計仍回 0——像是查詢方式或租戶對不上,不像真的沒資料,需要人再查一次` + : '這個租戶底下完全查不到任何資料——比較像是真的還沒有資料,不是查詢方式錯了', + }; + } catch (e) { + library_scope_check = { + ran: true, + any_entries_found: null, + note: `自我探測查詢本身失敗:${e instanceof Error ? e.message : String(e)}`, + }; + } + } + + // ③ 最近一次萃取(daemon → /portal/daemon/extract)成功與否:目前沒有雲端側的失敗歷史 + // 記錄可讀(該端點是同步請求/回應,失敗只回給呼叫當下的 daemon,雲端不落地保存)—— + // 這個缺口不在這裡假裝補上一句話,本機那半(manifest 的 LastError 分類統計,t213 phase 2 + // arcrun-app 那半)才是真正能答這題的地方;本函式維持誠實:知道多少答多少,不摻水。 + return { library_count, triplet_count, library_scope_check, embedding, notes }; +} + +// GET /portal/daemon/diagnostics — 檢修孔的 daemon 版(t213 matrix/arcrun 半部,InkStoneCo +// 總管交辦,2026-08-08)。讓 arcrun-app(daemon 桌面殼)能免帳密拿到雲端這半的診斷數字, +// 跟本機那半(manifest 總量/失敗分類統計/daemon 版本與自我更新狀態,見 products/arcrun-rag +// repo t213 phase 2 的設計)合併成一份完整診斷檔——本端點純粹只負責雲端能看到的那半。 +// +// 認證比照既有 /portal/daemon/extract(X-Arcrun-API-Key,不是 session):daemon 是背景 +// 常駐行程,使用者的密碼只在連線精靈當下用過就丟、不落地(見 collector/cmd/arcrun-app/ +// connect.go 註解),背景查詢沒有密碼可用。 +// +// apiKey 當 tenant/owner_id 用,**不與 portalTenant(env) 比對**——t189 教訓:daemon 的 +// api_key 不保證等於這個 worker 的 CONSOLE_TENANT(多帳號情境下曾對不上,見上方 +// /portal/daemon/extract 的 t189 註解),「有帶 key 即可」是本 repo 對 X-Arcrun-API-Key +// 的既有慣例(見 webhooks-named.ts 的 owner_id: apiKey 用法)。 +// +// 邏輯與 /portal/data/diagnostics(portal-data.ts)共用同一個上面的 buildDiagnostics()—— +// 薄殼原則:能力只實作一次;兩條路由只是認證層不同,回應形狀完全一致,不改既有行為。 +portalRouter.get('/portal/daemon/diagnostics', (c) => + run(c, async () => { + const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim(); + if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); + const core = await buildDiagnostics(c.env, apiKey); + return c.json({ + generated_at: new Date().toISOString(), + instance_url: new URL(c.req.url).origin, + bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null, + ...core, + }); + }), +); diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index 560b787..28bedba 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -857,3 +857,88 @@ describe('GET /portal/data/diagnostics', () => { expect(body.library_scope_check.note).toContain('查詢方式或租戶對不上'); }); }); + +// ═══════════ 8. GET /portal/daemon/diagnostics(t213,daemon 免帳密版檢修孔,2026-08-08) ═══════════ +// +// 與上面 /portal/data/diagnostics 共用同一個 buildDiagnostics()(portal.ts)——這裡只驗證 +// ①認證換了一套(X-Arcrun-API-Key,非 session)②apiKey 當 owner_id 打 KBDB,不與 +// portalTenant(env)(='leo',見上方 TENANT 常數)比對/不要求相等(t189 教訓)③回應形狀 +// 與 session 版一致。核心查詢邏輯已在上面 7 組測試驗過,這裡不重複。 +describe('GET /portal/daemon/diagnostics(t213 daemon 版)', () => { + it('沒帶 X-Arcrun-API-Key → 401,不碰 KBDB', async () => { + const res = await get('/portal/daemon/diagnostics'); + expect(res.status).toBe(401); + }); + + it('帶 key(刻意與 CONSOLE_TENANT="leo" 不同)→ 200,且 KBDB 查詢用的 owner_id 是這把 key 本身,不是 leo(t189:不假設 apiKey===portalTenant)', async () => { + const daemonKey = 'yuga3bse'; // 刻意選一個跟 TENANT('leo') 不同的值,比照 t189 geek6688 案例 + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status') && p.includes(`owner_id=${daemonKey}`), method: 'GET' }) + .reply(200, { success: true, enabled: true, pending: 2, embedded: 9 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/embed/selftest') && p.includes(`owner_id=${daemonKey}`), method: 'GET' }) + .reply(200, { success: true, enabled: true, tested: true, passed: true, note: '' }); + mockLibraryList([{ record_id: 'rec_lib_kb2', values: { name: 'kb' } }]); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries') && p.includes(`owner_id=${daemonKey}`), method: 'GET' }) + .reply(200, { success: true, libraries: ['general', 'kb'], count: 2 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats') && p.includes(`owner_id=${daemonKey}`), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 9 }] }); + + const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + library_count: number; + triplet_count: number; + embedding: { module_enabled: boolean; cards_embedded: number }; + instance_url: string; + bundle_version: string | null; + }; + expect(body.library_count).toBe(1); + expect(body.triplet_count).toBe(9); + expect(body.embedding.module_enabled).toBe(true); + expect(body.embedding.cards_embedded).toBe(9); + expect(body.instance_url).toBe('http://localhost'); + // 沒有任何 session 檢查——不打 SESSIONS_KV/portal_user record(本測試從未 seedSession/mockGetRecord + // 仍然 200,證明這條路徑真的不吃 session)。 + }); + + it('回應形狀與 session 版一致(同一組欄位名)', async () => { + const daemonKey = 'shape-check-key'; + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' }) + .reply(200, { success: true, enabled: false, pending: 0, embedded: 0 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' }) + .reply(200, { success: true, enabled: false, tested: false, passed: null, note: '' }); + mockLibraryList([]); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { success: true, libraries: [], count: 0 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' }) + .reply(200, { success: true, entries: [], count: 0, total: 0 }); + + const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(Object.keys(body).sort()).toEqual( + ['generated_at', 'instance_url', 'bundle_version', 'library_count', 'triplet_count', 'library_scope_check', 'embedding', 'notes'].sort(), + ); + // t213 leo 08-08 指令:舊的「需在失敗當下截圖」那句已刪,notes 不該再含這句話。 + expect(JSON.stringify(body.notes)).not.toContain('截圖'); + }); +});