a5e4caf5cb
leo 2026-08-12 打開總圖看到:「0 個實體 · 0 條關聯/知識庫還沒有任何關聯—— 上傳文件後 AI 會自動織網」。**而他庫裡有 1854 條三元組。** 那句話會叫他去做一件不需要做的事。 三個獨立的洞疊起來才變成那句謊: ① console-dashboard.ts 打 kbdb-graph-plugin 沒帶認證(同段落打 kbdb 的兩支都有帶) ② 那顆 worker 不在更新的部署清單裡 ⇒ token 一換它就落單 ③ **畫面把 null 畫成 0**——後端已經誠實回 null 了,是前端把它變成謊話 為什麼一直沒被發現:graph plugin 原本身上沒有 token ⇒ 門開著 ⇒ 沒帶也進得去。 2026-08-12 輪替後它有了 token,門關上,401 才浮出來。 📍 repo:matrix/arcrun(cypher-executor/src/routes/、console-ui/public/) 📍 票:Leo/Arcrun#100
560 lines
29 KiB
TypeScript
560 lines
29 KiB
TypeScript
/**
|
||
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
|
||
* 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦)
|
||
*
|
||
* ⚠️ 2026-07-21 cypher-ui-split 第一刀:本檔只剩 **API 端點**。原本的
|
||
* `GET /console/dashboard`(單檔 HTML)已搬到 `console-ui/`(Cloudflare Pages 靜態站),
|
||
* 頁面改由 Pages 託管、資料仍打本檔的 `/console/dashboard-data`。
|
||
*
|
||
* 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
||
* - GET /console/dashboard-data:聚合 JSON。
|
||
* - 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, graphHeaders } 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';
|
||
|
||
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<KbdbEntry[]> {
|
||
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<T>(url: string, headers?: Record<string, string>): Promise<T | null> {
|
||
try {
|
||
const res = await fetch(url, headers ? { headers } : undefined);
|
||
if (!res.ok) return null;
|
||
return (await res.json()) as T;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 本租戶三元組的**真實總數**(null = 讀不到,畫面要顯示「讀不到」而非 0)。
|
||
*
|
||
* 🔴 Arcrun#100:不可以拿 graph-plugin `/triplets/stats` 的 `total` 當數量。
|
||
* 那支的 `total` 是**分頁長度**不是 COUNT——它走 `/records/by-template/triplet`(KBDB 端
|
||
* `searchByTemplate` 預設 limit=100、硬上限 500)且不帶 owner 過濾,所以 1854 條的庫
|
||
* 只會回 100。修好 401 之後若還讀它,畫面會從「0」變成「100」——一樣是假的。
|
||
* 真相源=KBDB `/records/triplet-stats`(真 SQL COUNT(*)、依 owner_id 過濾、無上限),
|
||
* 回 `{ success, stats: [{ library, triplet_count }] }`,加總即全庫條數。
|
||
*/
|
||
async function fetchTripletTotal(env: Bindings, tenant: string): Promise<number | null> {
|
||
const { base, headers } = kbdbBase(env);
|
||
const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>(
|
||
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`,
|
||
headers,
|
||
);
|
||
if (!data || !Array.isArray(data.stats)) return null;
|
||
let total = 0;
|
||
for (const row of data.stats) {
|
||
if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到,不半信半疑加總
|
||
total += row.triplet_count;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||
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<SprintSnapshot | null> {
|
||
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<SprintSnapshot | null>;
|
||
|
||
/**
|
||
* 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<unknown>) => 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,
|
||
tripletTotal,
|
||
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),
|
||
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
|
||
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
|
||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(
|
||
`${graphUrl}/triplets/stats`,
|
||
graphHeaders(c.env),
|
||
),
|
||
fetchTripletTotal(c.env, tenant),
|
||
// 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<string>();
|
||
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,
|
||
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
|
||
graph: { ok: graphStats !== null, triplets: tripletTotal },
|
||
workflow_total: workflowTotal,
|
||
},
|
||
kb: {
|
||
entries_total: entriesTotal,
|
||
wiki_card_total: wikiCardTotal,
|
||
triplets_total: tripletTotal,
|
||
},
|
||
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 now = Date.now();
|
||
const [wikiCards, tripletTotal, 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,
|
||
),
|
||
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
|
||
fetchTripletTotal(c.env, tenant),
|
||
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: tripletTotal,
|
||
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' });
|
||
});
|
||
|