/** * arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工; * 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦) * * 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數): * - GET /console/dashboard-data:聚合 JSON。 * - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。 * - GET /console/kb-scale-data:精耕層規模(wiki 卡/三元組/已嵌入;2026-07-07 leo 裁 * 「遺產庫不用顯示」後 console 頭部統計改讀這裡)。 * - GET /console/settings-data:設定頁誠實系統值(MCP token TTL 佔位)。 * * ── 2026-07-07 二修(fix/console-truth-audit):「今日完成/今日路線」接 sprint 任務板 ── * leo 拍板(「今天做了這麼多事……其實就是我們到底完成了多少事」):dash_task 同 dash_wait * 病(沒活管線),真相源=sprint 檔「## 任務板」勾選。比照「等你的事」#36 模式:同一輪 * Gitea fetch(90s 快取共用)解析任務板,「今日完成」只認「完成(今天台北日)」標記, * dash_task 降 fallback;板檔今天沒 commit → 頁面誠實標「今日任務板未更新(最後 N 小時前)」。 * * ── 2026-07-07 整修:每個區塊都讀「live 一手資料」,讀不到就誠實標示,不擺 stale 殘骸 ── * * 資料源診斷(leo 抱怨「等你的事錯了好幾天」的根因): * - dash_wait(等你的事舊資料源)最後寫入 2026-07-04,**沒有活的維護管線**——等leo清單#11 * 已於 07-05 銷案(誤判),dashboard 卻繼續掛著它。真相源其實是 InkStoneCo sprint 檔的 * 「## 等 leo 清單」表格(progress-guard routine 每日核實維護)。 * - dash_task 的 scope:"today" 沒有日期——07-04 的「今日路線」到 07-07 還被當今天的。 * - dash_beat 是唯一有活管線的 dash_*(progress-guard/cloud-worker/watchdog 每日寫入)。 * * 整修後的資料源: * 等你的事 → 首選 Gitea sprint 檔等leo清單(需 GITEA_BASE_URL var + GITEA_TOKEN secret; * 進程內 fetch Gitea API,非 GitHub、無 D20 疑慮);讀不到 → fallback dash_wait * 但必標 age + stale 警示;連 dash_wait 都沒有 → 誠實顯示「管線未接」。 * 今日路線 → dash_task,但以台北日曆日判 is_today;非今日寫入=降級顯示「最後路線(N 天前)」, * 不假裝是今天的。今日無寫入時明講管線缺口(sprint 任務板→dashboard 無自動投影)。 * 系統狀況 → live 健康信號:KBDB /health、/embed/backfill/status(enabled:false 誠實顯示)、 * kbdb-graph-plugin /triplets/stats、workflow 總數(KBDB entry_type=workflow)。 * 總庫規模 → KBDB entries 總數/wiki_card 數/triplets 數,全部 live API 一手拉。 * * 燈號判定(寫死在端點,頁面只渲染): * red = 「今日寫入」的任務有 blocked,或最新心跳距今 > 240 分(台北 09:00-22:00 窗內判定), * 或 KBDB /health 打不通。stale 殘任務**不再**觸發燈號(07-04 的 blocked 不該讓 07-07 亮紅)。 * yellow = 無 red 條件,但今日任務有非標準 status(late/behind 等落後標記)。 * green = 其餘。 * * 薄殼定位:聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯;判定純函式抽在 * lib/console-dashboard-model.ts(可單測)。讀 KBDB 走 HTTP(kbdbBase 慣例),不新增 binding。 */ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { kbdbBase, graphBase } from './kbdb-proxy'; import { validateConsoleSession } from './console-auth'; import { type KbdbEntry, type WaitingItem, type WaitingModel, type CachedWaitingEnvelope, type SprintBoardTask, type SprintSnapshot, GITEA_WAITING_CACHE_TTL_SECONDS, parseCreatedAtMs, parseJsonContent, agoMinutes, buildRouteModel, buildSprintRouteModel, buildWaitingFallback, parseSprintTaskBoard, parseSprintWaitingTable, pickLatestSprintFiles, reviveWaitingAges, sortWaitingItems, taipeiDayKey, } from '../lib/console-dashboard-model'; import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model'; import { TAIPEI_CLIENT_JS } from '../lib/taipei-time'; export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>(); const STALE_MINUTES = 240; const JUDGE_START_HOUR = 9; // 台北時間,含 const JUDGE_END_HOUR = 22; // 台北時間,不含 const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']); async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise { const { base, headers } = kbdbBase(env); const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) }); try { const res = await fetch(`${base}/entries?${params.toString()}`, { headers }); if (!res.ok) return []; const data = (await res.json()) as { entries?: KbdbEntry[] }; return data.entries ?? []; } catch { return []; } } /** 泛用 GET JSON(失敗回 null,caller 誠實顯示「讀不到」,不編數字)。 */ async function fetchJson(url: string, headers?: Record): Promise { try { const res = await fetch(url, headers ? { headers } : undefined); if (!res.ok) return null; return (await res.json()) as T; } catch { return null; } } /** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */ async function fetchEntryTotal(env: Bindings, filters: Record): Promise { const { base, headers } = kbdbBase(env); const params = new URLSearchParams({ ...filters, limit: '1' }); const data = await fetchJson<{ total?: unknown }>(`${base}/entries?${params.toString()}`, headers); return data && typeof data.total === 'number' ? data.total : null; } /** * sprint 檔活資料源(同一輪 fetch 兩個產物,leo 2026-07-07 拍板加「今日完成」): * - 「等你的事」=「## 等 leo 清單」表格(progress-guard 每日維護)。 * - 「今日完成/今日路線」=「## 任務板」checkbox(今天勾的才算今日完成)。 * 需 GITEA_BASE_URL(var)+ GITEA_TOKEN(secret,建議唯讀 scope)。請求序: * 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項/未收項,例:07b 開了、 * 🔴 mira 憑證外洩與 [🔄] T-cockpit 仍掛 07a)→ 各抓 raw、兩個 parser 吃同一份文字 → * 最新檔的最後 commit 時間當「維護於」。等leo清單全解析失敗回 null → caller fallback * dash_wait / dash_task(標 age),不硬湊。 */ async function fetchGiteaSprint(env: Bindings, nowMs: number): Promise { const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, ''); const token = env.GITEA_TOKEN; if (!base || !token) return null; const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo'; const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch'; const headers = { Authorization: `token ${token}` }; try { const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers); if (!files) return null; const sprints = pickLatestSprintFiles(files.map((f) => f.name)); if (!sprints.length) return null; const parsed = await Promise.all( sprints.map(async (name) => { const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers }); if (!rawRes.ok) return null; const text = await rawRes.text(); return { waiting: parseSprintWaitingTable(text, name), board: parseSprintTaskBoard(text, name) }; }), ); const readFiles = sprints.filter((_, i) => parsed[i]?.waiting != null); const merged = parsed.map((p) => p?.waiting).filter((p): p is WaitingItem[] => p != null).flat(); if (!readFiles.length) return null; // 等leo清單全部解析失敗=誠實 fallback // 任務板:新→舊合併(現役 sprint 的板先列);兩檔都沒有可解析的板 → null(fallback dash_task) const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat(); // 清單上次維護時間 = 現役 sprint 檔最後 commit(progress-guard 每日 commit,>48h 沒動才算 stale) let ago = -1; const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>( `${base}/api/v1/repos/${repo}/commits?path=${encodeURIComponent(`${dir}/${readFiles[0]}`)}&limit=1&stat=false&verification=false&files=false`, headers, ); const date = commits?.[0]?.commit?.committer?.date; if (date) { const ms = Date.parse(date); if (!Number.isNaN(ms)) ago = agoMinutes(nowMs, ms); } return { waiting: { items: sortWaitingItems(merged), source: 'gitea_sprint', updated_ago_minutes: ago, stale: ago >= 0 && ago > 48 * 60, sprint_files: readFiles, }, board: boardMerged.length ? boardMerged : null, }; } catch { return null; } } export type GiteaSprintFetcher = (env: Bindings, nowMs: number) => Promise; /** * fetchGiteaSprint 的快取層(總管 #36 審查要求):CF Cache API(caches.default)、 * TTL 90s(GITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從 * 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。 * * - key:合成 URL(Cache API 要求合法 URL;host 用不會真的被打的保留名),帶 * base/repo/dir 參數——設定變了自然 miss,不會吐到別的 Gitea 的殘資料。 * - hit 回放時用 reviveWaitingAges 把「維護於 N 分鐘前」隨牆鐘補算(存的是 fetch * 當下的 ago,直接回放會讓時間停走)。任務板存原始 completed_days,「今天完成幾件」 * 由請求當下算——跨台北午夜的快取不會把昨天的完成冒領成今天。 * - **失敗不快取**:negative cache 會把一時網路抖動放大成 90 秒盲區,caller 該 * 當場 fallback dash_wait / dash_task。 * - cache.put 走 waitUntil(不阻塞回應);fetcher 參數可注入=單測不用真打網路。 * - 回傳多帶 cache:'hit'|'miss',吐進 waiting_meta 當快取生效的客觀證據(curl 兩次 * 第二次該是 hit)。 */ export async function cachedGiteaSprint( env: Bindings, nowMs: number, waitUntil: (p: Promise) => void, fetcher: GiteaSprintFetcher = fetchGiteaSprint, ): Promise<(SprintSnapshot & { cache: 'hit' | 'miss' }) | null> { if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null; const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo'; const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch'; const cacheKey = new Request( `https://console-dashboard.arcrun.internal/gitea-waiting?${new URLSearchParams({ base: env.GITEA_BASE_URL, repo, dir }).toString()}`, ); const cache = caches.default; try { const hit = await cache.match(cacheKey); if (hit) { const envelope = (await hit.json()) as CachedWaitingEnvelope; return { waiting: reviveWaitingAges(envelope.snapshot.waiting, envelope.fetched_at_ms, nowMs), board: envelope.snapshot.board, cache: 'hit', }; } } catch { /* cache 故障不致命,走 miss 路徑 */ } const fresh = await fetcher(env, nowMs); if (!fresh) return null; // 失敗不快取,caller 誠實 fallback const envelope: CachedWaitingEnvelope = { snapshot: fresh, fetched_at_ms: nowMs }; try { waitUntil( cache.put( cacheKey, new Response(JSON.stringify(envelope), { headers: { 'Content-Type': 'application/json', 'Cache-Control': `public, max-age=${GITEA_WAITING_CACHE_TTL_SECONDS}`, }, }), ), ); } catch { /* put 失敗只是少了快取,不影響本次回應 */ } return { ...fresh, cache: 'miss' }; } // GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值) consoleDashboardRouter.get('/console/dashboard-data', async (c) => { const tenant = c.env.CONSOLE_TENANT || 'leo'; const now = Date.now(); const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env); const graphUrl = graphBase(c.env); const [ beatEntries, taskEntries, waitEntries, inboxEntries, giteaSprint, kbdbHealth, embedStatus, graphStats, entriesTotal, wikiCardTotal, workflowTotal, ] = await Promise.all([ fetchEntries(c.env, tenant, 'dash_beat', 100), fetchEntries(c.env, tenant, 'dash_task', 200), fetchEntries(c.env, tenant, 'dash_wait', 100), fetchEntries(c.env, tenant, 'inbox', 200), cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)), fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders), fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders), fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`), // owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732) fetchEntryTotal(c.env, { owner_id: tenant }), fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }), fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }), ]); // dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)。唯一有活管線的 dash_*。 const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = []; const seenActors = new Set(); for (const e of beatEntries) { const j = parseJsonContent(e); const actor = typeof j?.actor === 'string' ? j.actor : null; if (!actor || seenActors.has(actor)) continue; seenActors.add(actor); const ms = parseCreatedAtMs(e.created_at); beats.push({ actor, event: typeof j?.event === 'string' ? (j.event as string) : '', note: typeof j?.note === 'string' ? (j.note as string) : '', at: e.created_at, ago_minutes: agoMinutes(now, ms), }); } const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null; // 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale) let waiting: WaitingModel; let waitingCache: 'hit' | 'miss' | null = null; if (giteaSprint) { waiting = giteaSprint.waiting; waitingCache = giteaSprint.cache; } else { waiting = buildWaitingFallback(waitEntries, now); if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) { waiting.note = 'Gitea sprint 清單未接(缺 GITEA_TOKEN secret)——以下是 dash_wait 殘資料'; } else if (waiting.source === 'kbdb_dash_wait') { waiting.note = 'Gitea sprint 清單讀取失敗——以下是 dash_wait 殘資料'; } } // 今日完成/今日路線:sprint 任務板優先(leo 2026-07-07 拍板——「到底完成了多少事」的 // 真相源=progress-guard/cloud-worker 每日勾選的板,dash_task 沒活管線降 fallback)。 // 板的「今日完成」只認「完成(今天台北日)」標記;板檔今天沒 commit 過 → 誠實標示。 const sprintRoute = giteaSprint?.board ? buildSprintRouteModel(giteaSprint.board, now) : null; const route = buildRouteModel(taskEntries, now); // fallback + 燈號仍吃 dash_task 今日寫入 const boardAgo = giteaSprint ? giteaSprint.waiting.updated_ago_minutes : -1; const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey(now - boardAgo * 60000) === taipeiDayKey(now); // inbox:未處理計數(status !== 'done';沒標 status 視為未處理) const inboxNew = inboxEntries.reduce((n, e) => { const j = parseJsonContent(e); return j && j.status !== 'done' ? n + 1 : n; }, 0); // 燈號:只吃「今日寫入」的任務 + 心跳 + KBDB 健康(stale 殘任務不再觸發燈號) const todayWrites = route.tasks.filter((t) => t.is_today_write); const hasBlocked = todayWrites.some((t) => t.status === 'blocked'); const hasLagMark = todayWrites.some((t) => !STANDARD_TASK_STATUS.has(t.status)); const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours(); const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR; const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES; const kbdbOk = kbdbHealth?.ok === true; const light: 'green' | 'yellow' | 'red' = hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green'; const lightReason = !kbdbOk ? 'KBDB 基本盤 /health 打不通' : hasBlocked ? '今日任務有 blocked' : inJudgeWindow && beatStale ? `心跳超過 ${STALE_MINUTES} 分鐘` : hasLagMark ? '今日任務有落後標記' : ''; return c.json({ light, light_reason: lightReason, last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null, beats, // 路線:sprint 任務板優先(tasks 欄位形狀與 dash_task 版相容——title/status/scope); // 板上開著的項 is_today_write=false(燈號沿 #36 原則只吃 dash_task 今日寫入+心跳+KBDB, // 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」) tasks: sprintRoute ? sprintRoute.tasks.map((t, i) => ({ title: t.title, status: t.status, order: i, scope: 'today' as const, age_minutes: boardAgo, is_today_write: t.status === 'done', // done 項必然是「今天完成」的(模型已濾) sprint: t.sprint ?? null, })) : route.tasks.map((t) => ({ title: t.title, status: t.status, order: t.order, scope: t.scope, age_minutes: t.age_minutes, is_today_write: t.is_today_write, sprint: null, })), route_meta: sprintRoute ? { source: 'gitea_sprint_board', // is_today=板檔今天(台北)有 commit 過;false → 頁面誠實標「今日任務板未更新」 is_today: boardUpdatedToday, updated_ago_minutes: boardAgo, sprint_files: waiting.sprint_files ?? null, } : { source: 'kbdb_dash_task', is_today: route.is_today, updated_ago_minutes: route.updated_ago_minutes, sprint_files: null, }, today_done: sprintRoute ? sprintRoute.today_done : route.today_done, today_total: sprintRoute ? sprintRoute.today_total : route.today_total, done_today_titles: sprintRoute ? sprintRoute.done_today_titles : null, waiting: waiting.items, waiting_meta: { source: waiting.source, updated_ago_minutes: waiting.updated_ago_minutes, stale: waiting.stale, sprint_files: waiting.sprint_files ?? null, note: waiting.note ?? null, // Gitea 快取層狀態(hit/miss;fallback 路徑為 null)——快取生效的客觀證據 cache: waitingCache, }, inbox_new: inboxNew, system: { kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false, embed: embedStatus ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } : null, graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null }, workflow_total: workflowTotal, }, kb: { entries_total: entriesTotal, wiki_card_total: wikiCardTotal, triplets_total: graphStats?.total ?? null, }, generated_at: new Date(now).toISOString(), }); }); // GET /console/kb-scale-data — 總庫「精耕層」規模(leo 2026-07-07 裁:45.8 萬 14-E 搬遷 // blocks 已 deprecated 之後要刪,頭部統計**不再拿遺產數字撐場面**,只顯示真的新的)。 // 免登入(純聚合計數、無內容原文,同 dashboard-data 標準)。3 個 subrequest,全是 // limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。 // 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。 consoleDashboardRouter.get('/console/kb-scale-data', async (c) => { const tenant = c.env.CONSOLE_TENANT || 'leo'; const { base, headers } = kbdbBase(c.env); const graphUrl = graphBase(c.env); const now = Date.now(); const [wikiCards, graphStats, embedStatus] = await Promise.all([ // limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」 fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>( `${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`, headers, ), fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`), fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers), ]); const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null); // 讀不到的欄位誠實回 null(頁面顯示「讀不到」),不編數字 return c.json({ wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null, wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs), triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null, embedded: embedStatus?.embedded ?? null, embed_enabled: embedStatus ? embedStatus.enabled === true : null, generated_at: new Date(now).toISOString(), }); }); // GET /console/settings-data — 設定頁的誠實系統值(目前只有 MCP token TTL 佔位區塊用)。 // TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`(mcp/src/types.ts,預設 2592000=30 天); // cypher 讀的是自己這份同名 var(deploy 時兩處要一致,#32 形態 config 同步教訓)—— // source 欄位如實標 env/default,頁面不假裝這是能遠端改的設定。 consoleDashboardRouter.get('/console/settings-data', (c) => { const raw = c.env.MCP_TOKEN_TTL; const parsed = raw ? parseInt(raw, 10) : NaN; const fromEnv = Number.isFinite(parsed) && parsed > 0; return c.json({ mcp_token_ttl_seconds: fromEnv ? parsed : 2592000, mcp_token_ttl_source: fromEnv ? 'env' : 'default', }); }); // GET /console/triage-data — 分流台資料(Mira Console 頁 7,Arcrun#9 收件夾改裝;原 // /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。 // **需 console session**(Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。 // 資料源二合一(kb-ingest SDD R7):entry_type=todo(Logseq 萃取,Arcrun#8 ingest 線)+ // entry_type=inbox(Telegram)。契約解析/三欄分流/計數=純函式 lib/console-triage-model.ts。 consoleDashboardRouter.get('/console/triage-data', async (c) => { const ok = await validateConsoleSession(c.env, c.req.header('authorization')); if (!ok) return c.json({ error: '需要登入(console session)' }, 401); const tenant = c.env.CONSOLE_TENANT || 'leo'; const [todoEntries, inboxEntries] = await Promise.all([ fetchEntries(c.env, tenant, 'todo', 500), fetchEntries(c.env, tenant, 'inbox', 200), ]); const model = buildTriageModel(todoEntries, inboxEntries); return c.json({ ...model, generated_at: new Date().toISOString() }); }); // POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。 // 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKEN(token 只能在 // server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用 // triage-data 同款 session 驗證,server 端做 KBDB PATCH(kbdbBase 慣例)。 // // PATCH content 需**整串回寫**(KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)—— // 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。 // 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。 // // ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)── // console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不 // 復活它;Logseq 改 DONE 的由萃取端 PATCH status:done(checked_via:"logseq")。 // console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。 consoleDashboardRouter.post('/console/triage-check', async (c) => { const ok = await validateConsoleSession(c.env, c.req.header('authorization')); if (!ok) return c.json({ error: '需要登入(console session)' }, 401); const body = await c.req.json().catch(() => null); const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : ''; if (!entryId) return c.json({ error: 'entry_id 必填' }, 400); const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check'; const tenant = c.env.CONSOLE_TENANT || 'leo'; const { base, headers } = kbdbBase(c.env); // 先 GET 原 entry(整串回寫的前提),順便守兩道邊界: // 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料); // 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。 const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>( `${base}/entries/${encodeURIComponent(entryId)}`, headers, ); const entry = got?.entry; if (!entry) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); // 不洩漏他租戶存在性 if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') { return c.json({ error: '只有分流台項目(todo/inbox)能在這裡勾掉' }, 400); } const newContent = applyTriageCheck(entry.content, action, new Date().toISOString()); const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, { method: 'PATCH', headers, body: JSON.stringify({ content: newContent }), }); if (!res.ok) return c.json({ error: `KBDB 回寫失敗(HTTP ${res.status})` }, 502); return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' }); }); function renderDashboardHtml(brand: string): string { return ` ${brand} 駕駛艙
${brand} 駕駛艙
載入中
今日完成
/
收件匣未處理
來自 Telegram
等你的事
載入中…
今日路線
  • 載入中…
    系統狀況live 健康信號
    載入中…
    每 60 秒自動刷新
    進入完整控制台 ›
    `; } // GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字) // 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG") // CONSOLE_PROFILE=rag(console-profile-trim):駕駛艙不屬企業版頁面 → 302 回 /console。 // 選 302 不選 404:舊書籤/外鏈直接落回產品頁,不給死路(只裁 UI 頁面,資料端點行為不動)。 consoleDashboardRouter.get('/console/dashboard', (c) => { if ((c.env.CONSOLE_PROFILE || 'full') === 'rag') return c.redirect('/console', 302); return c.html(renderDashboardHtml(c.env.CONSOLE_BRAND || 'Arcrun')); });