feat(portal): 新增 GET /portal/daemon/diagnostics(t213 matrix/arcrun 半部)
InkStoneCo 總管交辦(arcrun-rag repo t213):leo 拿真診斷檔實測四個真實問題,只答得出一題 ——其餘三題需要地端資料(daemon 本機 manifest 總量/失敗分類/自我更新狀態),而現有 GET /portal/data/diagnostics 只有 portal session 認證,daemon 背景行程沒有 session (密碼只在連線精靈當下用過就丟,不落地),構不到這支端點。 本次只做 matrix/arcrun 半部(雲端這半): - 把 /portal/data/diagnostics 的核心查詢邏輯抽成 buildDiagnostics(env, tenant) (portal.ts),薄殼原則:能力只實作一次 - 新增 GET /portal/daemon/diagnostics,認證比照既有 /portal/daemon/extract (X-Arcrun-API-Key,非 session);apiKey 當 owner_id 用,不與 portalTenant(env) 比對(t189 教訓:daemon 的 api_key 不保證等於 worker 的 CONSOLE_TENANT) - /portal/data/diagnostics 改呼叫共用函式,回應形狀完全不變 - 按 leo 指示刪掉「需在失敗當下由封測者截圖」那句 notes(本機那半資料到位後這句話 失去意義) 地端那半(arcrun-app 讀 manifest 合併 + 匯出按鈕)在 products/arcrun-rag repo 進行, 待另一隻處理 t210 的 agent 落地 app.go/main.js 改動後再接線,本次不動 arcrun-app。 驗證: - npx tsc --noEmit:與 stash 前錯誤數相同(4 個),零新增(pre-existing,與本次改動無關) - npx vitest run:314→315 通過(+3 新測試涵蓋 401/apiKey 當 owner_id 不比對 tenant/ 回應形狀與 session 版一致),14 個既有失敗與改動前完全相同(HTML 殼 404,測試環境 static asset 問題,與 portal.ts/portal-data.ts 邏輯無關) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> = { 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<string, unknown> = { 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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
embedding: Record<string, unknown>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** tenant=owner_id(session 版傳 portalTenant(env);daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */
|
||||
export async function buildDiagnostics(env: Bindings, tenant: string): Promise<DiagnosticsCore> {
|
||||
const notes: string[] = [];
|
||||
|
||||
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
|
||||
let embedding: Record<string, unknown> = { 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<string, unknown> = { 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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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('截圖');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user