Files
Arcrun/cypher-executor/src/routes/console-dashboard.ts
T
uncle6me-web b223a69884 fix(portal): 藏書地圖看得到自己的知識——租戶字串改從「寫入端」來,不再拿環境變數預設值(Arcrun#108)
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。

病根(不是資料掉了,是讀寫兩端各拿一個來源):
  寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
    leo = `bfezv28v`)
  讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
    ——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
  ⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。

與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。

租戶字串該從哪裡來(本票的核心判斷):
  **從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
  不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
  帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
  #105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。

修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
   - `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
     丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
     偽裝成「你沒有資料」的元凶。
   - `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
     自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
   - 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
   但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
   都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
   ——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
   未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
   scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
   「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
   ——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。

留下的閘(規則存在但沒機制驗證=會再犯第三次):
  · 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
  · 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
  · 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
    逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
  規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。

沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。

驗證:
  cypher   vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
           tsc 5 個既有錯誤,零新增
  cli      node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
  閘       壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
  端到端   ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:58 +08:00

563 lines
29 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.
/**
* arcrun console 駕駛艙 dashboardT-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
* 2026-07-07 fix/console-dashboard-live-datastale 資料整修,總管交辦)
*
* ⚠️ 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/statusenabled: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 走 HTTPkbdbBase 慣例),不新增 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';
// Arcrun#108:租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT
// 過濾而看不到自己的資料——與 portal 同一個病,同一個修法。
import { knowledgeOwner } from '../lib/tenant';
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_URLvar+ GITEA_TOKENsecret,建議唯讀 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 的板先列);兩檔都沒有可解析的板 → nullfallback dash_task
const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
// 清單上次維護時間 = 現役 sprint 檔最後 commitprogress-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 APIcaches.default)、
* TTL 90sGITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
* 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
*
* - key:合成 URLCache 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 = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
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/missfallback 路徑為 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 = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const { base, headers } = kbdbBase(c.env);
const now = Date.now();
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 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,預設 259200030 天);
// 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 頁 7Arcrun#9 收件夾改裝;原
// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。
// **需 console session**Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。
// 資料源二合一(kb-ingest SDD R7):entry_type=todoLogseq 萃取,Arcrun#8 ingest 線)+
// entry_type=inboxTelegram)。契約解析/三欄分流/計數=純函式 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 = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
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_TOKENtoken 只能在
// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
// triage-data 同款 session 驗證,server 端做 KBDB PATCHkbdbBase 慣例)。
//
// 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:donechecked_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 = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
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' });
});