Files
Arcrun/cypher-executor/src/routes/portal-data.ts
T
Claude (總管雲端) a82c9bbb82 portal: 搜尋濾內部型別(value/workflow 雜項列)+總圖頁(Arcrun#39 藏書地圖 GUI 切片)
- filterDeprecatedEntries 加 INTERNAL_ENTRY_TYPES(value/workflow):
  搜尋不再出現無標題雜項列與工作流定義(leo 客戶測試回饋);
  #46 上游修好後隨治標段一併拔除。測試 49/49 綠。
- 新增 GET /portal/data/graph/overview:全租戶 active 三元組 →
  {nodes(degree), edges},D-4 graph 粗閘、kbdbFetch 直讀、上限 500 誠實截斷。
- portal 新增「總圖」頁:nav 隨 graph_allowed 顯示;力導向佈局手刻
  (固定種子=確定性、零外部套件)、紙感樣式(green 節點/amber hub)、
  邊 hover 顯示謂詞、點節點跳該實體圖譜搜尋;頁尾連 00-MAP.md
  (#39「人機共用同一份地圖」的 AI 注入文字版)。
- #39 本體(library_map Template/ingest 重算/MCP 注入)仍歸該 issue SDD。
2026-07-18 08:17:22 +00:00

458 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RAG Portal 查詢面 — P3/portal/data/* server-side enforceportal-auth design §3.3/§3.4/§5
* Gitea #24/#25)。本檔是本 SDD 的**安全核心**。
*
* 安全模型(design §3.3,與 console 的關鍵差異):
* - console 登入後把 CONSOLE_TENANT 下發給前端直打 /kbdb/***portal 前端絕不持有租戶字串**,
* 只有 portal session token。portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/search
* 所以 enforce 全在 serversession → 回讀 user record(唯一真相源)→ 取 libraries →
* server 注入 owner_idlibrary 後轉發 KBDB。
* - caller 自帶的 owner_id / library query 參數**一律忽略**(不是拒絕——拒絕會變成
* 「試參數名」的 oracle;直接靜默覆蓋,怎麼傳都是自己的權限範圍)。
* - ["*"]=全庫:只注 owner_id、不注 librarydesign §3.3)。
*
* 不洩存在性(紅線):越庫的 entry(含根本不存在的 id、別的租戶的 id)一律回**同一句 404**,
* 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外=graph 粗閘按 SDD 明定回 403D-4)。
*
* 薄殼(rule 07):本檔沒有新能力——搜尋/取條目能力真身在 KBDB baseP1 的 library filter),
* graph 真身在 kbdb-graph-plugin,工作流真身在 WEBHOOKS/ANALYTICS KV(與 /webhooks/named、
* /workflows/:name/executions 同一資料源)。這裡只做「權限注入+轉發/讀取」。
*
* log 紅線:本檔不 log 任何 token / 密碼 / 查詢內容。
*/
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
import { graphBase } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
// ── tenant workflow in-process 執行(portal-demo-suite)───────────────────────
//
// 為什麼:RAG self-hosted 實例不部署 kbdb-graph-plugin workergraphBase 直連會 1042
// Arcrun#57);graph/chat 這類查詢能力在該實例是以 tenant 的 named workflow 形式存在
//WEBHOOKS KV `{tenant}:wf:{name}`,與 /webhooks/named 同一資料源)。
// 怎麼接:直接 import executeWebhookGraphwebhooks-named.ts trigger/query 端點同一個
// 執行入口)在 **本 worker 進程內** 執行——絕不 fetch 自己的 hostname
//global_fetch_strictly_public 下打自己=self-loop,見 /portal/data/workflows 同款註解)。
/** 讀 tenant 的 named workflow graph`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text');
if (!raw) return null;
try {
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
return rec.graph && typeof rec.graph === 'object' ? rec.graph : null;
} catch {
return null; // 壞 record 視同不存在(呼叫端各自誠實回報)
}
}
/** workflow 最終節點輸出常見兩形:本體即結果,或再包一層 data(http_request 類零件慣例)。取有目標欄位的那層。 */
function unwrapWorkflowData(data: unknown, key: string): Record<string, unknown> {
const outer = data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
if (key in outer) return outer;
const inner = outer.data;
if (inner && typeof inner === 'object' && key in (inner as Record<string, unknown>)) {
return inner as Record<string, unknown>;
}
return outer;
}
/**
* graph_neighbors workflow 輸出 → 與 kbdb-graph-plugin 相同的回應形狀 {neighbors, edges, count}
*(前端 doGraphSearch 讀 neighbors/edgescountneighbors 數,重算不信 workflow 自報)。
* 純函式(單測用 export)。
*/
export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; edges: unknown[]; count: number } {
const layer = unwrapWorkflowData(data, 'neighbors');
const neighbors = Array.isArray(layer.neighbors) ? layer.neighbors : [];
const edges = Array.isArray(layer.edges) ? layer.edges : [];
return { neighbors, edges, count: neighbors.length };
}
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
function notFound(c: Context<{ Bindings: Bindings }>): Response {
return c.json({ error: '找不到這筆資料' }, 404);
}
/** entry 的庫歸屬:metadata_json.$.library,未標記 → 'general'design §3.2 fallback,與 KBDB P1 同語意)。 */
export function entryLibrary(entry: { metadata_json?: string | null }): string {
try {
const meta = JSON.parse(entry.metadata_json ?? 'null') as { library?: unknown } | null;
if (meta && typeof meta.library === 'string' && meta.library.trim()) return meta.library;
} catch {
/* metadata 壞掉 → 視同未標記 */
}
return 'general';
}
/** 用戶庫集合是否覆蓋某庫(["*"]=全庫)。 */
function canReadLibrary(userLibraries: string[], library: string): boolean {
return userLibraries.includes('*') || userLibraries.includes(library);
}
/**
* 搜尋殘影過濾(**Arcrun#46 上游修好前的 portal 端治標**——舊管線的 deprecated 產物還躺在
* KBDB 裡污染搜尋結果;根治=上游清資料/重建索引,那修好後這段可整段拔掉)。
* 濾掉:metadata_json.status === 'deprecated' 的 entry、content 以「(舊管線產物」開頭的 entry、
* 內部型別 entryvalueslot 值外漏的無標題雜項列、workflow=工作流定義——都是系統內部件,
* 不是給搜尋用戶看的內容;2026-07-18 leo 客戶測試回饋「搜尋結果偶見無標題雜項列」)。
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
* 純函式(單測用 export)。
*/
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
entries: T[],
): T[] {
return entries.filter((e) => {
if (e.entry_type && INTERNAL_ENTRY_TYPES.has(e.entry_type)) return false;
try {
const meta = JSON.parse(e.metadata_json ?? 'null') as { status?: unknown } | null;
if (meta && meta.status === 'deprecated') return false;
} catch {
/* parse 失敗 → 保留 */
}
if (String(e.content ?? '').startsWith('(舊管線產物')) return false;
return true;
});
}
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
//graph 走 /portal/data/graph/*)。server 注入 owner_idlibrary;回應照 KBDB 原形
//entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
// semantic 未開的降級行為沿 KBDB 既有,P1 已保 library 照 enforce)。
portalDataRouter.get('/portal/data/search', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const q = c.req.query('q');
if (!q) return c.json({ error: 'q 必填' }, 400);
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
// 帳號沒被授權任何庫:誠實空結果(不打 KBDB——沒有可查範圍就沒有查詢)
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
if (!libraries.includes('*')) params.set('library', libraries.join(','));
// 透傳的只有「在權限範圍內再收窄」的 filterowner_id/library 上面已由 server 定死,
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
if (c.req.query('mode') === 'semantic') params.set('mode', 'semantic');
const entryType = c.req.query('entry_type');
if (entryType) params.set('entry_type', entryType);
const limit = c.req.query('limit');
if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit);
const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`);
if (!res.ok) {
// 錯誤回應照原樣透傳(誠實,不加工)
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
// Arcrun#46 治標:server-side 濾掉舊管線 deprecated 殘影再回(count 重算)。
// 上游修好(清資料/重建索引)後,這段連同 filterDeprecatedEntries 一起拔掉。
const body = (await res.json().catch(() => null)) as
| { entries?: { metadata_json?: string | null; content?: string | null }[]; count?: number }
| null;
if (!body || !Array.isArray(body.entries)) {
// 回應不是預期形狀 → 照原樣回(不因治標把正常錯誤形狀吃掉)
return c.json(body ?? { error: 'KBDB 回應不是 JSON' }, body ? 200 : 502);
}
const entries = filterDeprecatedEntries(body.entries);
return c.json({ ...body, entries, count: entries.length });
}),
);
// GET /portal/data/entries/:id — 卡片詳頁。**逐筆驗庫**design §5):
// ① entry 必須屬於本實例租戶(owner_id=CONSOLE_TENANT)——防拿別租戶 id 直讀;
// ② entry 的 libraryNULL→general)必須在用戶庫集合內——防拿越庫 id 直讀。
// 兩者不符與不存在同回 404(不洩存在性)。
portalDataRouter.get('/portal/data/entries/:id', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/entries/${encodeURIComponent(c.req.param('id'))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
const entry = body.entry;
if (!entry) return notFound(c);
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
return c.json({ success: true, entry });
}),
);
// GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘):
// 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。
// 放行後的資料源二選一(Arcrun#57):
// ① tenant 有 `{tenant}:wf:graph_neighbors` workflow → in-process 執行它(RAG self-hosted
// 實例不部署 kbdb-graph-plugin,直連會 1042),輸出映射成與 plugin 相同形狀;
// ② 沒有 → fallback 原本 graphBase 直連 pluginMira/leo21c 實例相容,行為一字不變)。
portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (!(await hasGraphAccess(c.env, libraries))) {
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = portalTenant(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
if (wfGraph) {
const depthRaw = c.req.query('depth') ?? '';
const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2;
const result = await executeWebhookGraph(
c.env,
wfGraph,
{ node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
'graph_neighbors',
tenant,
c.executionCtx,
);
if (!result.success) {
// workflow 執行失敗 → 誠實 502(不假裝無關聯)
return c.json({ error: `graph_neighbors workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
}
return c.json(mapGraphWorkflowOutput(result.data));
}
// ② plugin fallbackMira/leo21c 相容)
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
} catch (e) {
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
}
}),
);
// GET /portal/data/graph/overview — 書庫總圖(Arcrun#39 藏書地圖的 GUI 資料切片):
// 全租戶 active 三元組 → {nodes:[{name,degree}], edges:[{subject,predicate,object}]}。
// 權限=與 neighbors 同一道 D-4 graph 粗閘;資料直讀 KBDB records(與 search 同 kbdbFetch 路徑,
// 不經 graph plugin、不撞 1042)。邊數上限 500(demo 量級遠低於此;超限誠實截斷並回 truncated)。
portalDataRouter.get('/portal/data/graph/overview', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (!(await hasGraphAccess(c.env, libraries))) {
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
const tenant = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown> }[] }
| null;
const records = body && Array.isArray(body.records) ? body.records : [];
const EDGE_CAP = 500;
const seen = new Set<string>();
const edges: { subject: string; predicate: string; object: string }[] = [];
const degree = new Map<string, number>();
let truncated = false;
for (const r of records) {
const v = r && typeof r.values === 'object' && r.values ? r.values : null;
if (!v) continue;
if (v.status === 'deprecated') continue;
const s = typeof v.subject === 'string' ? v.subject.trim() : '';
const o = typeof v.object === 'string' ? v.object.trim() : '';
if (!s || !o) continue;
const p = typeof v.predicate === 'string' ? v.predicate : '';
const key = `${s}${p}${o}`;
if (seen.has(key)) continue;
seen.add(key);
if (edges.length >= EDGE_CAP) { truncated = true; break; }
edges.push({ subject: s, predicate: p, object: o });
degree.set(s, (degree.get(s) ?? 0) + 1);
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
}),
);
// GET /portal/data/chat?question=... — AI 問答(portal-demo-suite)。
// 設計哲學:AI 檢索=用戶手動搜尋同一套——同 search 的 requirePortalUser 閘、同一個租戶資料面,
// 只是把「人下關鍵字」換成「workflow 代查再作答」;前端不因走 AI 多拿任何權限。
// 機制同 graph_neighborsin-process 執行 tenant 的 rag_chat workflowexecuteWebhookGraph
// 絕不 fetch 自己 hostname);workflow 不存在 → 誠實 404,不假裝這實例有問答能力。
portalDataRouter.get('/portal/data/chat', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const question = c.req.query('question');
if (!question) return c.json({ error: 'question 必填' }, 400);
const wfGraph = await getTenantWorkflowGraph(c.env, 'rag_chat');
if (!wfGraph) return c.json({ error: '此實例未安裝問答 workflow' }, 404);
const result = await executeWebhookGraph(
c.env,
wfGraph,
{ question },
'rag_chat',
portalTenant(c.env),
c.executionCtx,
);
if (!result.success) {
// workflow 執行失敗 → 誠實 502(不把錯誤編成答案)
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
}
// 回 workflow 回應內層 data{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
const inner = unwrapWorkflowData(result.data, 'answer');
return c.json({
answer: typeof inner.answer === 'string' ? inner.answer : '',
sources: Array.isArray(inner.sources) ? inner.sources : [],
graph_facts: inner.graph_facts ?? null,
});
}),
);
// ── 上傳(portal-demo-suite)────────────────────────────────────────────────
/**
* 上傳檔名驗證(純函式,單測用 export):
* - 去路徑分隔(/ \)只取最後一段(擋 ../ 穿越)、去控制字元;
* - 空名/以 . 開頭(隱藏檔/./..)→ 無效(null);
* - 強制 .md 結尾(.txt 改副檔名、其餘直接補 .md——上傳面收的是知識文件,一律當 markdown 收件);
* - 最終長度限 100(含副檔名),超過 → 無效。
*/
export function sanitizeUploadFilename(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
let name = (raw.split(/[/\\]/).pop() ?? '').trim();
// eslint-disable-next-line no-control-regex
name = name.replace(/[\u0000-\u001f\u007f]/g, '');
if (!name || name.startsWith('.')) return null;
if (/\.txt$/i.test(name)) name = name.replace(/\.txt$/i, '.md');
if (!/\.md$/i.test(name)) name = `${name}.md`;
if (name.length > 100) return null;
return name;
}
// base64 內容上限(收 .md/.txt 知識文件,2 MiB 原文 ≈ 2.7 MB base64 已綽綽有餘;防拿上傳面塞大檔)
const MAX_UPLOAD_B64_CHARS = 3 * 1024 * 1024;
// POST /portal/data/upload — body {filename, content_b64}portal-demo-suite)。
// requirePortalUser 閘;server-side POST Gitea contents API 寫進 PORTAL_UPLOAD_REPO 的
// docs/{filename}——token 只在 server 側,前端永遠拿不到(同 kbdb-proxy token 慣例)。
// 未設 upload bindings(三者任一缺)→ 404「未啟用上傳」(Mira 零影響:功能不存在)。
portalDataRouter.post('/portal/data/upload', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
if (!uploadEnabled(c.env)) return c.json({ error: '此實例未啟用上傳' }, 404);
const body = await c.req.json().catch(() => null) as { filename?: unknown; content_b64?: unknown } | null;
const filename = sanitizeUploadFilename(body?.filename);
if (!filename) return c.json({ error: 'filename 無效(不可含路徑、不可空、長度限 100)' }, 400);
const contentB64 = body?.content_b64;
if (typeof contentB64 !== 'string' || !contentB64) return c.json({ error: 'content_b64 必填' }, 400);
if (contentB64.length > MAX_UPLOAD_B64_CHARS) return c.json({ error: '檔案過大(上限約 2 MB' }, 413);
const base = (c.env.PORTAL_UPLOAD_GITEA ?? '').replace(/\/$/, '');
const repo = c.env.PORTAL_UPLOAD_REPO ?? '';
let res: Response;
try {
res = await fetch(`${base}/api/v1/repos/${repo}/contents/docs/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `token ${c.env.PORTAL_UPLOAD_TOKEN}`,
},
body: JSON.stringify({
content: contentB64,
// commit 訊息帶上傳者(display_name 非機密),收件溯源用;不進任何內容
message: `portal 上傳:docs/${filename}${auth.user.values.display_name ?? 'portal user'}`,
}),
});
} catch (e) {
return c.json({ error: `知識庫收件服務不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
}
if (res.status === 409 || res.status === 422) {
// Gitea 同 path 已存在(版本差異回 409 或 422)→ 統一誠實回 409
return c.json({ error: '同名文件已存在,請改檔名後重傳' }, 409);
}
if (!res.ok) {
return c.json({ error: `知識庫收件失敗(HTTP ${res.status}` }, 502);
}
return c.json({ success: true, filename, path: `docs/${filename}` });
}),
);
// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger**
//trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。
// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403/ all / off(整頁不存在 → 404)。
portalDataRouter.get('/portal/data/workflows', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const setting = (c.env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
if (setting === 'off') return notFound(c);
if (!workflowsVisible(c.env, auth.user.values.role ?? 'user')) {
return c.json({ error: '需要 admin 權限' }, 403);
}
// 資料源與 /webhooks/named /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
const tenant = portalTenant(c.env);
const prefix = `${tenant}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = await Promise.all(
list.keys.map(async (k) => {
const name = k.name.slice(prefix.length);
const raw = await c.env.WEBHOOKS.get(k.name, 'text');
let description = '';
let created_at = '';
let cron_expr: string | undefined;
if (raw) {
try {
const rec = JSON.parse(raw) as { description?: string; created_at?: string; cron_expr?: string };
description = rec.description ?? '';
created_at = rec.created_at ?? '';
cron_expr = rec.cron_expr;
} catch {
/* 壞 record 誠實留空 */
}
}
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
let last_execution: { timestamp: string; verdict?: string } | null = null;
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
if (stats.keys.length > 0) {
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
const ts = latest.name.split(':').pop() ?? '';
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
let verdict: string | undefined;
if (rawStat) {
try {
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
} catch {
/* 壞 record 誠實留空 */
}
}
last_execution = { timestamp: ts, verdict };
}
return { name, description, created_at, cron_expr, last_execution };
}),
);
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
}),
);